forked from Github/frigate
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user