mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Refactor Search Page (#13645)
* Always enable search page * Always show eents when searching * No default search background * Center and show all filters when semantic search is not enabled * Limit number of default items shown * Adjust search options * Add support for sub label filtering * Separate out filters and clean up detail pane * Tablet cleanup * Fix current hour search preview * Handle single lists * Cleanup api search
This commit is contained in:
		
							parent
							
								
									ceb7aa8b36
								
							
						
					
					
						commit
						c8521554c8
					
				| @ -6,25 +6,40 @@ import { useCallback, useMemo, useState } from "react"; | ||||
| import { DropdownMenuSeparator } from "../ui/dropdown-menu"; | ||||
| import { getEndOfDayTimestamp } from "@/utils/dateUtil"; | ||||
| import { FaFilter } from "react-icons/fa"; | ||||
| import { isDesktop, isMobile } from "react-device-detect"; | ||||
| import { isMobile } from "react-device-detect"; | ||||
| import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; | ||||
| import { Switch } from "../ui/switch"; | ||||
| import { Label } from "../ui/label"; | ||||
| import MobileReviewSettingsDrawer, { | ||||
|   DrawerFeatures, | ||||
| } from "../overlay/MobileReviewSettingsDrawer"; | ||||
| import FilterSwitch from "./FilterSwitch"; | ||||
| import { FilterList } from "@/types/filter"; | ||||
| import { CalendarRangeFilterButton } from "./CalendarFilterButton"; | ||||
| import { CamerasFilterButton } from "./CamerasFilterButton"; | ||||
| import { SearchFilter, SearchSource } from "@/types/search"; | ||||
| import { DateRange } from "react-day-picker"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import SubFilterIcon from "../icons/SubFilterIcon"; | ||||
| import { FaLocationDot } from "react-icons/fa6"; | ||||
| 
 | ||||
| const SEARCH_FILTERS = ["cameras", "date", "general"] as const; | ||||
| const SEARCH_FILTERS = [ | ||||
|   "cameras", | ||||
|   "date", | ||||
|   "general", | ||||
|   "zone", | ||||
|   "sub", | ||||
|   "source", | ||||
| ] as const; | ||||
| type SearchFilters = (typeof SEARCH_FILTERS)[number]; | ||||
| const DEFAULT_REVIEW_FILTERS: SearchFilters[] = ["cameras", "date", "general"]; | ||||
| const DEFAULT_REVIEW_FILTERS: SearchFilters[] = [ | ||||
|   "cameras", | ||||
|   "date", | ||||
|   "general", | ||||
|   "zone", | ||||
|   "sub", | ||||
|   "source", | ||||
| ]; | ||||
| 
 | ||||
| type SearchFilterGroupProps = { | ||||
|   className: string; | ||||
|   filters?: SearchFilters[]; | ||||
|   filter?: SearchFilter; | ||||
|   filterList?: FilterList; | ||||
| @ -32,12 +47,15 @@ type SearchFilterGroupProps = { | ||||
| }; | ||||
| 
 | ||||
| export default function SearchFilterGroup({ | ||||
|   className, | ||||
|   filters = DEFAULT_REVIEW_FILTERS, | ||||
|   filter, | ||||
|   filterList, | ||||
|   onUpdateFilter, | ||||
| }: SearchFilterGroupProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const { data: config } = useSWR<FrigateConfig>("config", { | ||||
|     revalidateOnFocus: false, | ||||
|   }); | ||||
| 
 | ||||
|   const allLabels = useMemo<string[]>(() => { | ||||
|     if (filterList?.labels) { | ||||
| @ -70,6 +88,8 @@ export default function SearchFilterGroup({ | ||||
|     return [...labels].sort(); | ||||
|   }, [config, filterList, filter]); | ||||
| 
 | ||||
|   const { data: allSubLabels } = useSWR("sub_labels"); | ||||
| 
 | ||||
|   const allZones = useMemo<string[]>(() => { | ||||
|     if (filterList?.zones) { | ||||
|       return filterList.zones; | ||||
| @ -118,20 +138,6 @@ export default function SearchFilterGroup({ | ||||
|     ); | ||||
|   }, [config]); | ||||
| 
 | ||||
|   const mobileSettingsFeatures = useMemo<DrawerFeatures[]>(() => { | ||||
|     const features: DrawerFeatures[] = []; | ||||
| 
 | ||||
|     if (filters.includes("date")) { | ||||
|       features.push("calendar"); | ||||
|     } | ||||
| 
 | ||||
|     if (filters.includes("general")) { | ||||
|       features.push("filter"); | ||||
|     } | ||||
| 
 | ||||
|     return features; | ||||
|   }, [filters]); | ||||
| 
 | ||||
|   // handle updating filters
 | ||||
| 
 | ||||
|   const onUpdateSelectedRange = useCallback( | ||||
| @ -148,7 +154,7 @@ export default function SearchFilterGroup({ | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="flex justify-center gap-2"> | ||||
|     <div className={cn("flex justify-center gap-2", className)}> | ||||
|       {filters.includes("cameras") && ( | ||||
|         <CamerasFilterButton | ||||
|           allCameras={filterValues.cameras} | ||||
| @ -159,7 +165,7 @@ export default function SearchFilterGroup({ | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       {isDesktop && filters.includes("date") && ( | ||||
|       {filters.includes("date") && ( | ||||
|         <CalendarRangeFilterButton | ||||
|           range={ | ||||
|             filter?.after == undefined || filter?.before == undefined | ||||
| @ -173,42 +179,43 @@ export default function SearchFilterGroup({ | ||||
|           updateSelectedRange={onUpdateSelectedRange} | ||||
|         /> | ||||
|       )} | ||||
|       {isDesktop && filters.includes("general") && ( | ||||
|       {filters.includes("general") && ( | ||||
|         <GeneralFilterButton | ||||
|           allLabels={filterValues.labels} | ||||
|           selectedLabels={filter?.labels} | ||||
|           allZones={filterValues.zones} | ||||
|           selectedZones={filter?.zones} | ||||
|           selectedSearchSources={ | ||||
|             filter?.search_type ?? ["thumbnail", "description"] | ||||
|           } | ||||
|           updateLabelFilter={(newLabels) => { | ||||
|             onUpdateFilter({ ...filter, labels: newLabels }); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       {filters.includes("zone") && allZones.length > 0 && ( | ||||
|         <ZoneFilterButton | ||||
|           allZones={filterValues.zones} | ||||
|           selectedZones={filter?.zones} | ||||
|           updateZoneFilter={(newZones) => | ||||
|             onUpdateFilter({ ...filter, zones: newZones }) | ||||
|           } | ||||
|         /> | ||||
|       )} | ||||
|       {filters.includes("sub") && ( | ||||
|         <SubFilterButton | ||||
|           allSubLabels={allSubLabels} | ||||
|           selectedSubLabels={filter?.subLabels} | ||||
|           updateSubLabelFilter={(newSubLabels) => | ||||
|             onUpdateFilter({ ...filter, subLabels: newSubLabels }) | ||||
|           } | ||||
|         /> | ||||
|       )} | ||||
|       {config?.semantic_search?.enabled && filters.includes("source") && ( | ||||
|         <SearchTypeButton | ||||
|           selectedSearchSources={ | ||||
|             filter?.search_type ?? ["thumbnail", "description"] | ||||
|           } | ||||
|           updateSearchSourceFilter={(newSearchSource) => | ||||
|             onUpdateFilter({ ...filter, search_type: newSearchSource }) | ||||
|           } | ||||
|         /> | ||||
|       )} | ||||
|       {isMobile && mobileSettingsFeatures.length > 0 && ( | ||||
|         <MobileReviewSettingsDrawer | ||||
|           features={mobileSettingsFeatures} | ||||
|           filter={filter} | ||||
|           allLabels={allLabels} | ||||
|           allZones={allZones} | ||||
|           onUpdateFilter={onUpdateFilter} | ||||
|           // not applicable as exports are not used
 | ||||
|           camera="" | ||||
|           latestTime={0} | ||||
|           currentTime={0} | ||||
|           mode="none" | ||||
|           setMode={() => {}} | ||||
|           setRange={() => {}} | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @ -216,47 +223,29 @@ export default function SearchFilterGroup({ | ||||
| type GeneralFilterButtonProps = { | ||||
|   allLabels: string[]; | ||||
|   selectedLabels: string[] | undefined; | ||||
|   allZones: string[]; | ||||
|   selectedZones?: string[]; | ||||
|   selectedSearchSources: SearchSource[]; | ||||
|   updateLabelFilter: (labels: string[] | undefined) => void; | ||||
|   updateZoneFilter: (zones: string[] | undefined) => void; | ||||
|   updateSearchSourceFilter: (sources: SearchSource[]) => void; | ||||
| }; | ||||
| function GeneralFilterButton({ | ||||
|   allLabels, | ||||
|   selectedLabels, | ||||
|   allZones, | ||||
|   selectedZones, | ||||
|   selectedSearchSources, | ||||
|   updateLabelFilter, | ||||
|   updateZoneFilter, | ||||
|   updateSearchSourceFilter, | ||||
| }: GeneralFilterButtonProps) { | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( | ||||
|     selectedLabels, | ||||
|   ); | ||||
|   const [currentZones, setCurrentZones] = useState<string[] | undefined>( | ||||
|     selectedZones, | ||||
|   ); | ||||
|   const [currentSearchSources, setCurrentSearchSources] = useState< | ||||
|     SearchSource[] | ||||
|   >(selectedSearchSources); | ||||
| 
 | ||||
|   const trigger = ( | ||||
|     <Button | ||||
|       size="sm" | ||||
|       variant={ | ||||
|         selectedLabels?.length || selectedZones?.length ? "select" : "default" | ||||
|       } | ||||
|       variant={selectedLabels?.length ? "select" : "default"} | ||||
|       className="flex items-center gap-2 capitalize" | ||||
|     > | ||||
|       <FaFilter | ||||
|         className={`${selectedLabels?.length || selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} | ||||
|         className={`${selectedLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} | ||||
|       /> | ||||
|       <div | ||||
|         className={`hidden md:block ${selectedLabels?.length || selectedZones?.length ? "text-selected-foreground" : "text-primary"}`} | ||||
|         className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`} | ||||
|       > | ||||
|         Filter | ||||
|       </div> | ||||
| @ -267,17 +256,8 @@ function GeneralFilterButton({ | ||||
|       allLabels={allLabels} | ||||
|       selectedLabels={selectedLabels} | ||||
|       currentLabels={currentLabels} | ||||
|       allZones={allZones} | ||||
|       selectedZones={selectedZones} | ||||
|       currentZones={currentZones} | ||||
|       selectedSearchSources={selectedSearchSources} | ||||
|       currentSearchSources={currentSearchSources} | ||||
|       setCurrentZones={setCurrentZones} | ||||
|       updateZoneFilter={updateZoneFilter} | ||||
|       setCurrentLabels={setCurrentLabels} | ||||
|       updateLabelFilter={updateLabelFilter} | ||||
|       setCurrentSearchSources={setCurrentSearchSources} | ||||
|       updateSearchSourceFilter={updateSearchSourceFilter} | ||||
|       onClose={() => setOpen(false)} | ||||
|     /> | ||||
|   ); | ||||
| @ -323,82 +303,21 @@ type GeneralFilterContentProps = { | ||||
|   allLabels: string[]; | ||||
|   selectedLabels: string[] | undefined; | ||||
|   currentLabels: string[] | undefined; | ||||
|   allZones?: string[]; | ||||
|   selectedZones?: string[]; | ||||
|   currentZones?: string[]; | ||||
|   selectedSearchSources: SearchSource[]; | ||||
|   currentSearchSources: SearchSource[]; | ||||
|   updateLabelFilter: (labels: string[] | undefined) => void; | ||||
|   setCurrentLabels: (labels: string[] | undefined) => void; | ||||
|   updateZoneFilter?: (zones: string[] | undefined) => void; | ||||
|   setCurrentZones?: (zones: string[] | undefined) => void; | ||||
|   setCurrentSearchSources: (sources: SearchSource[]) => void; | ||||
|   updateSearchSourceFilter: (sources: SearchSource[]) => void; | ||||
|   onClose: () => void; | ||||
| }; | ||||
| export function GeneralFilterContent({ | ||||
|   allLabels, | ||||
|   selectedLabels, | ||||
|   currentLabels, | ||||
|   allZones, | ||||
|   selectedZones, | ||||
|   currentZones, | ||||
|   selectedSearchSources, | ||||
|   currentSearchSources, | ||||
|   updateLabelFilter, | ||||
|   setCurrentLabels, | ||||
|   updateZoneFilter, | ||||
|   setCurrentZones, | ||||
|   setCurrentSearchSources, | ||||
|   updateSearchSourceFilter, | ||||
|   onClose, | ||||
| }: GeneralFilterContentProps) { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> | ||||
|         <div className="my-2.5 flex flex-col gap-2.5"> | ||||
|           <FilterSwitch | ||||
|             label="Thumbnail Image" | ||||
|             isChecked={currentSearchSources?.includes("thumbnail") ?? false} | ||||
|             onCheckedChange={(isChecked) => { | ||||
|               const updatedSources = currentSearchSources | ||||
|                 ? [...currentSearchSources] | ||||
|                 : []; | ||||
| 
 | ||||
|               if (isChecked) { | ||||
|                 updatedSources.push("thumbnail"); | ||||
|                 setCurrentSearchSources(updatedSources); | ||||
|               } else { | ||||
|                 if (updatedSources.length > 1) { | ||||
|                   const index = updatedSources.indexOf("thumbnail"); | ||||
|                   if (index !== -1) updatedSources.splice(index, 1); | ||||
|                   setCurrentSearchSources(updatedSources); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|           <FilterSwitch | ||||
|             label="Description" | ||||
|             isChecked={currentSearchSources?.includes("description") ?? false} | ||||
|             onCheckedChange={(isChecked) => { | ||||
|               const updatedSources = currentSearchSources | ||||
|                 ? [...currentSearchSources] | ||||
|                 : []; | ||||
| 
 | ||||
|               if (isChecked) { | ||||
|                 updatedSources.push("description"); | ||||
|                 setCurrentSearchSources(updatedSources); | ||||
|               } else { | ||||
|                 if (updatedSources.length > 1) { | ||||
|                   const index = updatedSources.indexOf("description"); | ||||
|                   if (index !== -1) updatedSources.splice(index, 1); | ||||
|                   setCurrentSearchSources(updatedSources); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|           <DropdownMenuSeparator /> | ||||
|         </div> | ||||
|         <div className="mb-5 mt-2.5 flex items-center justify-between"> | ||||
|           <Label | ||||
|             className="mx-2 cursor-pointer text-primary" | ||||
| @ -442,7 +361,135 @@ export function GeneralFilterContent({ | ||||
|             /> | ||||
|           ))} | ||||
|         </div> | ||||
|       </div> | ||||
|       <DropdownMenuSeparator /> | ||||
|       <div className="flex items-center justify-evenly p-2"> | ||||
|         <Button | ||||
|           variant="select" | ||||
|           onClick={() => { | ||||
|             if (selectedLabels != currentLabels) { | ||||
|               updateLabelFilter(currentLabels); | ||||
|             } | ||||
| 
 | ||||
|             onClose(); | ||||
|           }} | ||||
|         > | ||||
|           Apply | ||||
|         </Button> | ||||
|         <Button | ||||
|           onClick={() => { | ||||
|             setCurrentLabels(undefined); | ||||
|             updateLabelFilter(undefined); | ||||
|           }} | ||||
|         > | ||||
|           Reset | ||||
|         </Button> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type ZoneFilterButtonProps = { | ||||
|   allZones: string[]; | ||||
|   selectedZones?: string[]; | ||||
|   updateZoneFilter: (zones: string[] | undefined) => void; | ||||
| }; | ||||
| function ZoneFilterButton({ | ||||
|   allZones, | ||||
|   selectedZones, | ||||
|   updateZoneFilter, | ||||
| }: ZoneFilterButtonProps) { | ||||
|   const [open, setOpen] = useState(false); | ||||
| 
 | ||||
|   const [currentZones, setCurrentZones] = useState<string[] | undefined>( | ||||
|     selectedZones, | ||||
|   ); | ||||
| 
 | ||||
|   const trigger = ( | ||||
|     <Button | ||||
|       size="sm" | ||||
|       variant={selectedZones?.length ? "select" : "default"} | ||||
|       className="flex items-center gap-2 capitalize" | ||||
|     > | ||||
|       <FaLocationDot | ||||
|         className={`${selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} | ||||
|       /> | ||||
|       <div | ||||
|         className={`hidden md:block ${selectedZones?.length ? "text-selected-foreground" : "text-primary"}`} | ||||
|       > | ||||
|         {selectedZones?.length | ||||
|           ? `${selectedZones.length} Zone${selectedZones.length > 1 ? "s" : ""}` | ||||
|           : "All Zones"} | ||||
|       </div> | ||||
|     </Button> | ||||
|   ); | ||||
|   const content = ( | ||||
|     <ZoneFilterContent | ||||
|       allZones={allZones} | ||||
|       selectedZones={selectedZones} | ||||
|       currentZones={currentZones} | ||||
|       setCurrentZones={setCurrentZones} | ||||
|       updateZoneFilter={updateZoneFilter} | ||||
|       onClose={() => setOpen(false)} | ||||
|     /> | ||||
|   ); | ||||
| 
 | ||||
|   if (isMobile) { | ||||
|     return ( | ||||
|       <Drawer | ||||
|         open={open} | ||||
|         onOpenChange={(open) => { | ||||
|           if (!open) { | ||||
|             setCurrentZones(selectedZones); | ||||
|           } | ||||
| 
 | ||||
|           setOpen(open); | ||||
|         }} | ||||
|       > | ||||
|         <DrawerTrigger asChild>{trigger}</DrawerTrigger> | ||||
|         <DrawerContent className="max-h-[75dvh] overflow-hidden"> | ||||
|           {content} | ||||
|         </DrawerContent> | ||||
|       </Drawer> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Popover | ||||
|       open={open} | ||||
|       onOpenChange={(open) => { | ||||
|         if (!open) { | ||||
|           setCurrentZones(selectedZones); | ||||
|         } | ||||
| 
 | ||||
|         setOpen(open); | ||||
|       }} | ||||
|     > | ||||
|       <PopoverTrigger asChild>{trigger}</PopoverTrigger> | ||||
|       <PopoverContent>{content}</PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type ZoneFilterContentProps = { | ||||
|   allZones?: string[]; | ||||
|   selectedZones?: string[]; | ||||
|   currentZones?: string[]; | ||||
|   updateZoneFilter?: (zones: string[] | undefined) => void; | ||||
|   setCurrentZones?: (zones: string[] | undefined) => void; | ||||
|   onClose: () => void; | ||||
| }; | ||||
| export function ZoneFilterContent({ | ||||
|   allZones, | ||||
|   selectedZones, | ||||
|   currentZones, | ||||
|   updateZoneFilter, | ||||
|   setCurrentZones, | ||||
|   onClose, | ||||
| }: ZoneFilterContentProps) { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> | ||||
|         {allZones && setCurrentZones && ( | ||||
|           <> | ||||
|             <DropdownMenuSeparator /> | ||||
| @ -501,18 +548,10 @@ export function GeneralFilterContent({ | ||||
|         <Button | ||||
|           variant="select" | ||||
|           onClick={() => { | ||||
|             if (selectedLabels != currentLabels) { | ||||
|               updateLabelFilter(currentLabels); | ||||
|             } | ||||
| 
 | ||||
|             if (updateZoneFilter && selectedZones != currentZones) { | ||||
|               updateZoneFilter(currentZones); | ||||
|             } | ||||
| 
 | ||||
|             if (selectedSearchSources != currentSearchSources) { | ||||
|               updateSearchSourceFilter(currentSearchSources); | ||||
|             } | ||||
| 
 | ||||
|             onClose(); | ||||
|           }} | ||||
|         > | ||||
| @ -520,9 +559,8 @@ export function GeneralFilterContent({ | ||||
|         </Button> | ||||
|         <Button | ||||
|           onClick={() => { | ||||
|             setCurrentLabels(undefined); | ||||
|             setCurrentZones?.(undefined); | ||||
|             updateLabelFilter(undefined); | ||||
|             updateZoneFilter?.(undefined); | ||||
|           }} | ||||
|         > | ||||
|           Reset | ||||
| @ -531,3 +569,344 @@ export function GeneralFilterContent({ | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type SubFilterButtonProps = { | ||||
|   allSubLabels: string[]; | ||||
|   selectedSubLabels: string[] | undefined; | ||||
|   updateSubLabelFilter: (labels: string[] | undefined) => void; | ||||
| }; | ||||
| function SubFilterButton({ | ||||
|   allSubLabels, | ||||
|   selectedSubLabels, | ||||
|   updateSubLabelFilter, | ||||
| }: SubFilterButtonProps) { | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const [currentSubLabels, setCurrentSubLabels] = useState< | ||||
|     string[] | undefined | ||||
|   >(selectedSubLabels); | ||||
| 
 | ||||
|   const trigger = ( | ||||
|     <Button | ||||
|       size="sm" | ||||
|       variant={selectedSubLabels?.length ? "select" : "default"} | ||||
|       className="flex items-center gap-2 capitalize" | ||||
|     > | ||||
|       <SubFilterIcon | ||||
|         className={`${selectedSubLabels?.length || selectedSubLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`} | ||||
|       /> | ||||
|       <div | ||||
|         className={`hidden md:block ${selectedSubLabels?.length ? "text-selected-foreground" : "text-primary"}`} | ||||
|       > | ||||
|         {selectedSubLabels?.length | ||||
|           ? `${selectedSubLabels.length} Sub Labels` | ||||
|           : "All Sub Labels"} | ||||
|       </div> | ||||
|     </Button> | ||||
|   ); | ||||
|   const content = ( | ||||
|     <SubFilterContent | ||||
|       allSubLabels={allSubLabels} | ||||
|       selectedSubLabels={selectedSubLabels} | ||||
|       currentSubLabels={currentSubLabels} | ||||
|       setCurrentSubLabels={setCurrentSubLabels} | ||||
|       updateSubLabelFilter={updateSubLabelFilter} | ||||
|       onClose={() => setOpen(false)} | ||||
|     /> | ||||
|   ); | ||||
| 
 | ||||
|   if (isMobile) { | ||||
|     return ( | ||||
|       <Drawer | ||||
|         open={open} | ||||
|         onOpenChange={(open) => { | ||||
|           if (!open) { | ||||
|             setCurrentSubLabels(selectedSubLabels); | ||||
|           } | ||||
| 
 | ||||
|           setOpen(open); | ||||
|         }} | ||||
|       > | ||||
|         <DrawerTrigger asChild>{trigger}</DrawerTrigger> | ||||
|         <DrawerContent className="max-h-[75dvh] overflow-hidden"> | ||||
|           {content} | ||||
|         </DrawerContent> | ||||
|       </Drawer> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Popover | ||||
|       open={open} | ||||
|       onOpenChange={(open) => { | ||||
|         if (!open) { | ||||
|           setCurrentSubLabels(selectedSubLabels); | ||||
|         } | ||||
| 
 | ||||
|         setOpen(open); | ||||
|       }} | ||||
|     > | ||||
|       <PopoverTrigger asChild>{trigger}</PopoverTrigger> | ||||
|       <PopoverContent>{content}</PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type SubFilterContentProps = { | ||||
|   allSubLabels: string[]; | ||||
|   selectedSubLabels: string[] | undefined; | ||||
|   currentSubLabels: string[] | undefined; | ||||
|   updateSubLabelFilter: (labels: string[] | undefined) => void; | ||||
|   setCurrentSubLabels: (labels: string[] | undefined) => void; | ||||
|   onClose: () => void; | ||||
| }; | ||||
| export function SubFilterContent({ | ||||
|   allSubLabels, | ||||
|   selectedSubLabels, | ||||
|   currentSubLabels, | ||||
|   updateSubLabelFilter, | ||||
|   setCurrentSubLabels, | ||||
|   onClose, | ||||
| }: SubFilterContentProps) { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> | ||||
|         <div className="mb-5 mt-2.5 flex items-center justify-between"> | ||||
|           <Label | ||||
|             className="mx-2 cursor-pointer text-primary" | ||||
|             htmlFor="allLabels" | ||||
|           > | ||||
|             All Sub Labels | ||||
|           </Label> | ||||
|           <Switch | ||||
|             className="ml-1" | ||||
|             id="allLabels" | ||||
|             checked={currentSubLabels == undefined} | ||||
|             onCheckedChange={(isChecked) => { | ||||
|               if (isChecked) { | ||||
|                 setCurrentSubLabels(undefined); | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|         <div className="my-2.5 flex flex-col gap-2.5"> | ||||
|           {allSubLabels.map((item) => ( | ||||
|             <FilterSwitch | ||||
|               key={item} | ||||
|               label={item.replaceAll("_", " ")} | ||||
|               isChecked={currentSubLabels?.includes(item) ?? false} | ||||
|               onCheckedChange={(isChecked) => { | ||||
|                 if (isChecked) { | ||||
|                   const updatedLabels = currentSubLabels | ||||
|                     ? [...currentSubLabels] | ||||
|                     : []; | ||||
| 
 | ||||
|                   updatedLabels.push(item); | ||||
|                   setCurrentSubLabels(updatedLabels); | ||||
|                 } else { | ||||
|                   const updatedLabels = currentSubLabels | ||||
|                     ? [...currentSubLabels] | ||||
|                     : []; | ||||
| 
 | ||||
|                   // can not deselect the last item
 | ||||
|                   if (updatedLabels.length > 1) { | ||||
|                     updatedLabels.splice(updatedLabels.indexOf(item), 1); | ||||
|                     setCurrentSubLabels(updatedLabels); | ||||
|                   } | ||||
|                 } | ||||
|               }} | ||||
|             /> | ||||
|           ))} | ||||
|         </div> | ||||
|       </div> | ||||
|       <DropdownMenuSeparator /> | ||||
|       <div className="flex items-center justify-evenly p-2"> | ||||
|         <Button | ||||
|           variant="select" | ||||
|           onClick={() => { | ||||
|             if (selectedSubLabels != currentSubLabels) { | ||||
|               updateSubLabelFilter(currentSubLabels); | ||||
|             } | ||||
| 
 | ||||
|             onClose(); | ||||
|           }} | ||||
|         > | ||||
|           Apply | ||||
|         </Button> | ||||
|         <Button | ||||
|           onClick={() => { | ||||
|             updateSubLabelFilter(undefined); | ||||
|           }} | ||||
|         > | ||||
|           Reset | ||||
|         </Button> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type SearchTypeButtonProps = { | ||||
|   selectedSearchSources: SearchSource[]; | ||||
|   updateSearchSourceFilter: (sources: SearchSource[]) => void; | ||||
| }; | ||||
| function SearchTypeButton({ | ||||
|   selectedSearchSources, | ||||
|   updateSearchSourceFilter, | ||||
| }: SearchTypeButtonProps) { | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const [currentSearchSources, setCurrentSearchSources] = useState< | ||||
|     SearchSource[] | ||||
|   >(selectedSearchSources); | ||||
| 
 | ||||
|   const trigger = ( | ||||
|     <Button | ||||
|       size="sm" | ||||
|       variant={selectedSearchSources?.length != 2 ? "select" : "default"} | ||||
|       className="flex items-center gap-2 capitalize" | ||||
|     > | ||||
|       <FaFilter | ||||
|         className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-secondary-foreground"}`} | ||||
|       /> | ||||
|       <div | ||||
|         className={`hidden md:block ${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-primary"}`} | ||||
|       > | ||||
|         {selectedSearchSources?.length != 2 | ||||
|           ? `${selectedSearchSources[0]}` | ||||
|           : "All Search Sources"} | ||||
|       </div> | ||||
|     </Button> | ||||
|   ); | ||||
|   const content = ( | ||||
|     <SearchTypeContent | ||||
|       selectedSearchSources={selectedSearchSources} | ||||
|       currentSearchSources={currentSearchSources} | ||||
|       setCurrentSearchSources={setCurrentSearchSources} | ||||
|       updateSearchSourceFilter={updateSearchSourceFilter} | ||||
|       onClose={() => setOpen(false)} | ||||
|     /> | ||||
|   ); | ||||
| 
 | ||||
|   if (isMobile) { | ||||
|     return ( | ||||
|       <Drawer | ||||
|         open={open} | ||||
|         onOpenChange={(open) => { | ||||
|           if (!open) { | ||||
|             setCurrentSearchSources(selectedSearchSources); | ||||
|           } | ||||
| 
 | ||||
|           setOpen(open); | ||||
|         }} | ||||
|       > | ||||
|         <DrawerTrigger asChild>{trigger}</DrawerTrigger> | ||||
|         <DrawerContent className="max-h-[75dvh] overflow-hidden"> | ||||
|           {content} | ||||
|         </DrawerContent> | ||||
|       </Drawer> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Popover | ||||
|       open={open} | ||||
|       onOpenChange={(open) => { | ||||
|         if (!open) { | ||||
|           setCurrentSearchSources(selectedSearchSources); | ||||
|         } | ||||
| 
 | ||||
|         setOpen(open); | ||||
|       }} | ||||
|     > | ||||
|       <PopoverTrigger asChild>{trigger}</PopoverTrigger> | ||||
|       <PopoverContent>{content}</PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type SearchTypeContentProps = { | ||||
|   selectedSearchSources: SearchSource[]; | ||||
|   currentSearchSources: SearchSource[]; | ||||
|   setCurrentSearchSources: (sources: SearchSource[]) => void; | ||||
|   updateSearchSourceFilter: (sources: SearchSource[]) => void; | ||||
|   onClose: () => void; | ||||
| }; | ||||
| export function SearchTypeContent({ | ||||
|   selectedSearchSources, | ||||
|   currentSearchSources, | ||||
|   setCurrentSearchSources, | ||||
|   updateSearchSourceFilter, | ||||
|   onClose, | ||||
| }: SearchTypeContentProps) { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden"> | ||||
|         <div className="my-2.5 flex flex-col gap-2.5"> | ||||
|           <FilterSwitch | ||||
|             label="Thumbnail Image" | ||||
|             isChecked={currentSearchSources?.includes("thumbnail") ?? false} | ||||
|             onCheckedChange={(isChecked) => { | ||||
|               const updatedSources = currentSearchSources | ||||
|                 ? [...currentSearchSources] | ||||
|                 : []; | ||||
| 
 | ||||
|               if (isChecked) { | ||||
|                 updatedSources.push("thumbnail"); | ||||
|                 setCurrentSearchSources(updatedSources); | ||||
|               } else { | ||||
|                 if (updatedSources.length > 1) { | ||||
|                   const index = updatedSources.indexOf("thumbnail"); | ||||
|                   if (index !== -1) updatedSources.splice(index, 1); | ||||
|                   setCurrentSearchSources(updatedSources); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|           <FilterSwitch | ||||
|             label="Description" | ||||
|             isChecked={currentSearchSources?.includes("description") ?? false} | ||||
|             onCheckedChange={(isChecked) => { | ||||
|               const updatedSources = currentSearchSources | ||||
|                 ? [...currentSearchSources] | ||||
|                 : []; | ||||
| 
 | ||||
|               if (isChecked) { | ||||
|                 updatedSources.push("description"); | ||||
|                 setCurrentSearchSources(updatedSources); | ||||
|               } else { | ||||
|                 if (updatedSources.length > 1) { | ||||
|                   const index = updatedSources.indexOf("description"); | ||||
|                   if (index !== -1) updatedSources.splice(index, 1); | ||||
|                   setCurrentSearchSources(updatedSources); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|         <DropdownMenuSeparator /> | ||||
|         <div className="flex items-center justify-evenly p-2"> | ||||
|           <Button | ||||
|             variant="select" | ||||
|             onClick={() => { | ||||
|               if (selectedSearchSources != currentSearchSources) { | ||||
|                 updateSearchSourceFilter(currentSearchSources); | ||||
|               } | ||||
| 
 | ||||
|               onClose(); | ||||
|             }} | ||||
|           > | ||||
|             Apply | ||||
|           </Button> | ||||
|           <Button | ||||
|             onClick={() => { | ||||
|               setCurrentSearchSources([ | ||||
|                 "thumbnail", | ||||
|                 "description", | ||||
|               ] as SearchSource[]); | ||||
|             }} | ||||
|           > | ||||
|             Reset | ||||
|           </Button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
							
								
								
									
										25
									
								
								web/src/components/icons/SubFilterIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								web/src/components/icons/SubFilterIcon.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| import { forwardRef } from "react"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import { FaCog, FaFilter } from "react-icons/fa"; | ||||
| 
 | ||||
| type SubFilterIconProps = { | ||||
|   className?: string; | ||||
|   onClick?: () => void; | ||||
| }; | ||||
| 
 | ||||
| const SubFilterIcon = forwardRef<HTMLDivElement, SubFilterIconProps>( | ||||
|   ({ className, onClick }, ref) => { | ||||
|     return ( | ||||
|       <div | ||||
|         ref={ref} | ||||
|         className={cn("relative flex items-center", className)} | ||||
|         onClick={onClick} | ||||
|       > | ||||
|         <FaFilter className="size-full" /> | ||||
|         <FaCog className="absolute size-3 translate-x-3 translate-y-3/4" /> | ||||
|       </div> | ||||
|     ); | ||||
|   }, | ||||
| ); | ||||
| 
 | ||||
| export default SubFilterIcon; | ||||
| @ -114,12 +114,15 @@ export default function SearchDetailDialog({ | ||||
|                   <div className="flex flex-row items-center gap-2 text-sm capitalize"> | ||||
|                     {getIconForLabel(search.label, "size-4 text-primary")} | ||||
|                     {search.label} | ||||
|                     {search.sub_label && ` (${search.sub_label})`} | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <div className="flex flex-col gap-1.5"> | ||||
|                   <div className="text-sm text-primary/40">Score</div> | ||||
|                   <div className="text-sm"> | ||||
|                     {Math.round(search.score * 100)}% | ||||
|                     {Math.round(search.data.top_score * 100)}% | ||||
|                     {search.sub_label && | ||||
|                       ` (${Math.round((search.data.sub_label_score ?? 0) * 100)}%)`} | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <div className="flex flex-col gap-1.5"> | ||||
|  | ||||
| @ -329,7 +329,9 @@ function PreviewContent({ | ||||
|   } else if (isCurrentHour(review.start_time)) { | ||||
|     return ( | ||||
|       <InProgressPreview | ||||
|         review={review} | ||||
|         camera={review.camera} | ||||
|         startTime={review.start_time} | ||||
|         endTime={review.end_time} | ||||
|         timeRange={timeRange} | ||||
|         setReviewed={setReviewed} | ||||
|         setIgnoreClick={setIgnoreClick} | ||||
|  | ||||
| @ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; | ||||
| import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; | ||||
| import ActivityIndicator from "../indicators/activity-indicator"; | ||||
| import { capitalizeFirstLetter } from "@/utils/stringUtil"; | ||||
| import { VideoPreview } from "../preview/ScrubbablePreview"; | ||||
| import { InProgressPreview, VideoPreview } from "../preview/ScrubbablePreview"; | ||||
| import { Preview } from "@/types/preview"; | ||||
| import { SearchResult } from "@/types/search"; | ||||
| import useContextMenu from "@/hooks/use-contextmenu"; | ||||
| @ -272,6 +272,7 @@ function PreviewContent({ | ||||
|   onTimeUpdate, | ||||
| }: PreviewContentProps) { | ||||
|   // preview
 | ||||
|   const now = useMemo(() => Date.now() / 1000, []); | ||||
| 
 | ||||
|   if (relevantPreview) { | ||||
|     return ( | ||||
| @ -287,6 +288,21 @@ function PreviewContent({ | ||||
|       /> | ||||
|     ); | ||||
|   } else if (isCurrentHour(searchResult.start_time)) { | ||||
|     return <div />; | ||||
|     return ( | ||||
|       <InProgressPreview | ||||
|         camera={searchResult.camera} | ||||
|         startTime={searchResult.start_time} | ||||
|         endTime={searchResult.end_time} | ||||
|         timeRange={{ | ||||
|           before: now, | ||||
|           after: searchResult.start_time, | ||||
|         }} | ||||
|         setIgnoreClick={setIgnoreClick} | ||||
|         isPlayingBack={isPlayingBack} | ||||
|         onTimeUpdate={onTimeUpdate} | ||||
|         windowVisible={true} | ||||
|         setReviewed={() => {}} | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -6,7 +6,6 @@ import React, { | ||||
|   useState, | ||||
| } from "react"; | ||||
| import { useApiHost } from "@/api"; | ||||
| import { ReviewSegment } from "@/types/review"; | ||||
| import useSWR from "swr"; | ||||
| import { isFirefox, isMobile, isSafari } from "react-device-detect"; | ||||
| import { TimelineScrubMode, TimeRange } from "@/types/timeline"; | ||||
| @ -286,21 +285,27 @@ export function VideoPreview({ | ||||
| 
 | ||||
| const MIN_LOAD_TIMEOUT_MS = 200; | ||||
| type InProgressPreviewProps = { | ||||
|   review: ReviewSegment; | ||||
|   camera: string; | ||||
|   startTime: number; | ||||
|   endTime?: number; | ||||
|   timeRange: TimeRange; | ||||
|   showProgress?: boolean; | ||||
|   loop?: boolean; | ||||
|   setReviewed: (reviewId: string) => void; | ||||
|   defaultImageUrl?: string; | ||||
|   setReviewed: () => void; | ||||
|   setIgnoreClick: (ignore: boolean) => void; | ||||
|   isPlayingBack: (ended: boolean) => void; | ||||
|   onTimeUpdate?: (time: number | undefined) => void; | ||||
|   windowVisible: boolean; | ||||
| }; | ||||
| export function InProgressPreview({ | ||||
|   review, | ||||
|   camera, | ||||
|   startTime, | ||||
|   endTime, | ||||
|   timeRange, | ||||
|   showProgress = true, | ||||
|   loop = false, | ||||
|   defaultImageUrl, | ||||
|   setReviewed, | ||||
|   setIgnoreClick, | ||||
|   isPlayingBack, | ||||
| @ -310,8 +315,8 @@ export function InProgressPreview({ | ||||
|   const apiHost = useApiHost(); | ||||
|   const sliderRef = useRef<HTMLDivElement | null>(null); | ||||
|   const { data: previewFrames } = useSWR<string[]>( | ||||
|     `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${ | ||||
|       Math.ceil(review.end_time ?? timeRange.before) + PREVIEW_PADDING | ||||
|     `preview/${camera}/start/${Math.floor(startTime) - PREVIEW_PADDING}/end/${ | ||||
|       Math.ceil(endTime ?? timeRange.before) + PREVIEW_PADDING | ||||
|     }/frames`,
 | ||||
|     { revalidateOnFocus: false }, | ||||
|   ); | ||||
| @ -326,7 +331,7 @@ export function InProgressPreview({ | ||||
|     } | ||||
| 
 | ||||
|     if (onTimeUpdate) { | ||||
|       onTimeUpdate(review.start_time - PREVIEW_PADDING + key); | ||||
|       onTimeUpdate(startTime - PREVIEW_PADDING + key); | ||||
|     } | ||||
| 
 | ||||
|     if (playbackMode != "auto") { | ||||
| @ -334,9 +339,7 @@ export function InProgressPreview({ | ||||
|     } | ||||
| 
 | ||||
|     if (key == previewFrames.length - 1) { | ||||
|       if (!review.has_been_reviewed) { | ||||
|         setReviewed(review.id); | ||||
|       } | ||||
|       setReviewed(); | ||||
| 
 | ||||
|       if (loop) { | ||||
|         setKey(0); | ||||
| @ -356,7 +359,7 @@ export function InProgressPreview({ | ||||
| 
 | ||||
|     setTimeout(() => { | ||||
|       if (setReviewed && key == Math.floor(previewFrames.length / 2)) { | ||||
|         setReviewed(review.id); | ||||
|         setReviewed(); | ||||
|       } | ||||
| 
 | ||||
|       if (previewFrames[key + 1]) { | ||||
| @ -377,11 +380,7 @@ export function InProgressPreview({ | ||||
|   const onManualSeek = useCallback( | ||||
|     (values: number[]) => { | ||||
|       const value = values[0]; | ||||
| 
 | ||||
|       if (!review.has_been_reviewed) { | ||||
|         setReviewed(review.id); | ||||
|       } | ||||
| 
 | ||||
|       setReviewed(); | ||||
|       setKey(value); | ||||
|     }, | ||||
| 
 | ||||
| @ -424,7 +423,7 @@ export function InProgressPreview({ | ||||
|     return ( | ||||
|       <img | ||||
|         className="size-full" | ||||
|         src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`} | ||||
|         src={defaultImageUrl} //{`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
 | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -1,5 +1,26 @@ | ||||
| import { FilterType } from "@/types/filter"; | ||||
| import { useMemo, useState } from "react"; | ||||
| import { useCallback, useMemo, useState } from "react"; | ||||
| import { useSearchParams } from "react-router-dom"; | ||||
| 
 | ||||
| function getStringifiedArgs(filter: FilterType) { | ||||
|   const search: { [key: string]: string } = {}; | ||||
| 
 | ||||
|   Object.entries(filter).forEach(([key, value]) => { | ||||
|     if (Array.isArray(value)) { | ||||
|       if (value.length == 0) { | ||||
|         // empty array means all so ignore
 | ||||
|       } else { | ||||
|         search[key] = value.join(","); | ||||
|       } | ||||
|     } else { | ||||
|       if (value != undefined) { | ||||
|         search[key] = `${value}`; | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   return search; | ||||
| } | ||||
| 
 | ||||
| type useApiFilterReturn<F extends FilterType> = [ | ||||
|   filter: F | undefined, | ||||
| @ -20,23 +41,48 @@ export default function useApiFilter< | ||||
|       return {}; | ||||
|     } | ||||
| 
 | ||||
|     const search: { [key: string]: string } = {}; | ||||
| 
 | ||||
|     Object.entries(filter).forEach(([key, value]) => { | ||||
|       if (Array.isArray(value)) { | ||||
|         if (value.length == 0) { | ||||
|           // empty array means all so ignore
 | ||||
|         } else { | ||||
|           search[key] = value.join(","); | ||||
|         } | ||||
|       } else { | ||||
|         if (value != undefined) { | ||||
|           search[key] = `${value}`; | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     return search; | ||||
|     return getStringifiedArgs(filter); | ||||
|   }, [filter]); | ||||
| 
 | ||||
|   return [filter, setFilter, searchParams]; | ||||
| } | ||||
| 
 | ||||
| export function useApiFilterArgs< | ||||
|   F extends FilterType, | ||||
| >(): useApiFilterReturn<F> { | ||||
|   const [rawParams, setRawParams] = useSearchParams(); | ||||
| 
 | ||||
|   const setFilter = useCallback( | ||||
|     (newFilter: F) => setRawParams(getStringifiedArgs(newFilter)), | ||||
|     [setRawParams], | ||||
|   ); | ||||
| 
 | ||||
|   const filter = useMemo<F>(() => { | ||||
|     if (rawParams.size == 0) { | ||||
|       return {} as F; | ||||
|     } | ||||
| 
 | ||||
|     const filter: { [key: string]: unknown } = {}; | ||||
| 
 | ||||
|     rawParams.forEach((value, key) => { | ||||
|       if (isNaN(parseFloat(value))) { | ||||
|         filter[key] = value.includes(",") ? value.split(",") : [value]; | ||||
|       } else { | ||||
|         if (value != undefined) { | ||||
|           filter[key] = `${value}`; | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     return filter as F; | ||||
|   }, [rawParams]); | ||||
| 
 | ||||
|   const searchParams = useMemo(() => { | ||||
|     if (filter == undefined || Object.keys(filter).length == 0) { | ||||
|       return {}; | ||||
|     } | ||||
| 
 | ||||
|     return getStringifiedArgs(filter); | ||||
|   }, [filter]); | ||||
| 
 | ||||
|   return [filter, setFilter, searchParams]; | ||||
|  | ||||
| @ -19,7 +19,9 @@ export const ID_PLAYGROUND = 6; | ||||
| export default function useNavigation( | ||||
|   variant: "primary" | "secondary" = "primary", | ||||
| ) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const { data: config } = useSWR<FrigateConfig>("config", { | ||||
|     revalidateOnFocus: false, | ||||
|   }); | ||||
| 
 | ||||
|   return useMemo( | ||||
|     () => | ||||
| @ -44,7 +46,6 @@ export default function useNavigation( | ||||
|           icon: IoSearch, | ||||
|           title: "Search", | ||||
|           url: "/search", | ||||
|           enabled: config?.semantic_search?.enabled, | ||||
|         }, | ||||
|         { | ||||
|           id: ID_EXPORT, | ||||
| @ -70,6 +71,6 @@ export default function useNavigation( | ||||
|           enabled: ENV !== "production", | ||||
|         }, | ||||
|       ] as NavData[], | ||||
|     [config?.plus.enabled, config?.semantic_search.enabled, variant], | ||||
|     [config?.plus.enabled, variant], | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import useApiFilter from "@/hooks/use-api-filter"; | ||||
| import { useApiFilterArgs } from "@/hooks/use-api-filter"; | ||||
| import { useCameraPreviews } from "@/hooks/use-camera-previews"; | ||||
| import { useOverlayState } from "@/hooks/use-overlay-state"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| @ -27,7 +27,7 @@ export default function Search() { | ||||
|   // search filter
 | ||||
| 
 | ||||
|   const [searchFilter, setSearchFilter, searchSearchParams] = | ||||
|     useApiFilter<SearchFilter>(); | ||||
|     useApiFilterArgs<SearchFilter>(); | ||||
| 
 | ||||
|   const onUpdateFilter = useCallback( | ||||
|     (newFilter: SearchFilter) => { | ||||
| @ -60,10 +60,6 @@ export default function Search() { | ||||
|   }, [search]); | ||||
| 
 | ||||
|   const searchQuery = useMemo(() => { | ||||
|     if (searchTerm.length == 0) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     if (similaritySearch) { | ||||
|       return [ | ||||
|         "events/search", | ||||
| @ -71,6 +67,7 @@ export default function Search() { | ||||
|           query: similaritySearch.id, | ||||
|           cameras: searchSearchParams["cameras"], | ||||
|           labels: searchSearchParams["labels"], | ||||
|           sub_labels: searchSearchParams["subLabels"], | ||||
|           zones: searchSearchParams["zones"], | ||||
|           before: searchSearchParams["before"], | ||||
|           after: searchSearchParams["after"], | ||||
| @ -80,16 +77,35 @@ export default function Search() { | ||||
|       ]; | ||||
|     } | ||||
| 
 | ||||
|     if (searchTerm) { | ||||
|       return [ | ||||
|         "events/search", | ||||
|         { | ||||
|           query: searchTerm, | ||||
|           cameras: searchSearchParams["cameras"], | ||||
|           labels: searchSearchParams["labels"], | ||||
|           sub_labels: searchSearchParams["subLabels"], | ||||
|           zones: searchSearchParams["zones"], | ||||
|           before: searchSearchParams["before"], | ||||
|           after: searchSearchParams["after"], | ||||
|           search_type: searchSearchParams["search_type"], | ||||
|           include_thumbnails: 0, | ||||
|         }, | ||||
|       ]; | ||||
|     } | ||||
| 
 | ||||
|     return [ | ||||
|       "events/search", | ||||
|       "events", | ||||
|       { | ||||
|         query: searchTerm, | ||||
|         cameras: searchSearchParams["cameras"], | ||||
|         labels: searchSearchParams["labels"], | ||||
|         sub_labels: searchSearchParams["subLabels"], | ||||
|         zones: searchSearchParams["zones"], | ||||
|         before: searchSearchParams["before"], | ||||
|         after: searchSearchParams["after"], | ||||
|         search_type: searchSearchParams["search_type"], | ||||
|         limit: Object.keys(searchSearchParams).length == 0 ? 20 : null, | ||||
|         in_progress: 0, | ||||
|         include_thumbnails: 0, | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
| @ -13,11 +13,22 @@ export type SearchResult = { | ||||
|   zones: string[]; | ||||
|   search_source: SearchSource; | ||||
|   search_distance: number; | ||||
|   data: { | ||||
|     top_score: number; | ||||
|     score: number; | ||||
|     sub_label_score?: number; | ||||
|     region: number[]; | ||||
|     box: number[]; | ||||
|     area: number; | ||||
|     ratio: number; | ||||
|     type: "object" | "audio" | "manual"; | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export type SearchFilter = { | ||||
|   cameras?: string[]; | ||||
|   labels?: string[]; | ||||
|   subLabels?: string[]; | ||||
|   zones?: string[]; | ||||
|   before?: number; | ||||
|   after?: number; | ||||
|  | ||||
| @ -11,19 +11,13 @@ import { | ||||
|   TooltipTrigger, | ||||
| } from "@/components/ui/tooltip"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { Preview } from "@/types/preview"; | ||||
| import { SearchFilter, SearchResult } from "@/types/search"; | ||||
| import { useCallback, useMemo, useState } from "react"; | ||||
| import { isMobileOnly } from "react-device-detect"; | ||||
| import { | ||||
|   LuExternalLink, | ||||
|   LuImage, | ||||
|   LuSearchCheck, | ||||
|   LuSearchX, | ||||
|   LuText, | ||||
|   LuXCircle, | ||||
| } from "react-icons/lu"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu"; | ||||
| import useSWR from "swr"; | ||||
| 
 | ||||
| type SearchViewProps = { | ||||
|   search: string; | ||||
| @ -51,6 +45,10 @@ export default function SearchView({ | ||||
|   onUpdateFilter, | ||||
|   onOpenSearch, | ||||
| }: SearchViewProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config", { | ||||
|     revalidateOnFocus: false, | ||||
|   }); | ||||
| 
 | ||||
|   // remove duplicate event ids
 | ||||
| 
 | ||||
|   const uniqueResults = useMemo(() => { | ||||
| @ -96,6 +94,11 @@ export default function SearchView({ | ||||
|     return Math.round(confidence * 100); | ||||
|   }; | ||||
| 
 | ||||
|   const hasExistingSearch = useMemo( | ||||
|     () => searchResults != undefined || searchFilter != undefined, | ||||
|     [searchResults, searchFilter], | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="flex size-full flex-col pt-2 md:py-2"> | ||||
|       <Toaster closeButton={true} /> | ||||
| @ -107,52 +110,49 @@ export default function SearchView({ | ||||
|         } | ||||
|       /> | ||||
| 
 | ||||
|       <div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3"> | ||||
|         <div className="relative mr-3 w-full md:w-1/3"> | ||||
|           <Input | ||||
|             className="text-md w-full bg-muted pr-10" | ||||
|             placeholder={ | ||||
|               isMobileOnly ? "Search" : "Search for a detected object..." | ||||
|             } | ||||
|             value={similaritySearch ? "" : search} | ||||
|             onChange={(e) => setSearch(e.target.value)} | ||||
|           /> | ||||
|           {search && ( | ||||
|             <LuXCircle | ||||
|               className="absolute right-2 top-1/2 h-5 w-5 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-primary" | ||||
|               onClick={() => setSearch("")} | ||||
|       <div | ||||
|         className={cn( | ||||
|           "relative mb-2 flex h-11 items-center pl-2 pr-2 md:pl-3", | ||||
|           config?.semantic_search?.enabled | ||||
|             ? "justify-between" | ||||
|             : "justify-center", | ||||
|           isMobileOnly && "h-[88px] flex-wrap gap-2", | ||||
|         )} | ||||
|       > | ||||
|         {config?.semantic_search?.enabled && ( | ||||
|           <div | ||||
|             className={cn( | ||||
|               "relative w-full", | ||||
|               hasExistingSearch ? "mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2", | ||||
|             )} | ||||
|           > | ||||
|             <Input | ||||
|               className="text-md w-full bg-muted pr-10" | ||||
|               placeholder={ | ||||
|                 isMobileOnly ? "Search" : "Search for a detected object..." | ||||
|               } | ||||
|               value={similaritySearch ? "" : search} | ||||
|               onChange={(e) => setSearch(e.target.value)} | ||||
|             /> | ||||
|           )} | ||||
|         </div> | ||||
| 
 | ||||
|         <SearchFilterGroup | ||||
|           filter={searchFilter} | ||||
|           onUpdateFilter={onUpdateFilter} | ||||
|         /> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"> | ||||
|         {searchTerm.length == 0 && ( | ||||
|           <div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center"> | ||||
|             <LuSearchCheck className="size-16" /> | ||||
|             Search | ||||
|             <div className="mt-2 max-w-64 text-sm text-secondary-foreground"> | ||||
|               Frigate can find detected objects in your review items. | ||||
|             </div> | ||||
|             <div className="mt-2 flex items-center text-center text-sm text-primary"> | ||||
|               <Link | ||||
|                 to="https://docs.frigate.video/configuration/semantic_search" | ||||
|                 target="_blank" | ||||
|                 rel="noopener noreferrer" | ||||
|                 className="inline" | ||||
|               > | ||||
|                 Read the Documentation{" "} | ||||
|                 <LuExternalLink className="ml-2 inline-flex size-3" /> | ||||
|               </Link> | ||||
|             </div> | ||||
|             {search && ( | ||||
|               <LuXCircle | ||||
|                 className="absolute right-2 top-1/2 h-5 w-5 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-primary" | ||||
|                 onClick={() => setSearch("")} | ||||
|               /> | ||||
|             )} | ||||
|           </div> | ||||
|         )} | ||||
| 
 | ||||
|         {hasExistingSearch && ( | ||||
|           <SearchFilterGroup | ||||
|             className={cn("", isMobileOnly && "w-full justify-between")} | ||||
|             filter={searchFilter} | ||||
|             onUpdateFilter={onUpdateFilter} | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"> | ||||
|         {searchTerm.length > 0 && searchResults?.length == 0 && ( | ||||
|           <div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center"> | ||||
|             <LuSearchX className="size-16" /> | ||||
| @ -186,34 +186,36 @@ export default function SearchView({ | ||||
|                       scrollLock={false} | ||||
|                       onClick={onSelectSearch} | ||||
|                     /> | ||||
|                     <div className={cn("absolute right-2 top-2 z-40")}> | ||||
|                       <Tooltip> | ||||
|                         <TooltipTrigger> | ||||
|                           <Chip | ||||
|                             className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`} | ||||
|                           > | ||||
|                             {value.search_source == "thumbnail" ? ( | ||||
|                               <LuImage className="mr-1 size-3" /> | ||||
|                             ) : ( | ||||
|                               <LuText className="mr-1 size-3" /> | ||||
|                             )} | ||||
|                     {searchTerm && ( | ||||
|                       <div className={cn("absolute right-2 top-2 z-40")}> | ||||
|                         <Tooltip> | ||||
|                           <TooltipTrigger> | ||||
|                             <Chip | ||||
|                               className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`} | ||||
|                             > | ||||
|                               {value.search_source == "thumbnail" ? ( | ||||
|                                 <LuImage className="mr-1 size-3" /> | ||||
|                               ) : ( | ||||
|                                 <LuText className="mr-1 size-3" /> | ||||
|                               )} | ||||
|                               {zScoreToConfidence( | ||||
|                                 value.search_distance, | ||||
|                                 value.search_source, | ||||
|                               )} | ||||
|                               % | ||||
|                             </Chip> | ||||
|                           </TooltipTrigger> | ||||
|                           <TooltipContent> | ||||
|                             Matched {value.search_source} at{" "} | ||||
|                             {zScoreToConfidence( | ||||
|                               value.search_distance, | ||||
|                               value.search_source, | ||||
|                             )} | ||||
|                             % | ||||
|                           </Chip> | ||||
|                         </TooltipTrigger> | ||||
|                         <TooltipContent> | ||||
|                           Matched {value.search_source} at{" "} | ||||
|                           {zScoreToConfidence( | ||||
|                             value.search_distance, | ||||
|                             value.search_source, | ||||
|                           )} | ||||
|                           % | ||||
|                         </TooltipContent> | ||||
|                       </Tooltip> | ||||
|                     </div> | ||||
|                           </TooltipContent> | ||||
|                         </Tooltip> | ||||
|                       </div> | ||||
|                     )} | ||||
|                   </div> | ||||
|                   <div | ||||
|                     className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`} | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user