diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 2f39921f5..52809d7b4 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -371,15 +371,15 @@ function CalendarFilterButton({ type GeneralFilterButtonProps = { allLabels: string[]; selectedLabels: string[] | undefined; - updateLabelFilter: (labels: string[] | undefined) => void; showReviewed?: 0 | 1; + updateLabelFilter: (labels: string[] | undefined) => void; setShowReviewed: (reviewed?: 0 | 1) => void; }; function GeneralFilterButton({ allLabels, selectedLabels, - updateLabelFilter, showReviewed, + updateLabelFilter, setShowReviewed, }: GeneralFilterButtonProps) { const [open, setOpen] = useState(false); @@ -395,6 +395,84 @@ function GeneralFilterButton({ ); const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setReviewed(showReviewed ?? 0); + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type GeneralFilterContentProps = { + allLabels: string[]; + selectedLabels: string[] | undefined; + currentLabels: string[] | undefined; + showReviewed?: 0 | 1; + reviewed: 0 | 1; + updateLabelFilter: (labels: string[] | undefined) => void; + setCurrentLabels: (labels: string[] | undefined) => void; + setShowReviewed: (reviewed?: 0 | 1) => void; + setReviewed: (reviewed: 0 | 1) => void; + onClose: () => void; +}; +export function GeneralFilterContent({ + allLabels, + selectedLabels, + currentLabels, + showReviewed, + reviewed, + updateLabelFilter, + setCurrentLabels, + setShowReviewed, + setReviewed, + onClose, +}: GeneralFilterContentProps) { + return ( <>
Apply @@ -478,44 +556,6 @@ function GeneralFilterButton({
); - - if (isMobile) { - return ( - { - if (!open) { - setReviewed(showReviewed ?? 0); - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setReviewed(showReviewed ?? 0); - setCurrentLabels(selectedLabels); - } - - setOpen(open); - }} - > - {trigger} - {content} - - ); } type ShowMotionOnlyButtonProps = { diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index b04d5db61..76127c353 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Dialog, DialogClose, @@ -55,6 +55,72 @@ export default function ExportDialog({ setRange, setMode, }: ExportDialogProps) { + const Overlay = isDesktop ? Dialog : Drawer; + const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; + const Content = isDesktop ? DialogContent : DrawerContent; + + return ( + { + if (!open) { + setMode("none"); + } + }} + > + + + + + setMode("none")} + /> + + + ); +} + +type ExportContentProps = { + camera: string; + latestTime: number; + currentTime: number; + range?: TimeRange; + mode: ExportMode; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; + onCancel: () => void; +}; +export function ExportContent({ + camera, + latestTime, + currentTime, + range, + mode, + setRange, + setMode, + onCancel, +}: ExportContentProps) { const [selectedOption, setSelectedOption] = useState("1"); const [name, setName] = useState(""); @@ -131,112 +197,90 @@ export default function ExportDialog({ }); }, [camera, name, range, setRange]); - const Overlay = isDesktop ? Dialog : Drawer; - const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; - const Content = isDesktop ? DialogContent : DrawerContent; + useEffect(() => { + if (mode != "timeline_save") { + return; + } + + onStartExport(); + setMode("none"); + }, [mode, onStartExport, setMode]); return ( - { - if (!open) { - setMode("none"); - } - }} - > - + <> + {isDesktop && ( + <> + + Export + + + + )} + onSelectTime(value as ExportOption)} + > + {EXPORT_OPTIONS.map((opt) => { + return ( +
+ + +
+ ); + })} +
+ {selectedOption == "custom" && ( + + )} + setName(e.target.value)} + /> + {isDesktop && } + +
+ Cancel +
-
- - {isDesktop && ( - <> - - Export - - - - )} - onSelectTime(value as ExportOption)} - > - {EXPORT_OPTIONS.map((opt) => { - return ( -
- - -
- ); - })} -
- {selectedOption == "custom" && ( - - )} - setName(e.target.value)} - /> - {isDesktop && } - - setMode("none")}>Cancel - - -
-
+ + ); } diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx new file mode 100644 index 000000000..6222f0e44 --- /dev/null +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -0,0 +1,231 @@ +import { useMemo, useState } from "react"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Button } from "../ui/button"; +import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; +import { TimeRange } from "@/types/timeline"; +import { ExportContent } from "./ExportDialog"; +import { ExportMode } from "@/types/filter"; +import ReviewActivityCalendar from "./ReviewActivityCalendar"; +import { SelectSeparator } from "../ui/select"; +import { ReviewFilter } from "@/types/review"; +import { getEndOfDayTimestamp } from "@/utils/dateUtil"; +import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; + +const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"]; +type DrawerMode = "none" | "select" | "export" | "calendar" | "filter"; + +type MobileReviewSettingsDrawerProps = { + camera: string; + filter?: ReviewFilter; + latestTime: number; + currentTime: number; + range?: TimeRange; + mode: ExportMode; + onUpdateFilter: (filter: ReviewFilter | undefined) => void; + setRange: (range: TimeRange | undefined) => void; + setMode: (mode: ExportMode) => void; +}; +export default function MobileReviewSettingsDrawer({ + camera, + filter, + latestTime, + currentTime, + range, + mode, + onUpdateFilter, + setRange, + setMode, +}: MobileReviewSettingsDrawerProps) { + const { data: config } = useSWR("config"); + const [drawerMode, setDrawerMode] = useState("none"); + + const allLabels = useMemo(() => { + if (!config) { + return []; + } + + const labels = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + const cameraConfig = config.cameras[camera]; + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + + if (cameraConfig.audio.enabled_in_config) { + cameraConfig.audio.listen.forEach((label) => { + labels.add(label); + }); + } + }); + + return [...labels].sort(); + }, [config, filter]); + const [currentLabels, setCurrentLabels] = useState( + filter?.labels, + ); + + let content; + if (drawerMode == "select") { + content = ( +
+ + + +
+ ); + } else if (drawerMode == "export") { + content = ( + { + setMode(mode); + + if (mode == "timeline") { + setDrawerMode("none"); + } + }} + onCancel={() => { + setMode("none"); + setRange(undefined); + setDrawerMode("select"); + }} + /> + ); + } else if (drawerMode == "calendar") { + content = ( +
+
+
setDrawerMode("select")} + > + Back +
+
+ Calendar +
+
+ { + onUpdateFilter({ + ...filter, + after: day == undefined ? undefined : day.getTime() / 1000, + before: day == undefined ? undefined : getEndOfDayTimestamp(day), + }); + }} + /> + +
+ +
+
+ ); + } else if (drawerMode == "filter") { + content = ( +
+
+
setDrawerMode("select")} + > + Back +
+
+ Filter +
+
+ + onUpdateFilter({ ...filter, labels: newLabels }) + } + setShowReviewed={() => {}} + setReviewed={() => {}} + onClose={() => setDrawerMode("select")} + /> +
+ ); + } + + return ( + { + if (!open) { + setDrawerMode("none"); + } + }} + > + + + + + {content} + + + ); +} + +/** + * + */ diff --git a/web/src/components/overlay/MobileTimelineDrawer.tsx b/web/src/components/overlay/MobileTimelineDrawer.tsx new file mode 100644 index 000000000..cd815f915 --- /dev/null +++ b/web/src/components/overlay/MobileTimelineDrawer.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Button } from "../ui/button"; +import { FaFlag } from "react-icons/fa"; +import { TimelineType } from "@/types/timeline"; + +type MobileTimelineDrawerProps = { + selected: TimelineType; + onSelect: (timeline: TimelineType) => void; +}; +export default function MobileTimelineDrawer({ + selected, + onSelect, +}: MobileTimelineDrawerProps) { + const [drawer, setDrawer] = useState(false); + + return ( + + + + + +
{ + onSelect("timeline"); + setDrawer(false); + }} + > + Timeline +
+
{ + onSelect("events"); + setDrawer(false); + }} + > + Events +
+
+
+ ); +} diff --git a/web/src/types/filter.ts b/web/src/types/filter.ts index 228aea98f..9cefd71df 100644 --- a/web/src/types/filter.ts +++ b/web/src/types/filter.ts @@ -2,4 +2,4 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type FilterType = { [searchKey: string]: any }; -export type ExportMode = "select" | "timeline" | "none"; +export type ExportMode = "select" | "timeline" | "timeline_save" | "none"; diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts index b5e746206..b4e02304c 100644 --- a/web/src/types/timeline.ts +++ b/web/src/types/timeline.ts @@ -24,3 +24,5 @@ export type Timeline = { }; export type TimeRange = { before: number; after: number }; + +export type TimelineType = "timeline" | "events"; diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index 3e945f235..b297c0639 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -33,11 +33,13 @@ import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; import useSWR from "swr"; -import { TimeRange } from "@/types/timeline"; +import { TimeRange, TimelineType } from "@/types/timeline"; import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer"; +import MobileTimelineDrawer from "@/components/overlay/MobileTimelineDrawer"; +import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSettingsDrawer"; +import Logo from "@/components/Logo"; const SEGMENT_DURATION = 30; -type TimelineType = "timeline" | "events"; type RecordingViewProps = { startCamera: string; @@ -218,7 +220,12 @@ export function RecordingView({ return (
-
+
+ {isMobile && ( + + )}
+ ) : ( + + )} + {isMobile && ( + )}
@@ -351,32 +380,6 @@ export function RecordingView({ )} - {isMobile && ( - - value ? setTimelineType(value) : null - } // don't allow the severity to be unselected - > - -
Timeline
-
- -
Events
-
-
- )}