mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Explore bulk actions (#15307)
* use id instead of index for object details and scrolling * long press package and hook * fix long press in review * search action group * multi select in explore * add bulk deletion to backend api * clean up * mimic behavior of review * don't open dialog on left click when mutli selecting * context menu on container ref * revert long press code * clean up
This commit is contained in:
		
							parent
							
								
									5475672a9d
								
							
						
					
					
						commit
						5f42caad03
					
				@ -1,4 +1,4 @@
 | 
				
			|||||||
from typing import Optional, Union
 | 
					from typing import List, Optional, Union
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from pydantic import BaseModel, Field
 | 
					from pydantic import BaseModel, Field
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -27,5 +27,9 @@ class EventsEndBody(BaseModel):
 | 
				
			|||||||
    end_time: Optional[float] = None
 | 
					    end_time: Optional[float] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class EventsDeleteBody(BaseModel):
 | 
				
			||||||
 | 
					    event_ids: List[str] = Field(title="The event IDs to delete")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SubmitPlusBody(BaseModel):
 | 
					class SubmitPlusBody(BaseModel):
 | 
				
			||||||
    include_annotation: int = Field(default=1)
 | 
					    include_annotation: int = Field(default=1)
 | 
				
			||||||
 | 
				
			|||||||
@ -16,6 +16,7 @@ from playhouse.shortcuts import model_to_dict
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from frigate.api.defs.events_body import (
 | 
					from frigate.api.defs.events_body import (
 | 
				
			||||||
    EventsCreateBody,
 | 
					    EventsCreateBody,
 | 
				
			||||||
 | 
					    EventsDeleteBody,
 | 
				
			||||||
    EventsDescriptionBody,
 | 
					    EventsDescriptionBody,
 | 
				
			||||||
    EventsEndBody,
 | 
					    EventsEndBody,
 | 
				
			||||||
    EventsSubLabelBody,
 | 
					    EventsSubLabelBody,
 | 
				
			||||||
@ -1036,35 +1037,65 @@ def regenerate_description(
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@router.delete("/events/{event_id}")
 | 
					def delete_single_event(event_id: str, request: Request) -> dict:
 | 
				
			||||||
def delete_event(request: Request, event_id: str):
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        event = Event.get(Event.id == event_id)
 | 
					        event = Event.get(Event.id == event_id)
 | 
				
			||||||
    except DoesNotExist:
 | 
					    except DoesNotExist:
 | 
				
			||||||
        return JSONResponse(
 | 
					        return {"success": False, "message": f"Event {event_id} not found"}
 | 
				
			||||||
            content=({"success": False, "message": "Event " + event_id + " not found"}),
 | 
					 | 
				
			||||||
            status_code=404,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    media_name = f"{event.camera}-{event.id}"
 | 
					    media_name = f"{event.camera}-{event.id}"
 | 
				
			||||||
    if event.has_snapshot:
 | 
					    if event.has_snapshot:
 | 
				
			||||||
        media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
 | 
					        snapshot_paths = [
 | 
				
			||||||
        media.unlink(missing_ok=True)
 | 
					            Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg"),
 | 
				
			||||||
        media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
 | 
					            Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        for media in snapshot_paths:
 | 
				
			||||||
            media.unlink(missing_ok=True)
 | 
					            media.unlink(missing_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    event.delete_instance()
 | 
					    event.delete_instance()
 | 
				
			||||||
    Timeline.delete().where(Timeline.source_id == event_id).execute()
 | 
					    Timeline.delete().where(Timeline.source_id == event_id).execute()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # If semantic search is enabled, update the index
 | 
					    # If semantic search is enabled, update the index
 | 
				
			||||||
    if request.app.frigate_config.semantic_search.enabled:
 | 
					    if request.app.frigate_config.semantic_search.enabled:
 | 
				
			||||||
        context: EmbeddingsContext = request.app.embeddings
 | 
					        context: EmbeddingsContext = request.app.embeddings
 | 
				
			||||||
        context.db.delete_embeddings_thumbnail(event_ids=[event_id])
 | 
					        context.db.delete_embeddings_thumbnail(event_ids=[event_id])
 | 
				
			||||||
        context.db.delete_embeddings_description(event_ids=[event_id])
 | 
					        context.db.delete_embeddings_description(event_ids=[event_id])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {"success": True, "message": f"Event {event_id} deleted"}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@router.delete("/events/{event_id}")
 | 
				
			||||||
 | 
					def delete_event(request: Request, event_id: str):
 | 
				
			||||||
 | 
					    result = delete_single_event(event_id, request)
 | 
				
			||||||
 | 
					    status_code = 200 if result["success"] else 404
 | 
				
			||||||
 | 
					    return JSONResponse(content=result, status_code=status_code)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@router.delete("/events/")
 | 
				
			||||||
 | 
					def delete_events(request: Request, body: EventsDeleteBody):
 | 
				
			||||||
 | 
					    if not body.event_ids:
 | 
				
			||||||
        return JSONResponse(
 | 
					        return JSONResponse(
 | 
				
			||||||
        content=({"success": True, "message": "Event " + event_id + " deleted"}),
 | 
					            content=({"success": False, "message": "No event IDs provided."}),
 | 
				
			||||||
        status_code=200,
 | 
					            status_code=404,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    deleted_events = []
 | 
				
			||||||
 | 
					    not_found_events = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for event_id in body.event_ids:
 | 
				
			||||||
 | 
					        result = delete_single_event(event_id, request)
 | 
				
			||||||
 | 
					        if result["success"]:
 | 
				
			||||||
 | 
					            deleted_events.append(event_id)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            not_found_events.append(event_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    response = {
 | 
				
			||||||
 | 
					        "success": True,
 | 
				
			||||||
 | 
					        "deleted_events": deleted_events,
 | 
				
			||||||
 | 
					        "not_found_events": not_found_events,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return JSONResponse(content=response, status_code=200)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@router.post("/events/{camera_name}/{label}/create")
 | 
					@router.post("/events/{camera_name}/{label}/create")
 | 
				
			||||||
def create_event(
 | 
					def create_event(
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										10
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -72,6 +72,7 @@
 | 
				
			|||||||
        "tailwind-merge": "^2.4.0",
 | 
					        "tailwind-merge": "^2.4.0",
 | 
				
			||||||
        "tailwind-scrollbar": "^3.1.0",
 | 
					        "tailwind-scrollbar": "^3.1.0",
 | 
				
			||||||
        "tailwindcss-animate": "^1.0.7",
 | 
					        "tailwindcss-animate": "^1.0.7",
 | 
				
			||||||
 | 
					        "use-long-press": "^3.2.0",
 | 
				
			||||||
        "vaul": "^0.9.1",
 | 
					        "vaul": "^0.9.1",
 | 
				
			||||||
        "vite-plugin-monaco-editor": "^1.1.0",
 | 
					        "vite-plugin-monaco-editor": "^1.1.0",
 | 
				
			||||||
        "zod": "^3.23.8"
 | 
					        "zod": "^3.23.8"
 | 
				
			||||||
@ -8709,6 +8710,15 @@
 | 
				
			|||||||
        "scheduler": ">=0.19.0"
 | 
					        "scheduler": ">=0.19.0"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/use-long-press": {
 | 
				
			||||||
 | 
					      "version": "3.2.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "react": ">=16.8.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/use-sidecar": {
 | 
					    "node_modules/use-sidecar": {
 | 
				
			||||||
      "version": "1.1.2",
 | 
					      "version": "1.1.2",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
 | 
				
			||||||
 | 
				
			|||||||
@ -78,6 +78,7 @@
 | 
				
			|||||||
    "tailwind-merge": "^2.4.0",
 | 
					    "tailwind-merge": "^2.4.0",
 | 
				
			||||||
    "tailwind-scrollbar": "^3.1.0",
 | 
					    "tailwind-scrollbar": "^3.1.0",
 | 
				
			||||||
    "tailwindcss-animate": "^1.0.7",
 | 
					    "tailwindcss-animate": "^1.0.7",
 | 
				
			||||||
 | 
					    "use-long-press": "^3.2.0",
 | 
				
			||||||
    "vaul": "^0.9.1",
 | 
					    "vaul": "^0.9.1",
 | 
				
			||||||
    "vite-plugin-monaco-editor": "^1.1.0",
 | 
					    "vite-plugin-monaco-editor": "^1.1.0",
 | 
				
			||||||
    "zod": "^3.23.8"
 | 
					    "zod": "^3.23.8"
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { useCallback, useMemo } from "react";
 | 
					import { useMemo } from "react";
 | 
				
			||||||
import { useApiHost } from "@/api";
 | 
					import { useApiHost } from "@/api";
 | 
				
			||||||
import { getIconForLabel } from "@/utils/iconUtil";
 | 
					import { getIconForLabel } from "@/utils/iconUtil";
 | 
				
			||||||
import useSWR from "swr";
 | 
					import useSWR from "swr";
 | 
				
			||||||
@ -12,10 +12,11 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
 | 
				
			|||||||
import { SearchResult } from "@/types/search";
 | 
					import { SearchResult } from "@/types/search";
 | 
				
			||||||
import { cn } from "@/lib/utils";
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
 | 
					import { TooltipPortal } from "@radix-ui/react-tooltip";
 | 
				
			||||||
 | 
					import useContextMenu from "@/hooks/use-contextmenu";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SearchThumbnailProps = {
 | 
					type SearchThumbnailProps = {
 | 
				
			||||||
  searchResult: SearchResult;
 | 
					  searchResult: SearchResult;
 | 
				
			||||||
  onClick: (searchResult: SearchResult) => void;
 | 
					  onClick: (searchResult: SearchResult, ctrl: boolean, detail: boolean) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function SearchThumbnail({
 | 
					export default function SearchThumbnail({
 | 
				
			||||||
@ -28,9 +29,9 @@ export default function SearchThumbnail({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // interactions
 | 
					  // interactions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleOnClick = useCallback(() => {
 | 
					  useContextMenu(imgRef, () => {
 | 
				
			||||||
    onClick(searchResult);
 | 
					    onClick(searchResult, true, false);
 | 
				
			||||||
  }, [searchResult, onClick]);
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const objectLabel = useMemo(() => {
 | 
					  const objectLabel = useMemo(() => {
 | 
				
			||||||
    if (
 | 
					    if (
 | 
				
			||||||
@ -45,7 +46,10 @@ export default function SearchThumbnail({
 | 
				
			|||||||
  }, [config, searchResult]);
 | 
					  }, [config, searchResult]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="relative size-full cursor-pointer" onClick={handleOnClick}>
 | 
					    <div
 | 
				
			||||||
 | 
					      className="relative size-full cursor-pointer"
 | 
				
			||||||
 | 
					      onClick={() => onClick(searchResult, false, true)}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <ImageLoadingIndicator
 | 
					      <ImageLoadingIndicator
 | 
				
			||||||
        className="absolute inset-0"
 | 
					        className="absolute inset-0"
 | 
				
			||||||
        imgLoaded={imgLoaded}
 | 
					        imgLoaded={imgLoaded}
 | 
				
			||||||
@ -79,7 +83,7 @@ export default function SearchThumbnail({
 | 
				
			|||||||
                <div className="mx-3 pb-1 text-sm text-white">
 | 
					                <div className="mx-3 pb-1 text-sm text-white">
 | 
				
			||||||
                  <Chip
 | 
					                  <Chip
 | 
				
			||||||
                    className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`}
 | 
					                    className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`}
 | 
				
			||||||
                    onClick={() => onClick(searchResult)}
 | 
					                    onClick={() => onClick(searchResult, false, true)}
 | 
				
			||||||
                  >
 | 
					                  >
 | 
				
			||||||
                    {getIconForLabel(objectLabel, "size-3 text-white")}
 | 
					                    {getIconForLabel(objectLabel, "size-3 text-white")}
 | 
				
			||||||
                    {Math.round(
 | 
					                    {Math.round(
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										132
									
								
								web/src/components/filter/SearchActionGroup.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								web/src/components/filter/SearchActionGroup.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,132 @@
 | 
				
			|||||||
 | 
					import { useCallback, useState } from "react";
 | 
				
			||||||
 | 
					import axios from "axios";
 | 
				
			||||||
 | 
					import { Button, buttonVariants } from "../ui/button";
 | 
				
			||||||
 | 
					import { isDesktop } from "react-device-detect";
 | 
				
			||||||
 | 
					import { HiTrash } from "react-icons/hi";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  AlertDialog,
 | 
				
			||||||
 | 
					  AlertDialogAction,
 | 
				
			||||||
 | 
					  AlertDialogCancel,
 | 
				
			||||||
 | 
					  AlertDialogContent,
 | 
				
			||||||
 | 
					  AlertDialogDescription,
 | 
				
			||||||
 | 
					  AlertDialogFooter,
 | 
				
			||||||
 | 
					  AlertDialogHeader,
 | 
				
			||||||
 | 
					  AlertDialogTitle,
 | 
				
			||||||
 | 
					} from "../ui/alert-dialog";
 | 
				
			||||||
 | 
					import useKeyboardListener from "@/hooks/use-keyboard-listener";
 | 
				
			||||||
 | 
					import { toast } from "sonner";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SearchActionGroupProps = {
 | 
				
			||||||
 | 
					  selectedObjects: string[];
 | 
				
			||||||
 | 
					  setSelectedObjects: (ids: string[]) => void;
 | 
				
			||||||
 | 
					  pullLatestData: () => void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export default function SearchActionGroup({
 | 
				
			||||||
 | 
					  selectedObjects,
 | 
				
			||||||
 | 
					  setSelectedObjects,
 | 
				
			||||||
 | 
					  pullLatestData,
 | 
				
			||||||
 | 
					}: SearchActionGroupProps) {
 | 
				
			||||||
 | 
					  const onClearSelected = useCallback(() => {
 | 
				
			||||||
 | 
					    setSelectedObjects([]);
 | 
				
			||||||
 | 
					  }, [setSelectedObjects]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onDelete = useCallback(async () => {
 | 
				
			||||||
 | 
					    await axios
 | 
				
			||||||
 | 
					      .delete(`events/`, {
 | 
				
			||||||
 | 
					        data: { event_ids: selectedObjects },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .then((resp) => {
 | 
				
			||||||
 | 
					        if (resp.status == 200) {
 | 
				
			||||||
 | 
					          toast.success("Tracked objects deleted successfully.", {
 | 
				
			||||||
 | 
					            position: "top-center",
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					          setSelectedObjects([]);
 | 
				
			||||||
 | 
					          pullLatestData();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .catch(() => {
 | 
				
			||||||
 | 
					        toast.error("Failed to delete tracked objects.", {
 | 
				
			||||||
 | 
					          position: "top-center",
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					  }, [selectedObjects, setSelectedObjects, pullLatestData]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
 | 
				
			||||||
 | 
					  const [bypassDialog, setBypassDialog] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useKeyboardListener(["Shift"], (_, modifiers) => {
 | 
				
			||||||
 | 
					    setBypassDialog(modifiers.shift);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleDelete = useCallback(() => {
 | 
				
			||||||
 | 
					    if (bypassDialog) {
 | 
				
			||||||
 | 
					      onDelete();
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      setDeleteDialogOpen(true);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [bypassDialog, onDelete]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <AlertDialog
 | 
				
			||||||
 | 
					        open={deleteDialogOpen}
 | 
				
			||||||
 | 
					        onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <AlertDialogContent>
 | 
				
			||||||
 | 
					          <AlertDialogHeader>
 | 
				
			||||||
 | 
					            <AlertDialogTitle>Confirm Delete</AlertDialogTitle>
 | 
				
			||||||
 | 
					          </AlertDialogHeader>
 | 
				
			||||||
 | 
					          <AlertDialogDescription>
 | 
				
			||||||
 | 
					            Deleting these {selectedObjects.length} tracked objects removes the
 | 
				
			||||||
 | 
					            snapshot, any saved embeddings, and any associated object lifecycle
 | 
				
			||||||
 | 
					            entries. Recorded footage of these tracked objects in History view
 | 
				
			||||||
 | 
					            will <em>NOT</em> be deleted.
 | 
				
			||||||
 | 
					            <br />
 | 
				
			||||||
 | 
					            <br />
 | 
				
			||||||
 | 
					            Are you sure you want to proceed?
 | 
				
			||||||
 | 
					            <br />
 | 
				
			||||||
 | 
					            <br />
 | 
				
			||||||
 | 
					            Hold the <em>Shift</em> key to bypass this dialog in the future.
 | 
				
			||||||
 | 
					          </AlertDialogDescription>
 | 
				
			||||||
 | 
					          <AlertDialogFooter>
 | 
				
			||||||
 | 
					            <AlertDialogCancel>Cancel</AlertDialogCancel>
 | 
				
			||||||
 | 
					            <AlertDialogAction
 | 
				
			||||||
 | 
					              className={buttonVariants({ variant: "destructive" })}
 | 
				
			||||||
 | 
					              onClick={onDelete}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              Delete
 | 
				
			||||||
 | 
					            </AlertDialogAction>
 | 
				
			||||||
 | 
					          </AlertDialogFooter>
 | 
				
			||||||
 | 
					        </AlertDialogContent>
 | 
				
			||||||
 | 
					      </AlertDialog>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
 | 
				
			||||||
 | 
					        <div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
 | 
				
			||||||
 | 
					          <div className="p-1">{`${selectedObjects.length} selected`}</div>
 | 
				
			||||||
 | 
					          <div className="p-1">{"|"}</div>
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
 | 
				
			||||||
 | 
					            onClick={onClearSelected}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Unselect
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div className="flex items-center gap-1 md:gap-2">
 | 
				
			||||||
 | 
					          <Button
 | 
				
			||||||
 | 
					            className="flex items-center gap-2 p-2"
 | 
				
			||||||
 | 
					            aria-label="Delete"
 | 
				
			||||||
 | 
					            size="sm"
 | 
				
			||||||
 | 
					            onClick={handleDelete}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <HiTrash className="text-secondary-foreground" />
 | 
				
			||||||
 | 
					            {isDesktop && (
 | 
				
			||||||
 | 
					              <div className="text-primary">
 | 
				
			||||||
 | 
					                {bypassDialog ? "Delete Now" : "Delete"}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										54
									
								
								web/src/hooks/use-press.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								web/src/hooks/use-press.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					// https://gist.github.com/cpojer/641bf305e6185006ea453e7631b80f95
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { useCallback, useState } from "react";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  LongPressCallbackMeta,
 | 
				
			||||||
 | 
					  LongPressReactEvents,
 | 
				
			||||||
 | 
					  useLongPress,
 | 
				
			||||||
 | 
					} from "use-long-press";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function usePress(
 | 
				
			||||||
 | 
					  options: Omit<Parameters<typeof useLongPress>[1], "onCancel" | "onStart"> & {
 | 
				
			||||||
 | 
					    onLongPress: NonNullable<Parameters<typeof useLongPress>[0]>;
 | 
				
			||||||
 | 
					    onPress: (event: LongPressReactEvents<Element>) => void;
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  const { onLongPress, onPress, ...actualOptions } = options;
 | 
				
			||||||
 | 
					  const [hasLongPress, setHasLongPress] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onCancel = useCallback(() => {
 | 
				
			||||||
 | 
					    if (hasLongPress) {
 | 
				
			||||||
 | 
					      setHasLongPress(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [hasLongPress]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const bind = useLongPress(
 | 
				
			||||||
 | 
					    useCallback(
 | 
				
			||||||
 | 
					      (
 | 
				
			||||||
 | 
					        event: LongPressReactEvents<Element>,
 | 
				
			||||||
 | 
					        meta: LongPressCallbackMeta<unknown>,
 | 
				
			||||||
 | 
					      ) => {
 | 
				
			||||||
 | 
					        setHasLongPress(true);
 | 
				
			||||||
 | 
					        onLongPress(event, meta);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      [onLongPress],
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      ...actualOptions,
 | 
				
			||||||
 | 
					      onCancel,
 | 
				
			||||||
 | 
					      onStart: onCancel,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return useCallback(
 | 
				
			||||||
 | 
					    () => ({
 | 
				
			||||||
 | 
					      ...bind(),
 | 
				
			||||||
 | 
					      onClick: (event: LongPressReactEvents<HTMLDivElement>) => {
 | 
				
			||||||
 | 
					        if (!hasLongPress) {
 | 
				
			||||||
 | 
					          onPress(event);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    [bind, hasLongPress, onPress],
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -26,7 +26,7 @@ type ExploreViewProps = {
 | 
				
			|||||||
  searchDetail: SearchResult | undefined;
 | 
					  searchDetail: SearchResult | undefined;
 | 
				
			||||||
  setSearchDetail: (search: SearchResult | undefined) => void;
 | 
					  setSearchDetail: (search: SearchResult | undefined) => void;
 | 
				
			||||||
  setSimilaritySearch: (search: SearchResult) => void;
 | 
					  setSimilaritySearch: (search: SearchResult) => void;
 | 
				
			||||||
  onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
 | 
					  onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function ExploreView({
 | 
					export default function ExploreView({
 | 
				
			||||||
@ -125,7 +125,7 @@ type ThumbnailRowType = {
 | 
				
			|||||||
  setSearchDetail: (search: SearchResult | undefined) => void;
 | 
					  setSearchDetail: (search: SearchResult | undefined) => void;
 | 
				
			||||||
  mutate: () => void;
 | 
					  mutate: () => void;
 | 
				
			||||||
  setSimilaritySearch: (search: SearchResult) => void;
 | 
					  setSimilaritySearch: (search: SearchResult) => void;
 | 
				
			||||||
  onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
 | 
					  onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function ThumbnailRow({
 | 
					function ThumbnailRow({
 | 
				
			||||||
@ -205,7 +205,7 @@ type ExploreThumbnailImageProps = {
 | 
				
			|||||||
  setSearchDetail: (search: SearchResult | undefined) => void;
 | 
					  setSearchDetail: (search: SearchResult | undefined) => void;
 | 
				
			||||||
  mutate: () => void;
 | 
					  mutate: () => void;
 | 
				
			||||||
  setSimilaritySearch: (search: SearchResult) => void;
 | 
					  setSimilaritySearch: (search: SearchResult) => void;
 | 
				
			||||||
  onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
 | 
					  onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
function ExploreThumbnailImage({
 | 
					function ExploreThumbnailImage({
 | 
				
			||||||
  event,
 | 
					  event,
 | 
				
			||||||
@ -225,11 +225,11 @@ function ExploreThumbnailImage({
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleShowObjectLifecycle = () => {
 | 
					  const handleShowObjectLifecycle = () => {
 | 
				
			||||||
    onSelectSearch(event, 0, "object lifecycle");
 | 
					    onSelectSearch(event, false, "object lifecycle");
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleShowSnapshot = () => {
 | 
					  const handleShowSnapshot = () => {
 | 
				
			||||||
    onSelectSearch(event, 0, "snapshot");
 | 
					    onSelectSearch(event, false, "snapshot");
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
 | 
				
			|||||||
@ -30,6 +30,7 @@ import {
 | 
				
			|||||||
} from "@/components/ui/tooltip";
 | 
					} from "@/components/ui/tooltip";
 | 
				
			||||||
import Chip from "@/components/indicators/Chip";
 | 
					import Chip from "@/components/indicators/Chip";
 | 
				
			||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
 | 
					import { TooltipPortal } from "@radix-ui/react-tooltip";
 | 
				
			||||||
 | 
					import SearchActionGroup from "@/components/filter/SearchActionGroup";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SearchViewProps = {
 | 
					type SearchViewProps = {
 | 
				
			||||||
  search: string;
 | 
					  search: string;
 | 
				
			||||||
@ -181,20 +182,53 @@ export default function SearchView({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // search interaction
 | 
					  // search interaction
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
 | 
					  const [selectedObjects, setSelectedObjects] = useState<string[]>([]);
 | 
				
			||||||
  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
 | 
					  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onSelectSearch = useCallback(
 | 
					  const onSelectSearch = useCallback(
 | 
				
			||||||
    (item: SearchResult, index: number, page: SearchTab = "details") => {
 | 
					    (item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
 | 
				
			||||||
 | 
					      if (selectedObjects.length > 1 || ctrl) {
 | 
				
			||||||
 | 
					        const index = selectedObjects.indexOf(item.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (index != -1) {
 | 
				
			||||||
 | 
					          if (selectedObjects.length == 1) {
 | 
				
			||||||
 | 
					            setSelectedObjects([]);
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            const copy = [
 | 
				
			||||||
 | 
					              ...selectedObjects.slice(0, index),
 | 
				
			||||||
 | 
					              ...selectedObjects.slice(index + 1),
 | 
				
			||||||
 | 
					            ];
 | 
				
			||||||
 | 
					            setSelectedObjects(copy);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          const copy = [...selectedObjects];
 | 
				
			||||||
 | 
					          copy.push(item.id);
 | 
				
			||||||
 | 
					          setSelectedObjects(copy);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
        setPage(page);
 | 
					        setPage(page);
 | 
				
			||||||
        setSearchDetail(item);
 | 
					        setSearchDetail(item);
 | 
				
			||||||
      setSelectedIndex(index);
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [],
 | 
					    [selectedObjects],
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onSelectAllObjects = useCallback(() => {
 | 
				
			||||||
 | 
					    if (!uniqueResults || uniqueResults.length == 0) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (selectedObjects.length < uniqueResults.length) {
 | 
				
			||||||
 | 
					      setSelectedObjects(uniqueResults.map((value) => value.id));
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      setSelectedObjects([]);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [uniqueResults, selectedObjects]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    setSelectedIndex(0);
 | 
					    setSelectedObjects([]);
 | 
				
			||||||
 | 
					    // unselect items when search term or filter changes
 | 
				
			||||||
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
  }, [searchTerm, searchFilter]);
 | 
					  }, [searchTerm, searchFilter]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // confidence score
 | 
					  // confidence score
 | 
				
			||||||
@ -243,23 +277,44 @@ export default function SearchView({
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      switch (key) {
 | 
					      switch (key) {
 | 
				
			||||||
        case "ArrowLeft":
 | 
					        case "a":
 | 
				
			||||||
          setSelectedIndex((prevIndex) => {
 | 
					          if (modifiers.ctrl) {
 | 
				
			||||||
            const newIndex =
 | 
					            onSelectAllObjects();
 | 
				
			||||||
              prevIndex === null
 | 
					          }
 | 
				
			||||||
                ? uniqueResults.length - 1
 | 
					 | 
				
			||||||
                : (prevIndex - 1 + uniqueResults.length) % uniqueResults.length;
 | 
					 | 
				
			||||||
            setSearchDetail(uniqueResults[newIndex]);
 | 
					 | 
				
			||||||
            return newIndex;
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        case "ArrowRight":
 | 
					        case "ArrowLeft":
 | 
				
			||||||
          setSelectedIndex((prevIndex) => {
 | 
					          if (uniqueResults.length > 0) {
 | 
				
			||||||
 | 
					            const currentIndex = searchDetail
 | 
				
			||||||
 | 
					              ? uniqueResults.findIndex(
 | 
				
			||||||
 | 
					                  (result) => result.id === searchDetail.id,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              : -1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            const newIndex =
 | 
					            const newIndex =
 | 
				
			||||||
              prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length;
 | 
					              currentIndex === -1
 | 
				
			||||||
 | 
					                ? uniqueResults.length - 1
 | 
				
			||||||
 | 
					                : (currentIndex - 1 + uniqueResults.length) %
 | 
				
			||||||
 | 
					                  uniqueResults.length;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            setSearchDetail(uniqueResults[newIndex]);
 | 
					            setSearchDetail(uniqueResults[newIndex]);
 | 
				
			||||||
            return newIndex;
 | 
					          }
 | 
				
			||||||
          });
 | 
					          break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        case "ArrowRight":
 | 
				
			||||||
 | 
					          if (uniqueResults.length > 0) {
 | 
				
			||||||
 | 
					            const currentIndex = searchDetail
 | 
				
			||||||
 | 
					              ? uniqueResults.findIndex(
 | 
				
			||||||
 | 
					                  (result) => result.id === searchDetail.id,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					              : -1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const newIndex =
 | 
				
			||||||
 | 
					              currentIndex === -1
 | 
				
			||||||
 | 
					                ? 0
 | 
				
			||||||
 | 
					                : (currentIndex + 1) % uniqueResults.length;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            setSearchDetail(uniqueResults[newIndex]);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        case "PageDown":
 | 
					        case "PageDown":
 | 
				
			||||||
          contentRef.current?.scrollBy({
 | 
					          contentRef.current?.scrollBy({
 | 
				
			||||||
@ -275,32 +330,80 @@ export default function SearchView({
 | 
				
			|||||||
          break;
 | 
					          break;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [uniqueResults, inputFocused],
 | 
					    [uniqueResults, inputFocused, onSelectAllObjects, searchDetail],
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useKeyboardListener(
 | 
					  useKeyboardListener(
 | 
				
			||||||
    ["ArrowLeft", "ArrowRight", "PageDown", "PageUp"],
 | 
					    ["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"],
 | 
				
			||||||
    onKeyboardShortcut,
 | 
					    onKeyboardShortcut,
 | 
				
			||||||
    !inputFocused,
 | 
					    !inputFocused,
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // scroll into view
 | 
					  // scroll into view
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [prevSearchDetail, setPrevSearchDetail] = useState<
 | 
				
			||||||
 | 
					    SearchResult | undefined
 | 
				
			||||||
 | 
					  >();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // keep track of previous ref to outline thumbnail when dialog closes
 | 
				
			||||||
 | 
					  const prevSearchDetailRef = useRef<SearchResult | undefined>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (
 | 
					    if (searchDetail === undefined && prevSearchDetailRef.current) {
 | 
				
			||||||
      selectedIndex !== null &&
 | 
					      setPrevSearchDetail(prevSearchDetailRef.current);
 | 
				
			||||||
      uniqueResults &&
 | 
					    }
 | 
				
			||||||
      itemRefs.current?.[selectedIndex]
 | 
					    prevSearchDetailRef.current = searchDetail;
 | 
				
			||||||
    ) {
 | 
					  }, [searchDetail]);
 | 
				
			||||||
      scrollIntoView(itemRefs.current[selectedIndex], {
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (uniqueResults && itemRefs.current && prevSearchDetail) {
 | 
				
			||||||
 | 
					      const selectedIndex = uniqueResults.findIndex(
 | 
				
			||||||
 | 
					        (result) => result.id === prevSearchDetail.id,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const parent = itemRefs.current[selectedIndex];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (selectedIndex !== -1 && parent) {
 | 
				
			||||||
 | 
					        const target = parent.querySelector(".review-item-ring");
 | 
				
			||||||
 | 
					        if (target) {
 | 
				
			||||||
 | 
					          scrollIntoView(target, {
 | 
				
			||||||
 | 
					            block: "center",
 | 
				
			||||||
 | 
					            behavior: "smooth",
 | 
				
			||||||
 | 
					            scrollMode: "if-needed",
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					          target.classList.add(`outline-selected`);
 | 
				
			||||||
 | 
					          target.classList.remove("outline-transparent");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          setTimeout(() => {
 | 
				
			||||||
 | 
					            target.classList.remove(`outline-selected`);
 | 
				
			||||||
 | 
					            target.classList.add("outline-transparent");
 | 
				
			||||||
 | 
					          }, 3000);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // we only want to scroll when the dialog closes
 | 
				
			||||||
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
 | 
					  }, [prevSearchDetail]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (uniqueResults && itemRefs.current && searchDetail) {
 | 
				
			||||||
 | 
					      const selectedIndex = uniqueResults.findIndex(
 | 
				
			||||||
 | 
					        (result) => result.id === searchDetail.id,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const parent = itemRefs.current[selectedIndex];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (selectedIndex !== -1 && parent) {
 | 
				
			||||||
 | 
					        scrollIntoView(parent, {
 | 
				
			||||||
          block: "center",
 | 
					          block: "center",
 | 
				
			||||||
          behavior: "smooth",
 | 
					          behavior: "smooth",
 | 
				
			||||||
          scrollMode: "if-needed",
 | 
					          scrollMode: "if-needed",
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    // we only want to scroll when the index changes
 | 
					    }
 | 
				
			||||||
 | 
					    // we only want to scroll when changing the detail pane
 | 
				
			||||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
  }, [selectedIndex]);
 | 
					  }, [searchDetail]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // observer for loading more
 | 
					  // observer for loading more
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -369,6 +472,8 @@ export default function SearchView({
 | 
				
			|||||||
        {hasExistingSearch && (
 | 
					        {hasExistingSearch && (
 | 
				
			||||||
          <ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
 | 
					          <ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
 | 
				
			||||||
            <div className="flex flex-row gap-2">
 | 
					            <div className="flex flex-row gap-2">
 | 
				
			||||||
 | 
					              {selectedObjects.length == 0 ? (
 | 
				
			||||||
 | 
					                <>
 | 
				
			||||||
                  <SearchFilterGroup
 | 
					                  <SearchFilterGroup
 | 
				
			||||||
                    className={cn(
 | 
					                    className={cn(
 | 
				
			||||||
                      "w-full justify-between md:justify-start lg:justify-end",
 | 
					                      "w-full justify-between md:justify-start lg:justify-end",
 | 
				
			||||||
@ -385,6 +490,21 @@ export default function SearchView({
 | 
				
			|||||||
                    onUpdateFilter={onUpdateFilter}
 | 
					                    onUpdateFilter={onUpdateFilter}
 | 
				
			||||||
                  />
 | 
					                  />
 | 
				
			||||||
                  <ScrollBar orientation="horizontal" className="h-0" />
 | 
					                  <ScrollBar orientation="horizontal" className="h-0" />
 | 
				
			||||||
 | 
					                </>
 | 
				
			||||||
 | 
					              ) : (
 | 
				
			||||||
 | 
					                <div
 | 
				
			||||||
 | 
					                  className={cn(
 | 
				
			||||||
 | 
					                    "scrollbar-container flex justify-center gap-2 overflow-x-auto",
 | 
				
			||||||
 | 
					                    "h-10 w-full justify-between md:justify-start lg:justify-end",
 | 
				
			||||||
 | 
					                  )}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  <SearchActionGroup
 | 
				
			||||||
 | 
					                    selectedObjects={selectedObjects}
 | 
				
			||||||
 | 
					                    setSelectedObjects={setSelectedObjects}
 | 
				
			||||||
 | 
					                    pullLatestData={refresh}
 | 
				
			||||||
 | 
					                  />
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              )}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </ScrollArea>
 | 
					          </ScrollArea>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
@ -412,14 +532,14 @@ export default function SearchView({
 | 
				
			|||||||
          <div className={gridClassName}>
 | 
					          <div className={gridClassName}>
 | 
				
			||||||
            {uniqueResults &&
 | 
					            {uniqueResults &&
 | 
				
			||||||
              uniqueResults.map((value, index) => {
 | 
					              uniqueResults.map((value, index) => {
 | 
				
			||||||
                const selected = selectedIndex === index;
 | 
					                const selected = selectedObjects.includes(value.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                return (
 | 
					                return (
 | 
				
			||||||
                  <div
 | 
					                  <div
 | 
				
			||||||
                    key={value.id}
 | 
					                    key={value.id}
 | 
				
			||||||
                    ref={(item) => (itemRefs.current[index] = item)}
 | 
					                    ref={(item) => (itemRefs.current[index] = item)}
 | 
				
			||||||
                    data-start={value.start_time}
 | 
					                    data-start={value.start_time}
 | 
				
			||||||
                    className="review-item relative flex flex-col rounded-lg"
 | 
					                    className="relative flex flex-col rounded-lg"
 | 
				
			||||||
                  >
 | 
					                  >
 | 
				
			||||||
                    <div
 | 
					                    <div
 | 
				
			||||||
                      className={cn(
 | 
					                      className={cn(
 | 
				
			||||||
@ -428,7 +548,20 @@ export default function SearchView({
 | 
				
			|||||||
                    >
 | 
					                    >
 | 
				
			||||||
                      <SearchThumbnail
 | 
					                      <SearchThumbnail
 | 
				
			||||||
                        searchResult={value}
 | 
					                        searchResult={value}
 | 
				
			||||||
                        onClick={() => onSelectSearch(value, index)}
 | 
					                        onClick={(
 | 
				
			||||||
 | 
					                          value: SearchResult,
 | 
				
			||||||
 | 
					                          ctrl: boolean,
 | 
				
			||||||
 | 
					                          detail: boolean,
 | 
				
			||||||
 | 
					                        ) => {
 | 
				
			||||||
 | 
					                          if (detail && selectedObjects.length == 0) {
 | 
				
			||||||
 | 
					                            setSearchDetail(value);
 | 
				
			||||||
 | 
					                          } else {
 | 
				
			||||||
 | 
					                            onSelectSearch(
 | 
				
			||||||
 | 
					                              value,
 | 
				
			||||||
 | 
					                              ctrl || selectedObjects.length > 0,
 | 
				
			||||||
 | 
					                            );
 | 
				
			||||||
 | 
					                          }
 | 
				
			||||||
 | 
					                        }}
 | 
				
			||||||
                      />
 | 
					                      />
 | 
				
			||||||
                      {(searchTerm ||
 | 
					                      {(searchTerm ||
 | 
				
			||||||
                        searchFilter?.search_type?.includes("similarity")) && (
 | 
					                        searchFilter?.search_type?.includes("similarity")) && (
 | 
				
			||||||
@ -469,10 +602,10 @@ export default function SearchView({
 | 
				
			|||||||
                        }}
 | 
					                        }}
 | 
				
			||||||
                        refreshResults={refresh}
 | 
					                        refreshResults={refresh}
 | 
				
			||||||
                        showObjectLifecycle={() =>
 | 
					                        showObjectLifecycle={() =>
 | 
				
			||||||
                          onSelectSearch(value, index, "object lifecycle")
 | 
					                          onSelectSearch(value, false, "object lifecycle")
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                        showSnapshot={() =>
 | 
					                        showSnapshot={() =>
 | 
				
			||||||
                          onSelectSearch(value, index, "snapshot")
 | 
					                          onSelectSearch(value, false, "snapshot")
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                      />
 | 
					                      />
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user