mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +01:00 
			
		
		
		
	Implement Review Filters (#10031)
* Get cameras filter working * Implement label and review filters * Fix * Add time selection * Cleanup * Cleanup * cleanup * remove commented code * Fix
This commit is contained in:
		
							parent
							
								
									4a7c159a44
								
							
						
					
					
						commit
						9801534f11
					
				| @ -2395,7 +2395,9 @@ def vod_event(id): | ||||
| 
 | ||||
| @bp.route("/review") | ||||
| def review(): | ||||
|     camera = request.args.get("camera", "all") | ||||
|     cameras = request.args.get("cameras", "all") | ||||
|     labels = request.args.get("labels", "all") | ||||
|     reviewed = request.args.get("reviewed", default=False) | ||||
|     limit = request.args.get("limit", 100) | ||||
|     severity = request.args.get("severity", None) | ||||
| 
 | ||||
| @ -2406,8 +2408,26 @@ def review(): | ||||
| 
 | ||||
|     clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))] | ||||
| 
 | ||||
|     if camera != "all": | ||||
|         clauses.append((ReviewSegment.camera == camera)) | ||||
|     if cameras != "all": | ||||
|         camera_list = cameras.split(",") | ||||
|         clauses.append((ReviewSegment.camera << camera_list)) | ||||
| 
 | ||||
|     if labels != "all": | ||||
|         # use matching so segments with multiple labels | ||||
|         # still match on a search where any label matches | ||||
|         label_clauses = [] | ||||
|         filtered_labels = labels.split(",") | ||||
| 
 | ||||
|         for label in filtered_labels: | ||||
|             label_clauses.append( | ||||
|                 (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | ||||
|             ) | ||||
| 
 | ||||
|         label_clause = reduce(operator.or_, label_clauses) | ||||
|         clauses.append((label_clause)) | ||||
| 
 | ||||
|     if not reviewed: | ||||
|         clauses.append((ReviewSegment.has_been_reviewed == False)) | ||||
| 
 | ||||
|     if severity: | ||||
|         clauses.append((ReviewSegment.severity == severity)) | ||||
|  | ||||
| @ -1,144 +0,0 @@ | ||||
| import { getTimelineItemDescription } from "@/utils/timelineUtil"; | ||||
| import { Button } from "../ui/button"; | ||||
| import Logo from "../Logo"; | ||||
| import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; | ||||
| import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import VideoPlayer from "../player/VideoPlayer"; | ||||
| import { Card } from "../ui/card"; | ||||
| import { useApiHost } from "@/api"; | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
|   AlertDialogCancel, | ||||
|   AlertDialogContent, | ||||
|   AlertDialogDescription, | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle, | ||||
|   AlertDialogTrigger, | ||||
| } from "../ui/alert-dialog"; | ||||
| import { useCallback } from "react"; | ||||
| import axios from "axios"; | ||||
| 
 | ||||
| type TimelineItemCardProps = { | ||||
|   timeline: Timeline; | ||||
|   relevantPreview: Preview | undefined; | ||||
|   onSelect: () => void; | ||||
| }; | ||||
| export default function TimelineItemCard({ | ||||
|   timeline, | ||||
|   relevantPreview, | ||||
|   onSelect, | ||||
| }: TimelineItemCardProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const apiHost = useApiHost(); | ||||
| 
 | ||||
|   const onSubmitToPlus = useCallback( | ||||
|     async (falsePositive: boolean) => { | ||||
|       falsePositive | ||||
|         ? await axios.put(`events/${timeline.source_id}/false_positive`) | ||||
|         : await axios.post(`events/${timeline.source_id}/plus`, { | ||||
|             include_annotation: 1, | ||||
|           }); | ||||
|     }, | ||||
|     [timeline] | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <Card | ||||
|       className="relative mx-2 mb-2 flex w-full h-20 xl:h-24 3xl:h-28 4xl:h-36 cursor-pointer" | ||||
|       onClick={onSelect} | ||||
|     > | ||||
|       <div className="w-32 xl:w-40 3xl:w-44 4xl:w-60 p-2"> | ||||
|         <VideoPlayer | ||||
|           options={{ | ||||
|             preload: "auto", | ||||
|             autoplay: true, | ||||
|             controls: false, | ||||
|             aspectRatio: "16:9", | ||||
|             muted: true, | ||||
|             loadingSpinner: false, | ||||
|             poster: relevantPreview | ||||
|               ? "" | ||||
|               : `${apiHost}api/preview/${timeline.camera}/${timeline.timestamp}/thumbnail.jpg`, | ||||
|             sources: relevantPreview | ||||
|               ? [ | ||||
|                   { | ||||
|                     src: `${relevantPreview.src}`, | ||||
|                     type: "video/mp4", | ||||
|                   }, | ||||
|                 ] | ||||
|               : [], | ||||
|           }} | ||||
|           seekOptions={{}} | ||||
|           onReady={(player) => { | ||||
|             if (relevantPreview) { | ||||
|               player.pause(); // autoplay + pause is required for iOS
 | ||||
|               player.currentTime(timeline.timestamp - relevantPreview.start); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="py-1"> | ||||
|         <div className="capitalize font-semibold text-sm"> | ||||
|           {getTimelineItemDescription(timeline)} | ||||
|         </div> | ||||
|         <div className="text-sm"> | ||||
|           {formatUnixTimestampToDateTime(timeline.timestamp, { | ||||
|             strftime_fmt: | ||||
|               config?.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S %p", | ||||
|             time_style: "medium", | ||||
|             date_style: "medium", | ||||
|           })} | ||||
|         </div> | ||||
|         {timeline.source == "tracked_object" && ( | ||||
|           <AlertDialog> | ||||
|             <AlertDialogTrigger asChild> | ||||
|               <Button | ||||
|                 className="absolute bottom-1 right-1 hidden xl:flex" | ||||
|                 size="sm" | ||||
|                 variant="secondary" | ||||
|               > | ||||
|                 <div className="w-8 h-8"> | ||||
|                   <Logo /> | ||||
|                 </div> | ||||
|                 + | ||||
|               </Button> | ||||
|             </AlertDialogTrigger> | ||||
|             <AlertDialogContent> | ||||
|               <AlertDialogHeader> | ||||
|                 <AlertDialogTitle>Submit To Frigate+</AlertDialogTitle> | ||||
|                 <AlertDialogDescription> | ||||
|                   Objects in locations you want to avoid are not false | ||||
|                   positives. Submitting them as false positives will confuse the | ||||
|                   model. | ||||
|                 </AlertDialogDescription> | ||||
|               </AlertDialogHeader> | ||||
|               <img | ||||
|                 className="flex-grow-0" | ||||
|                 src={`${apiHost}api/events/${timeline.source_id}/snapshot.jpg`} | ||||
|                 alt={`${timeline.data.label}`} | ||||
|               /> | ||||
|               <AlertDialogFooter> | ||||
|                 <AlertDialogCancel>Cancel</AlertDialogCancel> | ||||
|                 <AlertDialogAction | ||||
|                   className="bg-success" | ||||
|                   onClick={() => onSubmitToPlus(false)} | ||||
|                 > | ||||
|                   This is a {timeline.data.label} | ||||
|                 </AlertDialogAction> | ||||
|                 <AlertDialogAction | ||||
|                   className="bg-danger" | ||||
|                   onClick={() => onSubmitToPlus(true)} | ||||
|                 > | ||||
|                   This is not a {timeline.data.label} | ||||
|                 </AlertDialogAction> | ||||
|               </AlertDialogFooter> | ||||
|             </AlertDialogContent> | ||||
|           </AlertDialog> | ||||
|         )} | ||||
|       </div> | ||||
|     </Card> | ||||
|   ); | ||||
| } | ||||
| @ -1,307 +0,0 @@ | ||||
| import { LuCheck, LuFilter } from "react-icons/lu"; | ||||
| import { Button } from "../ui/button"; | ||||
| import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | ||||
| import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { useMemo, useState } from "react"; | ||||
| import { | ||||
|   DropdownMenu, | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuLabel, | ||||
|   DropdownMenuRadioGroup, | ||||
|   DropdownMenuRadioItem, | ||||
|   DropdownMenuSeparator, | ||||
|   DropdownMenuTrigger, | ||||
| } from "../ui/dropdown-menu"; | ||||
| import { Calendar } from "../ui/calendar"; | ||||
| 
 | ||||
| type HistoryFilterPopoverProps = { | ||||
|   // @ts-ignore
 | ||||
|   filter: HistoryFilter | undefined; | ||||
|   // @ts-ignore
 | ||||
|   onUpdateFilter: (filter: HistoryFilter) => void; | ||||
| }; | ||||
| 
 | ||||
| export default function HistoryFilterPopover({ | ||||
|   filter, | ||||
|   onUpdateFilter, | ||||
| }: HistoryFilterPopoverProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
| 
 | ||||
|   const [open, setOpen] = useState(false); | ||||
|   const disabledDates = useMemo(() => { | ||||
|     const tomorrow = new Date(); | ||||
|     tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); | ||||
|     const future = new Date(); | ||||
|     future.setFullYear(2032); | ||||
|     return { from: tomorrow, to: future }; | ||||
|   }, []); | ||||
| 
 | ||||
|   const { data: allLabels } = useSWR<string[]>(["labels"], { | ||||
|     revalidateOnFocus: false, | ||||
|   }); | ||||
|   const { data: allSubLabels } = useSWR<string[]>( | ||||
|     ["sub_labels", { split_joined: 1 }], | ||||
|     { | ||||
|       revalidateOnFocus: false, | ||||
|     } | ||||
|   ); | ||||
|   const filterValues = useMemo( | ||||
|     () => ({ | ||||
|       cameras: Object.keys(config?.cameras || {}), | ||||
|       labels: Object.values(allLabels || {}), | ||||
|     }), | ||||
|     [config, allLabels, allSubLabels] | ||||
|   ); | ||||
|   const [selectedFilters, setSelectedFilters] = useState({ | ||||
|     cameras: filter == undefined ? ["all"] : filter.cameras, | ||||
|     labels: filter == undefined ? ["all"] : filter.labels, | ||||
|     before: filter?.before, | ||||
|     after: filter?.after, | ||||
|     detailLevel: filter?.detailLevel ?? "normal", | ||||
|   }); | ||||
|   const dateRange = useMemo(() => { | ||||
|     return selectedFilters?.before == undefined || | ||||
|       selectedFilters?.after == undefined | ||||
|       ? undefined | ||||
|       : { | ||||
|           from: new Date(selectedFilters.after * 1000), | ||||
|           to: new Date(selectedFilters.before * 1000), | ||||
|         }; | ||||
|   }, [selectedFilters]); | ||||
| 
 | ||||
|   const allItems = useMemo(() => { | ||||
|     return { | ||||
|       cameras: | ||||
|         JSON.stringify(selectedFilters.cameras) == JSON.stringify(["all"]), | ||||
|       labels: JSON.stringify(selectedFilters.labels) == JSON.stringify(["all"]), | ||||
|     }; | ||||
|   }, [selectedFilters]); | ||||
| 
 | ||||
|   return ( | ||||
|     <Popover open={open} onOpenChange={(open) => setOpen(open)}> | ||||
|       <PopoverTrigger asChild> | ||||
|         <Button> | ||||
|           <LuFilter className="mx-1" /> | ||||
|           Filter | ||||
|         </Button> | ||||
|       </PopoverTrigger> | ||||
|       <PopoverContent className="w-screen sm:w-[340px]"> | ||||
|         <div className="flex justify-around"> | ||||
|           <DropdownMenu> | ||||
|             <DropdownMenuTrigger asChild> | ||||
|               <Button className="capitalize" variant="outline"> | ||||
|                 {allItems.cameras | ||||
|                   ? "All Cameras" | ||||
|                   : `${selectedFilters.cameras.length} Cameras`} | ||||
|               </Button> | ||||
|             </DropdownMenuTrigger> | ||||
|             <DropdownMenuContent> | ||||
|               <DropdownMenuLabel>Filter Cameras</DropdownMenuLabel> | ||||
|               <DropdownMenuSeparator /> | ||||
|               <FilterCheckBox | ||||
|                 isChecked={allItems.cameras} | ||||
|                 label="All Cameras" | ||||
|                 onCheckedChange={(isChecked) => { | ||||
|                   if (isChecked) { | ||||
|                     setSelectedFilters({ | ||||
|                       ...selectedFilters, | ||||
|                       cameras: ["all"], | ||||
|                     }); | ||||
|                   } | ||||
|                 }} | ||||
|               /> | ||||
|               <DropdownMenuSeparator /> | ||||
|               {filterValues.cameras.map((item) => ( | ||||
|                 <FilterCheckBox | ||||
|                   key={item} | ||||
|                   isChecked={selectedFilters.cameras.includes(item)} | ||||
|                   label={item.replaceAll("_", " ")} | ||||
|                   onCheckedChange={(isChecked) => { | ||||
|                     if (isChecked) { | ||||
|                       const selectedCameras = allItems.cameras | ||||
|                         ? [] | ||||
|                         : [...selectedFilters.cameras]; | ||||
|                       selectedCameras.push(item); | ||||
|                       setSelectedFilters({ | ||||
|                         ...selectedFilters, | ||||
|                         cameras: selectedCameras, | ||||
|                       }); | ||||
|                     } else { | ||||
|                       const selectedCameraList = [...selectedFilters.cameras]; | ||||
| 
 | ||||
|                       // can not deselect the last item
 | ||||
|                       if (selectedCameraList.length > 1) { | ||||
|                         selectedCameraList.splice( | ||||
|                           selectedCameraList.indexOf(item), | ||||
|                           1 | ||||
|                         ); | ||||
|                         setSelectedFilters({ | ||||
|                           ...selectedFilters, | ||||
|                           cameras: selectedCameraList, | ||||
|                         }); | ||||
|                       } | ||||
|                     } | ||||
|                   }} | ||||
|                 /> | ||||
|               ))} | ||||
|             </DropdownMenuContent> | ||||
|           </DropdownMenu> | ||||
|           <DropdownMenu> | ||||
|             <DropdownMenuTrigger asChild> | ||||
|               <Button className="capitalize" variant="outline"> | ||||
|                 {allItems.labels | ||||
|                   ? "All Labels" | ||||
|                   : `${selectedFilters.labels.length} Labels`} | ||||
|               </Button> | ||||
|             </DropdownMenuTrigger> | ||||
|             <DropdownMenuContent> | ||||
|               <DropdownMenuLabel>Filter Labels</DropdownMenuLabel> | ||||
|               <DropdownMenuSeparator /> | ||||
|               <FilterCheckBox | ||||
|                 isChecked={allItems.labels} | ||||
|                 label="All Labels" | ||||
|                 onCheckedChange={(isChecked) => { | ||||
|                   if (isChecked) { | ||||
|                     setSelectedFilters({ | ||||
|                       ...selectedFilters, | ||||
|                       labels: ["all"], | ||||
|                     }); | ||||
|                   } | ||||
|                 }} | ||||
|               /> | ||||
|               <DropdownMenuSeparator /> | ||||
|               {filterValues.labels.map((item) => ( | ||||
|                 <FilterCheckBox | ||||
|                   key={item} | ||||
|                   isChecked={ | ||||
|                     selectedFilters.labels.length == 0 || | ||||
|                     selectedFilters.labels.includes(item) | ||||
|                   } | ||||
|                   label={item.replaceAll("_", " ")} | ||||
|                   onCheckedChange={(isChecked) => { | ||||
|                     if (isChecked) { | ||||
|                       const selectedLabels = allItems.labels | ||||
|                         ? [] | ||||
|                         : [...selectedFilters.labels]; | ||||
|                       selectedLabels.push(item); | ||||
|                       setSelectedFilters({ | ||||
|                         ...selectedFilters, | ||||
|                         labels: selectedLabels, | ||||
|                       }); | ||||
|                     } else { | ||||
|                       const selectedLabelList = [...selectedFilters.labels]; | ||||
| 
 | ||||
|                       // can not deselect the last item
 | ||||
|                       if (selectedLabelList.length > 1) { | ||||
|                         selectedLabelList.splice( | ||||
|                           selectedLabelList.indexOf(item), | ||||
|                           1 | ||||
|                         ); | ||||
|                         setSelectedFilters({ | ||||
|                           ...selectedFilters, | ||||
|                           labels: selectedLabelList, | ||||
|                         }); | ||||
|                       } | ||||
|                     } | ||||
|                   }} | ||||
|                 /> | ||||
|               ))} | ||||
|             </DropdownMenuContent> | ||||
|           </DropdownMenu> | ||||
|           <DropdownMenu> | ||||
|             <DropdownMenuTrigger asChild> | ||||
|               <Button className="capitalize" variant="outline"> | ||||
|                 {selectedFilters.detailLevel} | ||||
|               </Button> | ||||
|             </DropdownMenuTrigger> | ||||
|             <DropdownMenuContent> | ||||
|               <DropdownMenuLabel> | ||||
|                 Detail Level | ||||
|               </DropdownMenuLabel> | ||||
|               <DropdownMenuSeparator /> | ||||
|               <DropdownMenuRadioGroup | ||||
|                 value={selectedFilters.detailLevel} | ||||
|                 onValueChange={(value) => { | ||||
|                   setSelectedFilters({ | ||||
|                     ...selectedFilters, | ||||
|                     // @ts-ignore we know that value is one of the detailLevel
 | ||||
|                     detailLevel: value, | ||||
|                   }); | ||||
|                 }} | ||||
|               > | ||||
|                 <DropdownMenuRadioItem value="normal"> | ||||
|                   Normal | ||||
|                 </DropdownMenuRadioItem> | ||||
|                 <DropdownMenuRadioItem value="extra"> | ||||
|                   Extra | ||||
|                 </DropdownMenuRadioItem> | ||||
|                 <DropdownMenuRadioItem value="full">Full</DropdownMenuRadioItem> | ||||
|               </DropdownMenuRadioGroup> | ||||
|             </DropdownMenuContent> | ||||
|           </DropdownMenu> | ||||
|         </div> | ||||
|         <Calendar | ||||
|           mode="range" | ||||
|           disabled={disabledDates} | ||||
|           selected={dateRange} | ||||
|           onSelect={(range) => { | ||||
|             let afterTime = undefined; | ||||
|             if (range?.from != undefined) { | ||||
|               afterTime = range.from.getTime() / 1000; | ||||
|             } | ||||
| 
 | ||||
|             // need to make sure the day selected for before covers the entire day
 | ||||
|             let beforeTime = undefined; | ||||
|             if (range?.from != undefined) { | ||||
|               const beforeDate = range.to ?? range.from; | ||||
|               beforeDate.setHours(beforeDate.getHours() + 24, -1, 0, 0); | ||||
|               beforeTime = beforeDate.getTime() / 1000; | ||||
|             } | ||||
| 
 | ||||
|             setSelectedFilters({ | ||||
|               ...selectedFilters, | ||||
|               after: afterTime, | ||||
|               before: beforeTime, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|         <Button | ||||
|           onClick={() => { | ||||
|             onUpdateFilter(selectedFilters); | ||||
|             setOpen(false); | ||||
|           }} | ||||
|         > | ||||
|           Save | ||||
|         </Button> | ||||
|       </PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type FilterCheckBoxProps = { | ||||
|   label: string; | ||||
|   isChecked: boolean; | ||||
|   onCheckedChange: (isChecked: boolean) => void; | ||||
| }; | ||||
| 
 | ||||
| function FilterCheckBox({ | ||||
|   label, | ||||
|   isChecked, | ||||
|   onCheckedChange, | ||||
| }: FilterCheckBoxProps) { | ||||
|   return ( | ||||
|     <Button | ||||
|       className="capitalize flex justify-between items-center cursor-pointer w-full" | ||||
|       variant="ghost" | ||||
|       onClick={(_) => onCheckedChange(!isChecked)} | ||||
|     > | ||||
|       {isChecked ? ( | ||||
|         <LuCheck className="w-6 h-6" /> | ||||
|       ) : ( | ||||
|         <div className="w-6 h-6" /> | ||||
|       )} | ||||
|       <div className="ml-1 w-full flex justify-start">{label}</div> | ||||
|     </Button> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										371
									
								
								web/src/components/filter/ReviewFilterGroup.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										371
									
								
								web/src/components/filter/ReviewFilterGroup.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,371 @@ | ||||
| import { LuCalendar, LuCheck, LuFilter, LuVideo } from "react-icons/lu"; | ||||
| import { Button } from "../ui/button"; | ||||
| import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | ||||
| import useSWR from "swr"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { useCallback, useMemo, useState } from "react"; | ||||
| import { | ||||
|   DropdownMenu, | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuLabel, | ||||
|   DropdownMenuSeparator, | ||||
|   DropdownMenuTrigger, | ||||
| } from "../ui/dropdown-menu"; | ||||
| import { Calendar } from "../ui/calendar"; | ||||
| import { ReviewFilter } from "@/types/review"; | ||||
| import { getEndOfDayTimestamp } from "@/utils/dateUtil"; | ||||
| import { useFormattedTimestamp } from "@/hooks/use-date-utils"; | ||||
| 
 | ||||
| const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; | ||||
| 
 | ||||
| type ReviewFilterGroupProps = { | ||||
|   filter?: ReviewFilter; | ||||
|   onUpdateFilter: (filter: ReviewFilter) => void; | ||||
| }; | ||||
| 
 | ||||
| export default function ReviewFilterGroup({ | ||||
|   filter, | ||||
|   onUpdateFilter, | ||||
| }: ReviewFilterGroupProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
| 
 | ||||
|   const allLabels = useMemo<string[]>(() => { | ||||
|     if (!config) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     const labels = new Set<string>(); | ||||
|     const cameras = filter?.cameras || Object.keys(config.cameras); | ||||
| 
 | ||||
|     cameras.forEach((camera) => { | ||||
|       config.cameras[camera].objects.track.forEach((label) => { | ||||
|         if (!ATTRIBUTES.includes(label)) { | ||||
|           labels.add(label); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     return [...labels]; | ||||
|   }, [config, filter]); | ||||
| 
 | ||||
|   const filterValues = useMemo( | ||||
|     () => ({ | ||||
|       cameras: Object.keys(config?.cameras || {}), | ||||
|       labels: Object.values(allLabels || {}), | ||||
|     }), | ||||
|     [config, allLabels] | ||||
|   ); | ||||
| 
 | ||||
|   // handle updating filters
 | ||||
| 
 | ||||
|   const onUpdateSelectedDay = useCallback( | ||||
|     (day?: Date) => { | ||||
|       onUpdateFilter({ | ||||
|         ...filter, | ||||
|         after: day == undefined ? undefined : day.getTime() / 1000, | ||||
|         before: day == undefined ? undefined : getEndOfDayTimestamp(day), | ||||
|       }); | ||||
|     }, | ||||
|     [onUpdateFilter] | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="mr-2"> | ||||
|       <CamerasFilterButton | ||||
|         allCameras={filterValues.cameras} | ||||
|         selectedCameras={filter?.cameras} | ||||
|         updateCameraFilter={(newCameras) => { | ||||
|           onUpdateFilter({ ...filter, cameras: newCameras }); | ||||
|         }} | ||||
|       /> | ||||
|       <CalendarFilterButton | ||||
|         day={ | ||||
|           filter?.after == undefined ? undefined : new Date(filter.after * 1000) | ||||
|         } | ||||
|         updateSelectedDay={onUpdateSelectedDay} | ||||
|       /> | ||||
|       <GeneralFilterButton | ||||
|         allLabels={filterValues.labels} | ||||
|         selectedLabels={filter?.labels} | ||||
|         updateLabelFilter={(newLabels) => { | ||||
|           onUpdateFilter({ ...filter, labels: newLabels }); | ||||
|         }} | ||||
|         showReviewed={filter?.showReviewed || false} | ||||
|         setShowReviewed={(reviewed) => | ||||
|           onUpdateFilter({ ...filter, showReviewed: reviewed }) | ||||
|         } | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type CameraFilterButtonProps = { | ||||
|   allCameras: string[]; | ||||
|   selectedCameras: string[] | undefined; | ||||
|   updateCameraFilter: (cameras: string[] | undefined) => void; | ||||
| }; | ||||
| function CamerasFilterButton({ | ||||
|   allCameras, | ||||
|   selectedCameras, | ||||
|   updateCameraFilter, | ||||
| }: CameraFilterButtonProps) { | ||||
|   const [currentCameras, setCurrentCameras] = useState<string[] | undefined>( | ||||
|     selectedCameras | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <DropdownMenu | ||||
|       onOpenChange={(open) => { | ||||
|         if (!open) { | ||||
|           updateCameraFilter(currentCameras); | ||||
|         } | ||||
|       }} | ||||
|     > | ||||
|       <DropdownMenuTrigger asChild> | ||||
|         <Button className="mx-1 capitalize" variant="secondary"> | ||||
|           <LuVideo className=" mr-[10px]" /> | ||||
|           {selectedCameras == undefined | ||||
|             ? "All Cameras" | ||||
|             : `${selectedCameras.length} Cameras`} | ||||
|         </Button> | ||||
|       </DropdownMenuTrigger> | ||||
|       <DropdownMenuContent> | ||||
|         <DropdownMenuLabel>Filter Cameras</DropdownMenuLabel> | ||||
|         <DropdownMenuSeparator /> | ||||
|         <FilterCheckBox | ||||
|           isChecked={currentCameras == undefined} | ||||
|           label="All Cameras" | ||||
|           onCheckedChange={(isChecked) => { | ||||
|             if (isChecked) { | ||||
|               setCurrentCameras(undefined); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|         <DropdownMenuSeparator /> | ||||
|         {allCameras.map((item) => ( | ||||
|           <FilterCheckBox | ||||
|             key={item} | ||||
|             isChecked={currentCameras?.includes(item) ?? false} | ||||
|             label={item.replaceAll("_", " ")} | ||||
|             onCheckedChange={(isChecked) => { | ||||
|               if (isChecked) { | ||||
|                 const updatedCameras = currentCameras | ||||
|                   ? [...currentCameras] | ||||
|                   : []; | ||||
| 
 | ||||
|                 updatedCameras.push(item); | ||||
|                 setCurrentCameras(updatedCameras); | ||||
|               } else { | ||||
|                 const updatedCameras = currentCameras | ||||
|                   ? [...currentCameras] | ||||
|                   : []; | ||||
| 
 | ||||
|                 // can not deselect the last item
 | ||||
|                 if (updatedCameras.length > 1) { | ||||
|                   updatedCameras.splice(updatedCameras.indexOf(item), 1); | ||||
|                   setCurrentCameras(updatedCameras); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|         ))} | ||||
|         <DropdownMenuSeparator /> | ||||
|       </DropdownMenuContent> | ||||
|     </DropdownMenu> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type CalendarFilterButtonProps = { | ||||
|   day?: Date; | ||||
|   updateSelectedDay: (day?: Date) => void; | ||||
| }; | ||||
| function CalendarFilterButton({ | ||||
|   day, | ||||
|   updateSelectedDay, | ||||
| }: CalendarFilterButtonProps) { | ||||
|   const [selectedDay, setSelectedDay] = useState(day); | ||||
|   const disabledDates = useMemo(() => { | ||||
|     const tomorrow = new Date(); | ||||
|     tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); | ||||
|     const future = new Date(); | ||||
|     future.setFullYear(tomorrow.getFullYear() + 10); | ||||
|     return { from: tomorrow, to: future }; | ||||
|   }, []); | ||||
|   const selectedDate = useFormattedTimestamp( | ||||
|     day == undefined ? 0 : day?.getTime() / 1000, | ||||
|     "%b %-d" | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <Popover | ||||
|       onOpenChange={(open) => { | ||||
|         if (!open) { | ||||
|           updateSelectedDay(selectedDay); | ||||
|         } | ||||
|       }} | ||||
|     > | ||||
|       <PopoverTrigger asChild> | ||||
|         <Button className="mx-1" variant="secondary"> | ||||
|           <LuCalendar className=" mr-[10px]" /> | ||||
|           {day == undefined ? "Last 24 Hours" : selectedDate} | ||||
|         </Button> | ||||
|       </PopoverTrigger> | ||||
|       <PopoverContent> | ||||
|         <Calendar | ||||
|           mode="single" | ||||
|           disabled={disabledDates} | ||||
|           selected={selectedDay} | ||||
|           onSelect={(day) => { | ||||
|             setSelectedDay(day); | ||||
|           }} | ||||
|         /> | ||||
|       </PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type GeneralFilterButtonProps = { | ||||
|   allLabels: string[]; | ||||
|   selectedLabels: string[] | undefined; | ||||
|   updateLabelFilter: (labels: string[] | undefined) => void; | ||||
|   showReviewed: boolean; | ||||
|   setShowReviewed: (reviewed: boolean) => void; | ||||
| }; | ||||
| function GeneralFilterButton({ | ||||
|   allLabels, | ||||
|   selectedLabels, | ||||
|   updateLabelFilter, | ||||
|   showReviewed, | ||||
|   setShowReviewed, | ||||
| }: GeneralFilterButtonProps) { | ||||
|   return ( | ||||
|     <Popover> | ||||
|       <PopoverTrigger asChild> | ||||
|         <Button className="mx-1" variant="secondary"> | ||||
|           <LuFilter className=" mr-[10px]" /> | ||||
|           Filter | ||||
|         </Button> | ||||
|       </PopoverTrigger> | ||||
|       <PopoverContent side="left" asChild> | ||||
|         <div className="w-80 flex"> | ||||
|           <LabelsFilterButton | ||||
|             allLabels={allLabels} | ||||
|             selectedLabels={selectedLabels} | ||||
|             updateLabelFilter={updateLabelFilter} | ||||
|           /> | ||||
|           <Button | ||||
|             className="capitalize flex justify-between items-center cursor-pointer w-full" | ||||
|             variant="secondary" | ||||
|             onClick={(_) => setShowReviewed(!showReviewed)} | ||||
|           > | ||||
|             {showReviewed ? ( | ||||
|               <LuCheck className="w-6 h-6" /> | ||||
|             ) : ( | ||||
|               <div className="w-6 h-6" /> | ||||
|             )} | ||||
|             <div className="ml-1 w-full flex justify-start">Show Reviewed</div> | ||||
|           </Button> | ||||
|         </div> | ||||
|       </PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type LabelFilterButtonProps = { | ||||
|   allLabels: string[]; | ||||
|   selectedLabels: string[] | undefined; | ||||
|   updateLabelFilter: (labels: string[] | undefined) => void; | ||||
| }; | ||||
| function LabelsFilterButton({ | ||||
|   allLabels, | ||||
|   selectedLabels, | ||||
|   updateLabelFilter, | ||||
| }: LabelFilterButtonProps) { | ||||
|   const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( | ||||
|     selectedLabels | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <DropdownMenu | ||||
|       onOpenChange={(open) => { | ||||
|         if (!open) { | ||||
|           updateLabelFilter(currentLabels); | ||||
|         } | ||||
|       }} | ||||
|     > | ||||
|       <DropdownMenuTrigger asChild> | ||||
|         <Button className="mx-1 capitalize" variant="secondary"> | ||||
|           <LuVideo className=" mr-[10px]" /> | ||||
|           {selectedLabels == undefined | ||||
|             ? "All Labels" | ||||
|             : `${selectedLabels.length} Labels`} | ||||
|         </Button> | ||||
|       </DropdownMenuTrigger> | ||||
|       <DropdownMenuContent> | ||||
|         <DropdownMenuLabel>Filter Labels</DropdownMenuLabel> | ||||
|         <DropdownMenuSeparator /> | ||||
|         <FilterCheckBox | ||||
|           isChecked={currentLabels == undefined} | ||||
|           label="All Labels" | ||||
|           onCheckedChange={(isChecked) => { | ||||
|             if (isChecked) { | ||||
|               setCurrentLabels(undefined); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|         <DropdownMenuSeparator /> | ||||
|         {allLabels.map((item) => ( | ||||
|           <FilterCheckBox | ||||
|             key={item} | ||||
|             isChecked={currentLabels?.includes(item) ?? false} | ||||
|             label={item.replaceAll("_", " ")} | ||||
|             onCheckedChange={(isChecked) => { | ||||
|               if (isChecked) { | ||||
|                 const updatedLabels = currentLabels ? [...currentLabels] : []; | ||||
| 
 | ||||
|                 updatedLabels.push(item); | ||||
|                 setCurrentLabels(updatedLabels); | ||||
|               } else { | ||||
|                 const updatedLabels = currentLabels ? [...currentLabels] : []; | ||||
| 
 | ||||
|                 // can not deselect the last item
 | ||||
|                 if (updatedLabels.length > 1) { | ||||
|                   updatedLabels.splice(updatedLabels.indexOf(item), 1); | ||||
|                   setCurrentLabels(updatedLabels); | ||||
|                 } | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|         ))} | ||||
|         <DropdownMenuSeparator /> | ||||
|       </DropdownMenuContent> | ||||
|     </DropdownMenu> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| type FilterCheckBoxProps = { | ||||
|   label: string; | ||||
|   isChecked: boolean; | ||||
|   onCheckedChange: (isChecked: boolean) => void; | ||||
| }; | ||||
| 
 | ||||
| function FilterCheckBox({ | ||||
|   label, | ||||
|   isChecked, | ||||
|   onCheckedChange, | ||||
| }: FilterCheckBoxProps) { | ||||
|   return ( | ||||
|     <Button | ||||
|       className="capitalize flex justify-between items-center cursor-pointer w-full" | ||||
|       variant="ghost" | ||||
|       onClick={(_) => onCheckedChange(!isChecked)} | ||||
|     > | ||||
|       {isChecked ? ( | ||||
|         <LuCheck className="w-6 h-6" /> | ||||
|       ) : ( | ||||
|         <div className="w-6 h-6" /> | ||||
|       )} | ||||
|       <div className="ml-1 w-full flex justify-start">{label}</div> | ||||
|     </Button> | ||||
|   ); | ||||
| } | ||||
| @ -1,6 +1,6 @@ | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||
| import { useApiHost } from "@/api"; | ||||
| import { formatUnixTimestampToDateTime, isCurrentHour } from "@/utils/dateUtil"; | ||||
| import { isCurrentHour } from "@/utils/dateUtil"; | ||||
| import { ReviewSegment } from "@/types/review"; | ||||
| import { Slider } from "../ui/slider"; | ||||
| import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil"; | ||||
| @ -18,6 +18,7 @@ import { | ||||
| } from "../ui/context-menu"; | ||||
| import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu"; | ||||
| import axios from "axios"; | ||||
| import { useFormattedTimestamp } from "@/hooks/use-date-utils"; | ||||
| 
 | ||||
| type PreviewPlayerProps = { | ||||
|   review: ReviewSegment; | ||||
| @ -92,6 +93,13 @@ export default function PreviewThumbnailPlayer({ | ||||
|     [hoverTimeout, review] | ||||
|   ); | ||||
| 
 | ||||
|   // date
 | ||||
| 
 | ||||
|   const formattedDate = useFormattedTimestamp( | ||||
|     review.start_time, | ||||
|     config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p" | ||||
|   ); | ||||
| 
 | ||||
|   return ( | ||||
|     <ContextMenu> | ||||
|       <ContextMenuTrigger asChild> | ||||
| @ -137,13 +145,7 @@ export default function PreviewThumbnailPlayer({ | ||||
|           {!playingBack && ( | ||||
|             <div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white"> | ||||
|               <TimeAgo time={review.start_time * 1000} dense /> | ||||
|               {config && | ||||
|                 formatUnixTimestampToDateTime(review.start_time, { | ||||
|                   strftime_fmt: | ||||
|                     config.ui.time_format == "24hour" | ||||
|                       ? "%b %-d, %H:%M" | ||||
|                       : "%b %-d, %I:%M %p", | ||||
|                 })} | ||||
|               {formattedDate} | ||||
|             </div> | ||||
|           )} | ||||
|           <div className="absolute top-0 left-0 right-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none" /> | ||||
|  | ||||
| @ -3,11 +3,9 @@ import { useMemo, useState } from "react"; | ||||
| type useApiFilterReturn<F extends FilterType> = [ | ||||
|   filter: F | undefined, | ||||
|   setFilter: (filter: F) => void, | ||||
|   searchParams: | ||||
|     | { | ||||
|   searchParams: { | ||||
|     [key: string]: any; | ||||
|       } | ||||
|     | undefined, | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| export default function useApiFilter< | ||||
| @ -16,7 +14,7 @@ export default function useApiFilter< | ||||
|   const [filter, setFilter] = useState<F | undefined>(undefined); | ||||
|   const searchParams = useMemo(() => { | ||||
|     if (filter == undefined) { | ||||
|       return undefined; | ||||
|       return {}; | ||||
|     } | ||||
| 
 | ||||
|     const search: { [key: string]: string } = {}; | ||||
|  | ||||
							
								
								
									
										12
									
								
								web/src/hooks/use-date-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web/src/hooks/use-date-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; | ||||
| import { useMemo } from "react"; | ||||
| 
 | ||||
| export function useFormattedTimestamp(timestamp: number, format: string) { | ||||
|   const formattedTimestamp = useMemo(() => { | ||||
|     return formatUnixTimestampToDateTime(timestamp, { | ||||
|       strftime_fmt: format, | ||||
|     }); | ||||
|   }, [format, timestamp]); | ||||
| 
 | ||||
|   return formattedTimestamp; | ||||
| } | ||||
| @ -1,5 +1,6 @@ | ||||
| import useApiFilter from "@/hooks/use-api-filter"; | ||||
| import useOverlayState from "@/hooks/use-overlay-state"; | ||||
| import { ReviewSegment } from "@/types/review"; | ||||
| import { ReviewFilter, ReviewSegment } from "@/types/review"; | ||||
| import DesktopEventView from "@/views/events/DesktopEventView"; | ||||
| import DesktopRecordingView from "@/views/events/DesktopRecordingView"; | ||||
| import MobileEventView from "@/views/events/MobileEventView"; | ||||
| @ -15,6 +16,16 @@ export default function Events() { | ||||
|   // recordings viewer
 | ||||
|   const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); | ||||
| 
 | ||||
|   // review filter
 | ||||
| 
 | ||||
|   const [reviewFilter, setReviewFilter, reviewSearchParams] = | ||||
|     useApiFilter<ReviewFilter>(); | ||||
| 
 | ||||
|   const onUpdateFilter = useCallback((newFilter: ReviewFilter) => { | ||||
|       setSize(1); | ||||
|       setReviewFilter(newFilter); | ||||
|   }, []) | ||||
| 
 | ||||
|   // review paging
 | ||||
| 
 | ||||
|   const timeRange = useMemo(() => { | ||||
| @ -26,29 +37,29 @@ export default function Events() { | ||||
|     return axios.get(path, { params }).then((res) => res.data); | ||||
|   }, []); | ||||
| 
 | ||||
|   const reviewSearchParams = {}; | ||||
|   const getKey = useCallback( | ||||
|     (index: number, prevData: ReviewSegment[]) => { | ||||
|       if (index > 0) { | ||||
|         const lastDate = prevData[prevData.length - 1].start_time; | ||||
|         const pagedParams = reviewSearchParams | ||||
|           ? { before: lastDate, after: timeRange.after, limit: API_LIMIT } | ||||
|           : { | ||||
|               ...reviewSearchParams, | ||||
|         reviewSearchParams; | ||||
|         const pagedParams = { | ||||
|           cameras: reviewSearchParams["cameras"], | ||||
|           labels: reviewSearchParams["labels"], | ||||
|           reviewed: reviewSearchParams["showReviewed"] || false, | ||||
|           before: lastDate, | ||||
|               after: timeRange.after, | ||||
|           after: reviewSearchParams["after"] || timeRange.after, | ||||
|           limit: API_LIMIT, | ||||
|         }; | ||||
|         return ["review", pagedParams]; | ||||
|       } | ||||
| 
 | ||||
|       const params = reviewSearchParams | ||||
|         ? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after } | ||||
|         : { | ||||
|             ...reviewSearchParams, | ||||
|       const params = { | ||||
|         cameras: reviewSearchParams["cameras"], | ||||
|         labels: reviewSearchParams["labels"], | ||||
|         reviewed: reviewSearchParams["showReviewed"] || false, | ||||
|         limit: API_LIMIT, | ||||
|             before: timeRange.before, | ||||
|             after: timeRange.after, | ||||
|         before: reviewSearchParams["before"] || timeRange.before, | ||||
|         after: reviewSearchParams["after"] || timeRange.after, | ||||
|       }; | ||||
|       return ["review", params]; | ||||
|     }, | ||||
| @ -130,7 +141,7 @@ export default function Events() { | ||||
| 
 | ||||
|             return newData; | ||||
|           }, | ||||
|           { revalidate: false } | ||||
|           { revalidate: false, populateCache: true } | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
| @ -197,10 +208,12 @@ export default function Events() { | ||||
|         timeRange={timeRange} | ||||
|         reachedEnd={isDone} | ||||
|         isValidating={isValidating} | ||||
|         filter={reviewFilter} | ||||
|         loadNextPage={() => setSize(size + 1)} | ||||
|         markItemAsReviewed={markItemAsReviewed} | ||||
|         onSelectReview={setSelectedReviewId} | ||||
|         pullLatestData={updateSegments} | ||||
|         updateFilter={onUpdateFilter} | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @ -19,3 +19,11 @@ export type ReviewData = { | ||||
|   significant_motion_areas: number[]; | ||||
|   zones: string[]; | ||||
| }; | ||||
| 
 | ||||
| export type ReviewFilter = { | ||||
|   cameras?: string[]; | ||||
|   labels?: string[]; | ||||
|   before?: number; | ||||
|   after?: number; | ||||
|   showReviewed?: boolean; | ||||
| }; | ||||
|  | ||||
| @ -293,6 +293,11 @@ export function endOfHourOrCurrentTime(timestamp: number) { | ||||
|   return Math.min(timestamp, now.getTime() / 1000); | ||||
| } | ||||
| 
 | ||||
| export function getEndOfDayTimestamp(date: Date) { | ||||
|   date.setHours(23, 59, 59, 999); | ||||
|   return date.getTime() / 1000; | ||||
| } | ||||
| 
 | ||||
| export function isCurrentHour(timestamp: number) { | ||||
|   const now = new Date(); | ||||
|   now.setMinutes(0, 0, 0); | ||||
|  | ||||
| @ -1,20 +1,14 @@ | ||||
| import { useFrigateEvents } from "@/api/ws"; | ||||
| import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; | ||||
| import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; | ||||
| import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; | ||||
| import ActivityIndicator from "@/components/ui/activity-indicator"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { Calendar } from "@/components/ui/calendar"; | ||||
| import { | ||||
|   Popover, | ||||
|   PopoverContent, | ||||
|   PopoverTrigger, | ||||
| } from "@/components/ui/popover"; | ||||
| import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; | ||||
| import { FrigateConfig } from "@/types/frigateConfig"; | ||||
| import { ReviewSegment, ReviewSeverity } from "@/types/review"; | ||||
| import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; | ||||
| import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||
| import { LuCalendar, LuFilter, LuRefreshCcw, LuVideo } from "react-icons/lu"; | ||||
| import { LuRefreshCcw } from "react-icons/lu"; | ||||
| import { MdCircle } from "react-icons/md"; | ||||
| import useSWR from "swr"; | ||||
| 
 | ||||
| @ -24,10 +18,12 @@ type DesktopEventViewProps = { | ||||
|   timeRange: { before: number; after: number }; | ||||
|   reachedEnd: boolean; | ||||
|   isValidating: boolean; | ||||
|   filter?: ReviewFilter; | ||||
|   loadNextPage: () => void; | ||||
|   markItemAsReviewed: (reviewId: string) => void; | ||||
|   onSelectReview: (reviewId: string) => void; | ||||
|   pullLatestData: () => void; | ||||
|   updateFilter: (filter: ReviewFilter) => void; | ||||
| }; | ||||
| export default function DesktopEventView({ | ||||
|   reviewPages, | ||||
| @ -35,10 +31,12 @@ export default function DesktopEventView({ | ||||
|   timeRange, | ||||
|   reachedEnd, | ||||
|   isValidating, | ||||
|   filter, | ||||
|   loadNextPage, | ||||
|   markItemAsReviewed, | ||||
|   onSelectReview, | ||||
|   pullLatestData, | ||||
|   updateFilter, | ||||
| }: DesktopEventViewProps) { | ||||
|   const { data: config } = useSWR<FrigateConfig>("config"); | ||||
|   const [severity, setSeverity] = useState<ReviewSeverity>("alert"); | ||||
| @ -234,17 +232,7 @@ export default function DesktopEventView({ | ||||
|             Motion | ||||
|           </ToggleGroupItem> | ||||
|         </ToggleGroup> | ||||
|         <div> | ||||
|           <Button className="mx-1" variant="secondary"> | ||||
|             <LuVideo className=" mr-[10px]" /> | ||||
|             All Cameras | ||||
|           </Button> | ||||
|           <ReviewCalendarButton /> | ||||
|           <Button className="mx-1" variant="secondary"> | ||||
|             <LuFilter className=" mr-[10px]" /> | ||||
|             Filter | ||||
|           </Button> | ||||
|         </div> | ||||
|         <ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} /> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="flex h-full overflow-hidden"> | ||||
| @ -334,29 +322,3 @@ export default function DesktopEventView({ | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function ReviewCalendarButton() { | ||||
|   const disabledDates = useMemo(() => { | ||||
|     const tomorrow = new Date(); | ||||
|     tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0); | ||||
|     const future = new Date(); | ||||
|     future.setFullYear(tomorrow.getFullYear() + 10); | ||||
|     return { from: tomorrow, to: future }; | ||||
|   }, []); | ||||
| 
 | ||||
|   return ( | ||||
|     <Popover> | ||||
|       <PopoverTrigger asChild> | ||||
|         <Button className="mx-1" variant="secondary"> | ||||
|           <LuCalendar className=" mr-[10px]" /> | ||||
|           {formatUnixTimestampToDateTime(Date.now() / 1000, { | ||||
|             strftime_fmt: "%b %-d", | ||||
|           })} | ||||
|         </Button> | ||||
|       </PopoverTrigger> | ||||
|       <PopoverContent> | ||||
|         <Calendar mode="single" disabled={disabledDates} /> | ||||
|       </PopoverContent> | ||||
|     </Popover> | ||||
|   ); | ||||
| } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user