mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-18 00:16:41 +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
|
||||
|
||||
@ -27,5 +27,9 @@ class EventsEndBody(BaseModel):
|
||||
end_time: Optional[float] = None
|
||||
|
||||
|
||||
class EventsDeleteBody(BaseModel):
|
||||
event_ids: List[str] = Field(title="The event IDs to delete")
|
||||
|
||||
|
||||
class SubmitPlusBody(BaseModel):
|
||||
include_annotation: int = Field(default=1)
|
||||
|
@ -16,6 +16,7 @@ from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.defs.events_body import (
|
||||
EventsCreateBody,
|
||||
EventsDeleteBody,
|
||||
EventsDescriptionBody,
|
||||
EventsEndBody,
|
||||
EventsSubLabelBody,
|
||||
@ -1036,34 +1037,64 @@ def regenerate_description(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}")
|
||||
def delete_event(request: Request, event_id: str):
|
||||
def delete_single_event(event_id: str, request: Request) -> dict:
|
||||
try:
|
||||
event = Event.get(Event.id == event_id)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Event " + event_id + " not found"}),
|
||||
status_code=404,
|
||||
)
|
||||
return {"success": False, "message": f"Event {event_id} not found"}
|
||||
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
if event.has_snapshot:
|
||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||
media.unlink(missing_ok=True)
|
||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||
media.unlink(missing_ok=True)
|
||||
snapshot_paths = [
|
||||
Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg"),
|
||||
Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"),
|
||||
]
|
||||
for media in snapshot_paths:
|
||||
media.unlink(missing_ok=True)
|
||||
|
||||
event.delete_instance()
|
||||
Timeline.delete().where(Timeline.source_id == event_id).execute()
|
||||
|
||||
# If semantic search is enabled, update the index
|
||||
if request.app.frigate_config.semantic_search.enabled:
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
context.db.delete_embeddings_thumbnail(event_ids=[event_id])
|
||||
context.db.delete_embeddings_description(event_ids=[event_id])
|
||||
return JSONResponse(
|
||||
content=({"success": True, "message": "Event " + event_id + " deleted"}),
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
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(
|
||||
content=({"success": False, "message": "No event IDs provided."}),
|
||||
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")
|
||||
|
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@ -72,6 +72,7 @@
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"vaul": "^0.9.1",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.23.8"
|
||||
@ -8709,6 +8710,15 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
|
||||
|
@ -78,6 +78,7 @@
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.2.0",
|
||||
"vaul": "^0.9.1",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.23.8"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import useSWR from "swr";
|
||||
@ -12,10 +12,11 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
|
||||
type SearchThumbnailProps = {
|
||||
searchResult: SearchResult;
|
||||
onClick: (searchResult: SearchResult) => void;
|
||||
onClick: (searchResult: SearchResult, ctrl: boolean, detail: boolean) => void;
|
||||
};
|
||||
|
||||
export default function SearchThumbnail({
|
||||
@ -28,9 +29,9 @@ export default function SearchThumbnail({
|
||||
|
||||
// interactions
|
||||
|
||||
const handleOnClick = useCallback(() => {
|
||||
onClick(searchResult);
|
||||
}, [searchResult, onClick]);
|
||||
useContextMenu(imgRef, () => {
|
||||
onClick(searchResult, true, false);
|
||||
});
|
||||
|
||||
const objectLabel = useMemo(() => {
|
||||
if (
|
||||
@ -45,7 +46,10 @@ export default function SearchThumbnail({
|
||||
}, [config, searchResult]);
|
||||
|
||||
return (
|
||||
<div className="relative size-full cursor-pointer" onClick={handleOnClick}>
|
||||
<div
|
||||
className="relative size-full cursor-pointer"
|
||||
onClick={() => onClick(searchResult, false, true)}
|
||||
>
|
||||
<ImageLoadingIndicator
|
||||
className="absolute inset-0"
|
||||
imgLoaded={imgLoaded}
|
||||
@ -79,7 +83,7 @@ export default function SearchThumbnail({
|
||||
<div className="mx-3 pb-1 text-sm text-white">
|
||||
<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`}
|
||||
onClick={() => onClick(searchResult)}
|
||||
onClick={() => onClick(searchResult, false, true)}
|
||||
>
|
||||
{getIconForLabel(objectLabel, "size-3 text-white")}
|
||||
{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;
|
||||
setSearchDetail: (search: SearchResult | undefined) => 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({
|
||||
@ -125,7 +125,7 @@ type ThumbnailRowType = {
|
||||
setSearchDetail: (search: SearchResult | undefined) => void;
|
||||
mutate: () => void;
|
||||
setSimilaritySearch: (search: SearchResult) => void;
|
||||
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
|
||||
onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void;
|
||||
};
|
||||
|
||||
function ThumbnailRow({
|
||||
@ -205,7 +205,7 @@ type ExploreThumbnailImageProps = {
|
||||
setSearchDetail: (search: SearchResult | undefined) => void;
|
||||
mutate: () => void;
|
||||
setSimilaritySearch: (search: SearchResult) => void;
|
||||
onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void;
|
||||
onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void;
|
||||
};
|
||||
function ExploreThumbnailImage({
|
||||
event,
|
||||
@ -225,11 +225,11 @@ function ExploreThumbnailImage({
|
||||
};
|
||||
|
||||
const handleShowObjectLifecycle = () => {
|
||||
onSelectSearch(event, 0, "object lifecycle");
|
||||
onSelectSearch(event, false, "object lifecycle");
|
||||
};
|
||||
|
||||
const handleShowSnapshot = () => {
|
||||
onSelectSearch(event, 0, "snapshot");
|
||||
onSelectSearch(event, false, "snapshot");
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -30,6 +30,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import Chip from "@/components/indicators/Chip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
||||
|
||||
type SearchViewProps = {
|
||||
search: string;
|
||||
@ -181,20 +182,53 @@ export default function SearchView({
|
||||
|
||||
// search interaction
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [selectedObjects, setSelectedObjects] = useState<string[]>([]);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const onSelectSearch = useCallback(
|
||||
(item: SearchResult, index: number, page: SearchTab = "details") => {
|
||||
setPage(page);
|
||||
setSearchDetail(item);
|
||||
setSelectedIndex(index);
|
||||
(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);
|
||||
setSearchDetail(item);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[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(() => {
|
||||
setSelectedIndex(0);
|
||||
setSelectedObjects([]);
|
||||
// unselect items when search term or filter changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchTerm, searchFilter]);
|
||||
|
||||
// confidence score
|
||||
@ -243,23 +277,44 @@ export default function SearchView({
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
setSelectedIndex((prevIndex) => {
|
||||
const newIndex =
|
||||
prevIndex === null
|
||||
? uniqueResults.length - 1
|
||||
: (prevIndex - 1 + uniqueResults.length) % uniqueResults.length;
|
||||
setSearchDetail(uniqueResults[newIndex]);
|
||||
return newIndex;
|
||||
});
|
||||
case "a":
|
||||
if (modifiers.ctrl) {
|
||||
onSelectAllObjects();
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
setSelectedIndex((prevIndex) => {
|
||||
case "ArrowLeft":
|
||||
if (uniqueResults.length > 0) {
|
||||
const currentIndex = searchDetail
|
||||
? uniqueResults.findIndex(
|
||||
(result) => result.id === searchDetail.id,
|
||||
)
|
||||
: -1;
|
||||
|
||||
const newIndex =
|
||||
prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length;
|
||||
currentIndex === -1
|
||||
? uniqueResults.length - 1
|
||||
: (currentIndex - 1 + uniqueResults.length) %
|
||||
uniqueResults.length;
|
||||
|
||||
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;
|
||||
case "PageDown":
|
||||
contentRef.current?.scrollBy({
|
||||
@ -275,32 +330,80 @@ export default function SearchView({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[uniqueResults, inputFocused],
|
||||
[uniqueResults, inputFocused, onSelectAllObjects, searchDetail],
|
||||
);
|
||||
|
||||
useKeyboardListener(
|
||||
["ArrowLeft", "ArrowRight", "PageDown", "PageUp"],
|
||||
["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"],
|
||||
onKeyboardShortcut,
|
||||
!inputFocused,
|
||||
);
|
||||
|
||||
// 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(() => {
|
||||
if (
|
||||
selectedIndex !== null &&
|
||||
uniqueResults &&
|
||||
itemRefs.current?.[selectedIndex]
|
||||
) {
|
||||
scrollIntoView(itemRefs.current[selectedIndex], {
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
scrollMode: "if-needed",
|
||||
});
|
||||
if (searchDetail === undefined && prevSearchDetailRef.current) {
|
||||
setPrevSearchDetail(prevSearchDetailRef.current);
|
||||
}
|
||||
// we only want to scroll when the index changes
|
||||
prevSearchDetailRef.current = searchDetail;
|
||||
}, [searchDetail]);
|
||||
|
||||
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
|
||||
}, [selectedIndex]);
|
||||
}, [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",
|
||||
behavior: "smooth",
|
||||
scrollMode: "if-needed",
|
||||
});
|
||||
}
|
||||
}
|
||||
// we only want to scroll when changing the detail pane
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchDetail]);
|
||||
|
||||
// observer for loading more
|
||||
|
||||
@ -369,22 +472,39 @@ export default function SearchView({
|
||||
{hasExistingSearch && (
|
||||
<ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
|
||||
<div className="flex flex-row gap-2">
|
||||
<SearchFilterGroup
|
||||
className={cn(
|
||||
"w-full justify-between md:justify-start lg:justify-end",
|
||||
)}
|
||||
filter={searchFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
/>
|
||||
<SearchSettings
|
||||
columns={columns}
|
||||
setColumns={setColumns}
|
||||
defaultView={defaultView}
|
||||
setDefaultView={setDefaultView}
|
||||
filter={searchFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
/>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
{selectedObjects.length == 0 ? (
|
||||
<>
|
||||
<SearchFilterGroup
|
||||
className={cn(
|
||||
"w-full justify-between md:justify-start lg:justify-end",
|
||||
)}
|
||||
filter={searchFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
/>
|
||||
<SearchSettings
|
||||
columns={columns}
|
||||
setColumns={setColumns}
|
||||
defaultView={defaultView}
|
||||
setDefaultView={setDefaultView}
|
||||
filter={searchFilter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
/>
|
||||
<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>
|
||||
</ScrollArea>
|
||||
)}
|
||||
@ -412,14 +532,14 @@ export default function SearchView({
|
||||
<div className={gridClassName}>
|
||||
{uniqueResults &&
|
||||
uniqueResults.map((value, index) => {
|
||||
const selected = selectedIndex === index;
|
||||
const selected = selectedObjects.includes(value.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={value.id}
|
||||
ref={(item) => (itemRefs.current[index] = item)}
|
||||
data-start={value.start_time}
|
||||
className="review-item relative flex flex-col rounded-lg"
|
||||
className="relative flex flex-col rounded-lg"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@ -428,7 +548,20 @@ export default function SearchView({
|
||||
>
|
||||
<SearchThumbnail
|
||||
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 ||
|
||||
searchFilter?.search_type?.includes("similarity")) && (
|
||||
@ -469,10 +602,10 @@ export default function SearchView({
|
||||
}}
|
||||
refreshResults={refresh}
|
||||
showObjectLifecycle={() =>
|
||||
onSelectSearch(value, index, "object lifecycle")
|
||||
onSelectSearch(value, false, "object lifecycle")
|
||||
}
|
||||
showSnapshot={() =>
|
||||
onSelectSearch(value, index, "snapshot")
|
||||
onSelectSearch(value, false, "snapshot")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user