From 9d7e499adbca20597179d5723c6d776bb7c9fe19 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 23 Jun 2024 14:58:00 -0600 Subject: [PATCH] Semantic Search Frontend (#12112) * Add basic search page * Abstract filters to separate components * Make searching functional * Add loading and no results indicators * Implement searching * Combine account and settings menus on mobile * Support using thumbnail for in progress detections * Fetch previews * Move recordings view and open recordings when search is selected * Implement detail pane * Implement saving of description * Implement similarity search * Fix clicking * Add date range picker * Fix * Fix iOS zoom bug * Mobile fixes * Use text area * Fix spacing for drawer * Fix fetching previews incorrectly --- web/src/App.tsx | 2 + web/src/components/card/AnimatedEventCard.tsx | 2 +- .../filter/CalendarFilterButton.tsx | 152 ++++++ .../components/filter/CamerasFilterButton.tsx | 177 +++++++ .../components/filter/ReviewFilterGroup.tsx | 254 +-------- .../components/filter/SearchFilterGroup.tsx | 458 ++++++++++++++++ web/src/components/indicators/Chip.tsx | 8 +- web/src/components/menu/GeneralSettings.tsx | 32 +- web/src/components/navigation/Bottombar.tsx | 2 - .../components/overlay/SearchDetailDialog.tsx | 167 ++++++ .../player/PreviewThumbnailPlayer.tsx | 496 +----------------- .../player/SearchThumbnailPlayer.tsx | 331 ++++++++++++ .../components/preview/ScrubbablePreview.tsx | 486 +++++++++++++++++ web/src/components/ui/calendar-range.tsx | 444 ++++++++++++++++ web/src/components/ui/textarea.tsx | 24 + web/src/hooks/use-date-utils.ts | 15 + web/src/hooks/use-navigation.ts | 28 +- web/src/pages/Events.tsx | 2 +- web/src/pages/Search.tsx | 201 +++++++ web/src/pages/SubmitPlus.tsx | 6 +- web/src/types/frigateConfig.ts | 4 + web/src/types/search.ts | 20 + .../{events => recording}/RecordingView.tsx | 0 web/src/views/search/SearchView.tsx | 126 +++++ 24 files changed, 2682 insertions(+), 755 deletions(-) create mode 100644 web/src/components/filter/CalendarFilterButton.tsx create mode 100644 web/src/components/filter/CamerasFilterButton.tsx create mode 100644 web/src/components/filter/SearchFilterGroup.tsx create mode 100644 web/src/components/overlay/SearchDetailDialog.tsx create mode 100644 web/src/components/player/SearchThumbnailPlayer.tsx create mode 100644 web/src/components/preview/ScrubbablePreview.tsx create mode 100644 web/src/components/ui/calendar-range.tsx create mode 100644 web/src/components/ui/textarea.tsx create mode 100644 web/src/pages/Search.tsx create mode 100644 web/src/types/search.ts rename web/src/views/{events => recording}/RecordingView.tsx (100%) create mode 100644 web/src/views/search/SearchView.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 93e980f2c..53491c6aa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import { isPWA } from "./utils/isPWA"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); +const Search = lazy(() => import("@/pages/Search")); const Exports = lazy(() => import("@/pages/Exports")); const SubmitPlus = lazy(() => import("@/pages/SubmitPlus")); const ConfigEditor = lazy(() => import("@/pages/ConfigEditor")); @@ -44,6 +45,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 85d6eb6d4..7a2eba4b1 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -7,10 +7,10 @@ import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import { RecordingStartingPoint } from "@/types/record"; import axios from "axios"; -import { VideoPreview } from "../player/PreviewThumbnailPlayer"; import { isCurrentHour } from "@/utils/dateUtil"; import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { baseUrl } from "@/api/baseUrl"; +import { VideoPreview } from "../preview/ScrubbablePreview"; import { useApiHost } from "@/api"; import { isDesktop, isSafari } from "react-device-detect"; import { usePersistence } from "@/hooks/use-persistence"; diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx new file mode 100644 index 000000000..9b630b893 --- /dev/null +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -0,0 +1,152 @@ +import { + useFormattedRange, + useFormattedTimestamp, +} from "@/hooks/use-date-utils"; +import { ReviewSummary } from "@/types/review"; +import { Button } from "../ui/button"; +import { FaCalendarAlt } from "react-icons/fa"; +import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; +import { DropdownMenuSeparator } from "../ui/dropdown-menu"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { isMobile } from "react-device-detect"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { DateRangePicker } from "../ui/calendar-range"; +import { DateRange } from "react-day-picker"; + +type CalendarFilterButtonProps = { + reviewSummary?: ReviewSummary; + day?: Date; + updateSelectedDay: (day?: Date) => void; +}; +export default function CalendarFilterButton({ + reviewSummary, + day, + updateSelectedDay, +}: CalendarFilterButtonProps) { + const selectedDate = useFormattedTimestamp( + day == undefined ? 0 : day?.getTime() / 1000 + 1, + "%b %-d", + ); + + const trigger = ( + + ); + const content = ( + <> + + +
+ +
+ + ); + + if (isMobile) { + return ( + + {trigger} + {content} + + ); + } + + return ( + + {trigger} + {content} + + ); +} + +type CalendarRangeFilterButtonProps = { + range?: DateRange; + defaultText: string; + updateSelectedRange: (range?: DateRange) => void; +}; +export function CalendarRangeFilterButton({ + range, + defaultText, + updateSelectedRange, +}: CalendarRangeFilterButtonProps) { + const selectedDate = useFormattedRange( + range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1, + range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1, + "%b %-d", + ); + + const trigger = ( + + ); + const content = ( + <> + updateSelectedRange(range.range)} + /> + +
+ +
+ + ); + + if (isMobile) { + return ( + + {trigger} + {content} + + ); + } + + return ( + + {trigger} + {content} + + ); +} diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx new file mode 100644 index 000000000..b1878bf12 --- /dev/null +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -0,0 +1,177 @@ +import { Button } from "../ui/button"; +import { CameraGroupConfig } from "@/types/frigateConfig"; +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { isMobile } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import FilterSwitch from "./FilterSwitch"; +import { FaVideo } from "react-icons/fa"; + +type CameraFilterButtonProps = { + allCameras: string[]; + groups: [string, CameraGroupConfig][]; + selectedCameras: string[] | undefined; + updateCameraFilter: (cameras: string[] | undefined) => void; +}; +export function CamerasFilterButton({ + allCameras, + groups, + selectedCameras, + updateCameraFilter, +}: CameraFilterButtonProps) { + const [open, setOpen] = useState(false); + const [currentCameras, setCurrentCameras] = useState( + selectedCameras, + ); + + const trigger = ( + + ); + const content = ( + <> + {isMobile && ( + <> + + Cameras + + + + )} +
+ { + if (isChecked) { + setCurrentCameras(undefined); + } + }} + /> + {groups.length > 0 && ( + <> + + {groups.map(([name, conf]) => { + return ( +
setCurrentCameras([...conf.cameras])} + > + {name} +
+ ); + })} + + )} + +
+ {allCameras.map((item) => ( + { + 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); + } + } + }} + /> + ))} +
+
+ +
+ + +
+ + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentCameras(selectedCameras); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index d01ea384f..d73e77464 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -1,36 +1,24 @@ import { Button } from "../ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import useSWR from "swr"; -import { CameraGroupConfig, FrigateConfig } from "@/types/frigateConfig"; +import { FrigateConfig } from "@/types/frigateConfig"; import { useCallback, useMemo, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; +import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; -import { useFormattedTimestamp } from "@/hooks/use-date-utils"; -import { - FaCalendarAlt, - FaCheckCircle, - FaFilter, - FaRunning, - FaVideo, -} from "react-icons/fa"; +import { FaCheckCircle, FaFilter, FaRunning } from "react-icons/fa"; import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; -import ReviewActivityCalendar from "../overlay/ReviewActivityCalendar"; import MobileReviewSettingsDrawer, { DrawerFeatures, } from "../overlay/MobileReviewSettingsDrawer"; import useOptimisticState from "@/hooks/use-optimistic-state"; import FilterSwitch from "./FilterSwitch"; import { FilterList } from "@/types/filter"; +import CalendarFilterButton from "./CalendarFilterButton"; +import { CamerasFilterButton } from "./CamerasFilterButton"; const REVIEW_FILTERS = [ "cameras", @@ -210,6 +198,7 @@ export default function ReviewFilterGroup({ ? undefined : new Date(filter.after * 1000) } + defaultText="Last 24 Hours" updateSelectedDay={onUpdateSelectedDay} /> )} @@ -260,169 +249,6 @@ export default function ReviewFilterGroup({ ); } -type CameraFilterButtonProps = { - allCameras: string[]; - groups: [string, CameraGroupConfig][]; - selectedCameras: string[] | undefined; - updateCameraFilter: (cameras: string[] | undefined) => void; -}; -export function CamerasFilterButton({ - allCameras, - groups, - selectedCameras, - updateCameraFilter, -}: CameraFilterButtonProps) { - const [open, setOpen] = useState(false); - const [currentCameras, setCurrentCameras] = useState( - selectedCameras, - ); - - const trigger = ( - - ); - const content = ( - <> - {isMobile && ( - <> - - Cameras - - - - )} -
- { - if (isChecked) { - setCurrentCameras(undefined); - } - }} - /> - {groups.length > 0 && ( - <> - - {groups.map(([name, conf]) => { - return ( -
setCurrentCameras([...conf.cameras])} - > - {name} -
- ); - })} - - )} - -
- {allCameras.map((item) => ( - { - 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); - } - } - }} - /> - ))} -
-
- -
- - -
- - ); - - if (isMobile) { - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - - setOpen(open); - }} - > - {trigger} - - {content} - - - ); - } - - return ( - { - if (!open) { - setCurrentCameras(selectedCameras); - } - - setOpen(open); - }} - > - {trigger} - {content} - - ); -} - type ShowReviewedFilterProps = { showReviewed: boolean; setShowReviewed: (reviewed: boolean) => void; @@ -466,74 +292,6 @@ function ShowReviewFilter({ ); } -type CalendarFilterButtonProps = { - reviewSummary?: ReviewSummary; - day?: Date; - updateSelectedDay: (day?: Date) => void; -}; -function CalendarFilterButton({ - reviewSummary, - day, - updateSelectedDay, -}: CalendarFilterButtonProps) { - const selectedDate = useFormattedTimestamp( - day == undefined ? 0 : day?.getTime() / 1000 + 1, - "%b %-d", - ); - - const trigger = ( - - ); - const content = ( - <> - - -
- -
- - ); - - if (isMobile) { - return ( - - {trigger} - {content} - - ); - } - - return ( - - {trigger} - {content} - - ); -} - type GeneralFilterButtonProps = { allLabels: string[]; selectedLabels: string[] | undefined; diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx new file mode 100644 index 000000000..e27a65a47 --- /dev/null +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -0,0 +1,458 @@ +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 { DropdownMenuSeparator } from "../ui/dropdown-menu"; +import { getEndOfDayTimestamp } from "@/utils/dateUtil"; +import { FaFilter } from "react-icons/fa"; +import { isDesktop, isMobile } from "react-device-detect"; +import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; +import { Switch } from "../ui/switch"; +import { Label } from "../ui/label"; +import MobileReviewSettingsDrawer, { + DrawerFeatures, +} from "../overlay/MobileReviewSettingsDrawer"; +import FilterSwitch from "./FilterSwitch"; +import { FilterList } from "@/types/filter"; +import { CalendarRangeFilterButton } from "./CalendarFilterButton"; +import { CamerasFilterButton } from "./CamerasFilterButton"; +import { SearchFilter } from "@/types/search"; +import { DateRange } from "react-day-picker"; + +const SEARCH_FILTERS = ["cameras", "date", "general"] as const; +type SearchFilters = (typeof SEARCH_FILTERS)[number]; +const DEFAULT_REVIEW_FILTERS: SearchFilters[] = ["cameras", "date", "general"]; + +type SearchFilterGroupProps = { + filters?: SearchFilters[]; + filter?: SearchFilter; + filterList?: FilterList; + onUpdateFilter: (filter: SearchFilter) => void; +}; + +export default function SearchFilterGroup({ + filters = DEFAULT_REVIEW_FILTERS, + filter, + filterList, + onUpdateFilter, +}: SearchFilterGroupProps) { + const { data: config } = useSWR("config"); + + const allLabels = useMemo(() => { + if (filterList?.labels) { + return filterList.labels; + } + + if (!config) { + return []; + } + + const labels = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + if (camera == "birdseye") { + return; + } + const cameraConfig = config.cameras[camera]; + cameraConfig.objects.track.forEach((label) => { + labels.add(label); + }); + + if (cameraConfig.audio.enabled_in_config) { + cameraConfig.audio.listen.forEach((label) => { + labels.add(label); + }); + } + }); + + return [...labels].sort(); + }, [config, filterList, filter]); + + const allZones = useMemo(() => { + if (filterList?.zones) { + return filterList.zones; + } + + if (!config) { + return []; + } + + const zones = new Set(); + const cameras = filter?.cameras || Object.keys(config.cameras); + + cameras.forEach((camera) => { + if (camera == "birdseye") { + return; + } + const cameraConfig = config.cameras[camera]; + cameraConfig.review.alerts.required_zones.forEach((zone) => { + zones.add(zone); + }); + cameraConfig.review.detections.required_zones.forEach((zone) => { + zones.add(zone); + }); + }); + + return [...zones].sort(); + }, [config, filterList, filter]); + + const filterValues = useMemo( + () => ({ + cameras: Object.keys(config?.cameras || {}), + labels: Object.values(allLabels || {}), + zones: Object.values(allZones || {}), + }), + [config, allLabels, allZones], + ); + + const groups = useMemo(() => { + if (!config) { + return []; + } + + return Object.entries(config.camera_groups).sort( + (a, b) => a[1].order - b[1].order, + ); + }, [config]); + + const mobileSettingsFeatures = useMemo(() => { + const features: DrawerFeatures[] = []; + + if (filters.includes("date")) { + features.push("calendar"); + } + + if (filters.includes("general")) { + features.push("filter"); + } + + return features; + }, [filters]); + + // handle updating filters + + const onUpdateSelectedRange = useCallback( + (range?: DateRange) => { + onUpdateFilter({ + ...filter, + after: + range?.from == undefined ? undefined : range.from.getTime() / 1000, + before: + range?.to == undefined ? undefined : getEndOfDayTimestamp(range.to), + }); + }, + [filter, onUpdateFilter], + ); + + return ( +
+ {filters.includes("cameras") && ( + { + onUpdateFilter({ ...filter, cameras: newCameras }); + }} + /> + )} + {isDesktop && filters.includes("date") && ( + + )} + {isDesktop && filters.includes("general") && ( + { + onUpdateFilter({ ...filter, labels: newLabels }); + }} + updateZoneFilter={(newZones) => + onUpdateFilter({ ...filter, zones: newZones }) + } + /> + )} + {isMobile && mobileSettingsFeatures.length > 0 && ( + {}} + setRange={() => {}} + /> + )} +
+ ); +} + +type GeneralFilterButtonProps = { + allLabels: string[]; + selectedLabels: string[] | undefined; + allZones: string[]; + selectedZones?: string[]; + updateLabelFilter: (labels: string[] | undefined) => void; + updateZoneFilter: (zones: string[] | undefined) => void; +}; +function GeneralFilterButton({ + allLabels, + selectedLabels, + allZones, + selectedZones, + updateLabelFilter, + updateZoneFilter, +}: GeneralFilterButtonProps) { + const [open, setOpen] = useState(false); + const [currentLabels, setCurrentLabels] = useState( + selectedLabels, + ); + const [currentZones, setCurrentZones] = useState( + selectedZones, + ); + + const trigger = ( + + ); + const content = ( + setOpen(false)} + /> + ); + + if (isMobile) { + return ( + { + if (!open) { + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + + {content} + + + ); + } + + return ( + { + if (!open) { + setCurrentLabels(selectedLabels); + } + + setOpen(open); + }} + > + {trigger} + {content} + + ); +} + +type GeneralFilterContentProps = { + allLabels: string[]; + selectedLabels: string[] | undefined; + currentLabels: string[] | undefined; + allZones?: string[]; + selectedZones?: string[]; + currentZones?: string[]; + updateLabelFilter: (labels: string[] | undefined) => void; + setCurrentLabels: (labels: string[] | undefined) => void; + updateZoneFilter?: (zones: string[] | undefined) => void; + setCurrentZones?: (zones: string[] | undefined) => void; + onClose: () => void; +}; +export function GeneralFilterContent({ + allLabels, + selectedLabels, + currentLabels, + allZones, + selectedZones, + currentZones, + updateLabelFilter, + setCurrentLabels, + updateZoneFilter, + setCurrentZones, + onClose, +}: GeneralFilterContentProps) { + return ( + <> +
+
+ + { + if (isChecked) { + setCurrentLabels(undefined); + } + }} + /> +
+
+ {allLabels.map((item) => ( + { + 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); + } + } + }} + /> + ))} +
+ + {allZones && setCurrentZones && ( + <> + +
+ + { + if (isChecked) { + setCurrentZones(undefined); + } + }} + /> +
+
+ {allZones.map((item) => ( + { + if (isChecked) { + const updatedZones = currentZones + ? [...currentZones] + : []; + + updatedZones.push(item); + setCurrentZones(updatedZones); + } else { + const updatedZones = currentZones + ? [...currentZones] + : []; + + // can not deselect the last item + if (updatedZones.length > 1) { + updatedZones.splice(updatedZones.indexOf(item), 1); + setCurrentZones(updatedZones); + } + } + }} + /> + ))} +
+ + )} +
+ +
+ + +
+ + ); +} diff --git a/web/src/components/indicators/Chip.tsx b/web/src/components/indicators/Chip.tsx index e484f2f5a..8c8d373c8 100644 --- a/web/src/components/indicators/Chip.tsx +++ b/web/src/components/indicators/Chip.tsx @@ -39,7 +39,13 @@ export default function Chip({ className, !isIOS && "z-10", )} - onClick={onClick} + onClick={(e) => { + e.stopPropagation(); + + if (onClick) { + onClick(); + } + }} > {children} diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 34637d57e..c1c46d87b 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -3,6 +3,7 @@ import { LuGithub, LuLifeBuoy, LuList, + LuLogOut, LuMoon, LuPenSquare, LuRotateCw, @@ -56,7 +57,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import ActivityIndicator from "../indicators/activity-indicator"; -import { isDesktop } from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Dialog, @@ -68,11 +69,18 @@ import { import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; import { baseUrl } from "@/api/baseUrl"; +import useSWR from "swr"; type GeneralSettingsProps = { className?: string; }; export default function GeneralSettings({ className }: GeneralSettingsProps) { + const { data: profile } = useSWR("profile"); + const { data: config } = useSWR("config"); + const logoutUrl = config?.proxy?.logout_url || "/api/logout"; + + // settings + const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [restartingSheetOpen, setRestartingSheetOpen] = useState(false); @@ -154,6 +162,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { } >
+ {isMobile && ( + <> + + Current User: {profile?.username || "anonymous"} + + + + + + Logout + + + + )} System diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index b6432bd6c..d82f69c60 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -7,7 +7,6 @@ import { useFrigateStats } from "@/api/ws"; import { useContext, useEffect, useMemo } from "react"; import useStats from "@/hooks/use-stats"; import GeneralSettings from "../menu/GeneralSettings"; -import AccountSettings from "../menu/AccountSettings"; import useNavigation from "@/hooks/use-navigation"; import { StatusBarMessagesContext, @@ -35,7 +34,6 @@ function Bottombar() { ))} -
); diff --git a/web/src/components/overlay/SearchDetailDialog.tsx b/web/src/components/overlay/SearchDetailDialog.tsx new file mode 100644 index 000000000..23580bdbf --- /dev/null +++ b/web/src/components/overlay/SearchDetailDialog.tsx @@ -0,0 +1,167 @@ +import { isDesktop, isIOS } from "react-device-detect"; +import { Sheet, SheetContent } from "../ui/sheet"; +import { Drawer, DrawerContent } from "../ui/drawer"; +import { SearchResult } from "@/types/search"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; +import { getIconForLabel } from "@/utils/iconUtil"; +import { useApiHost } from "@/api"; +import { Button } from "../ui/button"; +import { useCallback, useEffect, useState } from "react"; +import axios from "axios"; +import { toast } from "sonner"; +import { Textarea } from "../ui/textarea"; + +type SearchDetailDialogProps = { + search?: SearchResult; + setSearch: (search: SearchResult | undefined) => void; + setSimilarity?: () => void; +}; +export default function SearchDetailDialog({ + search, + setSearch, + setSimilarity, +}: SearchDetailDialogProps) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const apiHost = useApiHost(); + + // data + + const [desc, setDesc] = useState(search?.description); + + // we have to make sure the current selected search item stays in sync + useEffect(() => setDesc(search?.description), [search]); + + const formattedDate = useFormattedTimestamp( + search?.start_time ?? 0, + config?.ui.time_format == "24hour" + ? "%b %-d %Y, %H:%M" + : "%b %-d %Y, %I:%M %p", + ); + + // api + + const updateDescription = useCallback(() => { + if (!search) { + return; + } + + axios + .post(`events/${search.id}/description`, { description: desc }) + .then((resp) => { + if (resp.status == 200) { + toast.success("Successfully saved description", { + position: "top-center", + }); + } + }) + .catch(() => { + toast.error("Failed to update the description", { + position: "top-center", + }); + setDesc(search.description); + }); + }, [desc, search]); + + // content + + const Overlay = isDesktop ? Sheet : Drawer; + const Content = isDesktop ? SheetContent : DrawerContent; + + return ( + { + if (!open) { + setSearch(undefined); + } + }} + > + + {search && ( +
+
+
+
+
Label
+
+ {getIconForLabel(search.label, "size-4 text-white")} + {search.label} +
+
+
+
Score
+
+ {Math.round(search.score * 100)}% +
+
+
+
Camera
+
+ {search.camera.replaceAll("_", " ")} +
+
+
+
Timestamp
+
{formattedDate}
+
+
+
+ + +
+
+
+
Description
+