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") | @bp.route("/review") | ||||||
| def 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) |     limit = request.args.get("limit", 100) | ||||||
|     severity = request.args.get("severity", None) |     severity = request.args.get("severity", None) | ||||||
| 
 | 
 | ||||||
| @ -2406,8 +2408,26 @@ def review(): | |||||||
| 
 | 
 | ||||||
|     clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))] |     clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))] | ||||||
| 
 | 
 | ||||||
|     if camera != "all": |     if cameras != "all": | ||||||
|         clauses.append((ReviewSegment.camera == camera)) |         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: |     if severity: | ||||||
|         clauses.append((ReviewSegment.severity == 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||||
| import { useApiHost } from "@/api"; | import { useApiHost } from "@/api"; | ||||||
| import { formatUnixTimestampToDateTime, isCurrentHour } from "@/utils/dateUtil"; | import { isCurrentHour } from "@/utils/dateUtil"; | ||||||
| import { ReviewSegment } from "@/types/review"; | import { ReviewSegment } from "@/types/review"; | ||||||
| import { Slider } from "../ui/slider"; | import { Slider } from "../ui/slider"; | ||||||
| import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil"; | import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil"; | ||||||
| @ -18,6 +18,7 @@ import { | |||||||
| } from "../ui/context-menu"; | } from "../ui/context-menu"; | ||||||
| import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu"; | import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu"; | ||||||
| import axios from "axios"; | import axios from "axios"; | ||||||
|  | import { useFormattedTimestamp } from "@/hooks/use-date-utils"; | ||||||
| 
 | 
 | ||||||
| type PreviewPlayerProps = { | type PreviewPlayerProps = { | ||||||
|   review: ReviewSegment; |   review: ReviewSegment; | ||||||
| @ -92,6 +93,13 @@ export default function PreviewThumbnailPlayer({ | |||||||
|     [hoverTimeout, review] |     [hoverTimeout, review] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|  |   // date
 | ||||||
|  | 
 | ||||||
|  |   const formattedDate = useFormattedTimestamp( | ||||||
|  |     review.start_time, | ||||||
|  |     config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p" | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <ContextMenu> |     <ContextMenu> | ||||||
|       <ContextMenuTrigger asChild> |       <ContextMenuTrigger asChild> | ||||||
| @ -137,13 +145,7 @@ export default function PreviewThumbnailPlayer({ | |||||||
|           {!playingBack && ( |           {!playingBack && ( | ||||||
|             <div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white"> |             <div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white"> | ||||||
|               <TimeAgo time={review.start_time * 1000} dense /> |               <TimeAgo time={review.start_time * 1000} dense /> | ||||||
|               {config && |               {formattedDate} | ||||||
|                 formatUnixTimestampToDateTime(review.start_time, { |  | ||||||
|                   strftime_fmt: |  | ||||||
|                     config.ui.time_format == "24hour" |  | ||||||
|                       ? "%b %-d, %H:%M" |  | ||||||
|                       : "%b %-d, %I:%M %p", |  | ||||||
|                 })} |  | ||||||
|             </div> |             </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" /> |           <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> = [ | type useApiFilterReturn<F extends FilterType> = [ | ||||||
|   filter: F | undefined, |   filter: F | undefined, | ||||||
|   setFilter: (filter: F) => void, |   setFilter: (filter: F) => void, | ||||||
|   searchParams: |   searchParams: { | ||||||
|     | { |  | ||||||
|     [key: string]: any; |     [key: string]: any; | ||||||
|       } |   }, | ||||||
|     | undefined, |  | ||||||
| ]; | ]; | ||||||
| 
 | 
 | ||||||
| export default function useApiFilter< | export default function useApiFilter< | ||||||
| @ -16,7 +14,7 @@ export default function useApiFilter< | |||||||
|   const [filter, setFilter] = useState<F | undefined>(undefined); |   const [filter, setFilter] = useState<F | undefined>(undefined); | ||||||
|   const searchParams = useMemo(() => { |   const searchParams = useMemo(() => { | ||||||
|     if (filter == undefined) { |     if (filter == undefined) { | ||||||
|       return undefined; |       return {}; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const search: { [key: string]: string } = {}; |     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 useOverlayState from "@/hooks/use-overlay-state"; | ||||||
| import { ReviewSegment } from "@/types/review"; | import { ReviewFilter, ReviewSegment } from "@/types/review"; | ||||||
| import DesktopEventView from "@/views/events/DesktopEventView"; | import DesktopEventView from "@/views/events/DesktopEventView"; | ||||||
| import DesktopRecordingView from "@/views/events/DesktopRecordingView"; | import DesktopRecordingView from "@/views/events/DesktopRecordingView"; | ||||||
| import MobileEventView from "@/views/events/MobileEventView"; | import MobileEventView from "@/views/events/MobileEventView"; | ||||||
| @ -15,6 +16,16 @@ export default function Events() { | |||||||
|   // recordings viewer
 |   // recordings viewer
 | ||||||
|   const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); |   const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); | ||||||
| 
 | 
 | ||||||
|  |   // review filter
 | ||||||
|  | 
 | ||||||
|  |   const [reviewFilter, setReviewFilter, reviewSearchParams] = | ||||||
|  |     useApiFilter<ReviewFilter>(); | ||||||
|  | 
 | ||||||
|  |   const onUpdateFilter = useCallback((newFilter: ReviewFilter) => { | ||||||
|  |       setSize(1); | ||||||
|  |       setReviewFilter(newFilter); | ||||||
|  |   }, []) | ||||||
|  | 
 | ||||||
|   // review paging
 |   // review paging
 | ||||||
| 
 | 
 | ||||||
|   const timeRange = useMemo(() => { |   const timeRange = useMemo(() => { | ||||||
| @ -26,29 +37,29 @@ export default function Events() { | |||||||
|     return axios.get(path, { params }).then((res) => res.data); |     return axios.get(path, { params }).then((res) => res.data); | ||||||
|   }, []); |   }, []); | ||||||
| 
 | 
 | ||||||
|   const reviewSearchParams = {}; |  | ||||||
|   const getKey = useCallback( |   const getKey = useCallback( | ||||||
|     (index: number, prevData: ReviewSegment[]) => { |     (index: number, prevData: ReviewSegment[]) => { | ||||||
|       if (index > 0) { |       if (index > 0) { | ||||||
|         const lastDate = prevData[prevData.length - 1].start_time; |         const lastDate = prevData[prevData.length - 1].start_time; | ||||||
|         const pagedParams = reviewSearchParams |         reviewSearchParams; | ||||||
|           ? { before: lastDate, after: timeRange.after, limit: API_LIMIT } |         const pagedParams = { | ||||||
|           : { |           cameras: reviewSearchParams["cameras"], | ||||||
|               ...reviewSearchParams, |           labels: reviewSearchParams["labels"], | ||||||
|  |           reviewed: reviewSearchParams["showReviewed"] || false, | ||||||
|           before: lastDate, |           before: lastDate, | ||||||
|               after: timeRange.after, |           after: reviewSearchParams["after"] || timeRange.after, | ||||||
|           limit: API_LIMIT, |           limit: API_LIMIT, | ||||||
|         }; |         }; | ||||||
|         return ["review", pagedParams]; |         return ["review", pagedParams]; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       const params = reviewSearchParams |       const params = { | ||||||
|         ? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after } |         cameras: reviewSearchParams["cameras"], | ||||||
|         : { |         labels: reviewSearchParams["labels"], | ||||||
|             ...reviewSearchParams, |         reviewed: reviewSearchParams["showReviewed"] || false, | ||||||
|         limit: API_LIMIT, |         limit: API_LIMIT, | ||||||
|             before: timeRange.before, |         before: reviewSearchParams["before"] || timeRange.before, | ||||||
|             after: timeRange.after, |         after: reviewSearchParams["after"] || timeRange.after, | ||||||
|       }; |       }; | ||||||
|       return ["review", params]; |       return ["review", params]; | ||||||
|     }, |     }, | ||||||
| @ -130,7 +141,7 @@ export default function Events() { | |||||||
| 
 | 
 | ||||||
|             return newData; |             return newData; | ||||||
|           }, |           }, | ||||||
|           { revalidate: false } |           { revalidate: false, populateCache: true } | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
| @ -197,10 +208,12 @@ export default function Events() { | |||||||
|         timeRange={timeRange} |         timeRange={timeRange} | ||||||
|         reachedEnd={isDone} |         reachedEnd={isDone} | ||||||
|         isValidating={isValidating} |         isValidating={isValidating} | ||||||
|  |         filter={reviewFilter} | ||||||
|         loadNextPage={() => setSize(size + 1)} |         loadNextPage={() => setSize(size + 1)} | ||||||
|         markItemAsReviewed={markItemAsReviewed} |         markItemAsReviewed={markItemAsReviewed} | ||||||
|         onSelectReview={setSelectedReviewId} |         onSelectReview={setSelectedReviewId} | ||||||
|         pullLatestData={updateSegments} |         pullLatestData={updateSegments} | ||||||
|  |         updateFilter={onUpdateFilter} | ||||||
|       /> |       /> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -19,3 +19,11 @@ export type ReviewData = { | |||||||
|   significant_motion_areas: number[]; |   significant_motion_areas: number[]; | ||||||
|   zones: string[]; |   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); |   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) { | export function isCurrentHour(timestamp: number) { | ||||||
|   const now = new Date(); |   const now = new Date(); | ||||||
|   now.setMinutes(0, 0, 0); |   now.setMinutes(0, 0, 0); | ||||||
|  | |||||||
| @ -1,20 +1,14 @@ | |||||||
| import { useFrigateEvents } from "@/api/ws"; | import { useFrigateEvents } from "@/api/ws"; | ||||||
|  | import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; | ||||||
| import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; | import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; | ||||||
| import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; | import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; | ||||||
| import ActivityIndicator from "@/components/ui/activity-indicator"; | import ActivityIndicator from "@/components/ui/activity-indicator"; | ||||||
| import { Button } from "@/components/ui/button"; | 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; | ||||||
| import { FrigateConfig } from "@/types/frigateConfig"; | import { FrigateConfig } from "@/types/frigateConfig"; | ||||||
| import { ReviewSegment, ReviewSeverity } from "@/types/review"; | import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; | ||||||
| import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; |  | ||||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 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 { MdCircle } from "react-icons/md"; | ||||||
| import useSWR from "swr"; | import useSWR from "swr"; | ||||||
| 
 | 
 | ||||||
| @ -24,10 +18,12 @@ type DesktopEventViewProps = { | |||||||
|   timeRange: { before: number; after: number }; |   timeRange: { before: number; after: number }; | ||||||
|   reachedEnd: boolean; |   reachedEnd: boolean; | ||||||
|   isValidating: boolean; |   isValidating: boolean; | ||||||
|  |   filter?: ReviewFilter; | ||||||
|   loadNextPage: () => void; |   loadNextPage: () => void; | ||||||
|   markItemAsReviewed: (reviewId: string) => void; |   markItemAsReviewed: (reviewId: string) => void; | ||||||
|   onSelectReview: (reviewId: string) => void; |   onSelectReview: (reviewId: string) => void; | ||||||
|   pullLatestData: () => void; |   pullLatestData: () => void; | ||||||
|  |   updateFilter: (filter: ReviewFilter) => void; | ||||||
| }; | }; | ||||||
| export default function DesktopEventView({ | export default function DesktopEventView({ | ||||||
|   reviewPages, |   reviewPages, | ||||||
| @ -35,10 +31,12 @@ export default function DesktopEventView({ | |||||||
|   timeRange, |   timeRange, | ||||||
|   reachedEnd, |   reachedEnd, | ||||||
|   isValidating, |   isValidating, | ||||||
|  |   filter, | ||||||
|   loadNextPage, |   loadNextPage, | ||||||
|   markItemAsReviewed, |   markItemAsReviewed, | ||||||
|   onSelectReview, |   onSelectReview, | ||||||
|   pullLatestData, |   pullLatestData, | ||||||
|  |   updateFilter, | ||||||
| }: DesktopEventViewProps) { | }: DesktopEventViewProps) { | ||||||
|   const { data: config } = useSWR<FrigateConfig>("config"); |   const { data: config } = useSWR<FrigateConfig>("config"); | ||||||
|   const [severity, setSeverity] = useState<ReviewSeverity>("alert"); |   const [severity, setSeverity] = useState<ReviewSeverity>("alert"); | ||||||
| @ -234,17 +232,7 @@ export default function DesktopEventView({ | |||||||
|             Motion |             Motion | ||||||
|           </ToggleGroupItem> |           </ToggleGroupItem> | ||||||
|         </ToggleGroup> |         </ToggleGroup> | ||||||
|         <div> |         <ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} /> | ||||||
|           <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> |  | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div className="flex h-full overflow-hidden"> |       <div className="flex h-full overflow-hidden"> | ||||||
| @ -334,29 +322,3 @@ export default function DesktopEventView({ | |||||||
|     </div> |     </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