From d84e3cacca962afbf59184ca341c055fe6775499 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 12 Sep 2024 08:46:29 -0600 Subject: [PATCH 1/5] UI Improvements and Tweaks (#13689) * Improve image loading by not loading when off screen * Add share menu to export * Add share button and tidy up review detail lists * Fix missing key * Use query args for review filter * Add object lifecycle to explore dialog * Adjust sizing * Simplify share button * Always show snapshot but hide buttons for frigate+ if not applicable * Handle case when user switches to element missing the previously selected tab * Handle cases where share is not available * Fix logic --- web/src/components/camera/CameraImage.tsx | 1 + web/src/components/card/ExportCard.tsx | 16 +++++- .../components/filter/ReviewFilterGroup.tsx | 2 + .../overlay/detail/ObjectLifecycle.tsx | 54 +++++++++++-------- .../overlay/detail/ReviewDetailDialog.tsx | 29 ++++++---- .../overlay/detail/SearchDetailDialog.tsx | 50 +++++++++++++---- .../overlay/dialog/FrigatePlusDialog.tsx | 2 +- web/src/components/player/PreviewPlayer.tsx | 1 + web/src/hooks/use-api-filter.ts | 4 +- web/src/hooks/use-overlay-state.tsx | 32 +++++------ web/src/pages/Events.tsx | 10 +++- web/src/pages/Explore.tsx | 1 + web/src/pages/Exports.tsx | 20 +++++-- web/src/pages/Live.tsx | 4 ++ web/src/utils/browserUtil.ts | 16 ++++++ 15 files changed, 172 insertions(+), 70 deletions(-) create mode 100644 web/src/utils/browserUtil.ts diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index 9f25f69a3..08f3c9126 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -93,6 +93,7 @@ export default function CameraImage({ "rounded-lg md:rounded-2xl", )} onLoad={handleImageLoad} + loading="lazy" /> ) : (
diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 1ad98be0d..045740a97 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -3,7 +3,7 @@ import { LuTrash } from "react-icons/lu"; import { Button } from "../ui/button"; import { useCallback, useState } from "react"; import { isDesktop } from "react-device-detect"; -import { FaDownload, FaPlay } from "react-icons/fa"; +import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa"; import Chip from "../indicators/Chip"; import { Skeleton } from "../ui/skeleton"; import { @@ -19,6 +19,7 @@ import { DeleteClipType, Export } from "@/types/export"; import { MdEditSquare } from "react-icons/md"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; +import { shareOrCopy } from "@/utils/browserUtil"; type ExportProps = { className: string; @@ -147,6 +148,19 @@ export default function ExportCard({
+ {!exportedRecording.in_progress && ( + + shareOrCopy( + `${baseUrl}exports?id=${exportedRecording.id}`, + exportedRecording.name.replaceAll("_", " "), + ) + } + > + + + )} {!exportedRecording.in_progress && ( {allLabels.map((item) => ( { @@ -516,6 +517,7 @@ export function GeneralFilterContent({
{allZones.map((item) => ( { diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 3cb7945ce..ca8851b92 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -13,7 +13,7 @@ import { import { Button } from "@/components/ui/button"; import { ObjectLifecycleSequence } from "@/types/timeline"; import Heading from "@/components/ui/heading"; -import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; +import { ReviewDetailPaneType } from "@/types/review"; import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { getIconForLabel } from "@/utils/iconUtil"; @@ -47,14 +47,16 @@ import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { TooltipPortal } from "@radix-ui/react-tooltip"; type ObjectLifecycleProps = { - review: ReviewSegment; + className?: string; event: Event; + fullscreen?: boolean; setPane: React.Dispatch>; }; export default function ObjectLifecycle({ - review, + className, event, + fullscreen = false, setPane, }: ObjectLifecycleProps) { const { data: eventSequence } = useSWR([ @@ -78,13 +80,13 @@ export default function ObjectLifecycle({ const getZoneColor = useCallback( (zoneName: string) => { const zoneColor = - config?.cameras?.[review.camera]?.zones?.[zoneName]?.color; + config?.cameras?.[event.camera]?.zones?.[zoneName]?.color; if (zoneColor) { const reversed = [...zoneColor].reverse(); return reversed; } }, - [config, review], + [config, event], ); const getZonePolygon = useCallback( @@ -93,7 +95,7 @@ export default function ObjectLifecycle({ return; } const zonePoints = - config?.cameras[review.camera].zones[zoneName].coordinates; + config?.cameras[event.camera].zones[zoneName].coordinates; const imgElement = imgRef.current; const imgRect = imgElement.getBoundingClientRect(); @@ -110,7 +112,7 @@ export default function ObjectLifecycle({ }, [] as number[]) .join(","); }, - [config, imgRef, review], + [config, imgRef, event], ); const [boxStyle, setBoxStyle] = useState(null); @@ -224,17 +226,19 @@ export default function ObjectLifecycle({ } return ( - <> -
- -
+
+ {!fullscreen && ( +
+ +
+ )}
- + {eventSequence.map((item, index) => ( @@ -455,7 +462,7 @@ export default function ObjectLifecycle({
-
+
( handleThumbnailClick(index)} >
@@ -513,7 +523,7 @@ export default function ObjectLifecycle({
- +
); } diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 25dd79711..0bfae33bc 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -25,7 +25,7 @@ import { cn } from "@/lib/utils"; import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; import ObjectLifecycle from "./ObjectLifecycle"; import Chip from "@/components/indicators/Chip"; -import { FaDownload, FaImages } from "react-icons/fa"; +import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa"; import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; import { FaArrowsRotate } from "react-icons/fa6"; import { @@ -34,6 +34,9 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { baseUrl } from "@/api/baseUrl"; +import { shareOrCopy } from "@/utils/browserUtil"; type ReviewDetailDialogProps = { review?: ReviewSegment; @@ -136,11 +139,21 @@ export default function ReviewDetailDialog({
Timestamp
{formattedDate}
+
-
-
+
+
Objects
-
+
{events?.map((event) => { return (
{review.data.zones.length > 0 && ( -
+
Zones
{review.data.zones.map((zone) => { @@ -199,11 +212,7 @@ export default function ReviewDetailDialog({ {pane == "details" && selectedEvent && (
- +
)} diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index a67ec3b4a..02c099b29 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -34,10 +34,16 @@ import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; -import { FaRegListAlt, FaVideo } from "react-icons/fa"; -import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; +import { FaImage, FaRegListAlt, FaVideo } from "react-icons/fa"; +import { FaRotate } from "react-icons/fa6"; +import ObjectLifecycle from "./ObjectLifecycle"; -const SEARCH_TABS = ["details", "frigate+", "video"] as const; +const SEARCH_TABS = [ + "details", + "snapshot", + "video", + "object lifecycle", +] as const; type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { @@ -66,8 +72,8 @@ export default function SearchDetailDialog({ const views = [...SEARCH_TABS]; - if (!config.plus.enabled || !search.has_snapshot) { - const index = views.indexOf("frigate+"); + if (!search.has_snapshot) { + const index = views.indexOf("snapshot"); views.splice(index, 1); } @@ -80,6 +86,16 @@ export default function SearchDetailDialog({ return views; }, [config, search]); + useEffect(() => { + if (searchTabs.length == 0) { + return; + } + + if (!searchTabs.includes(pageToggle)) { + setPage("details"); + } + }, [pageToggle, searchTabs]); + if (!search) { return; } @@ -104,7 +120,7 @@ export default function SearchDetailDialog({ @@ -136,8 +152,11 @@ export default function SearchDetailDialog({ aria-label={`Select ${item}`} > {item == "details" && } - {item == "frigate+" && } + {item == "snapshot" && } {item == "video" && } + {item == "object lifecycle" && ( + + )}
{item}
))} @@ -153,9 +172,14 @@ export default function SearchDetailDialog({ setSimilarity={setSimilarity} /> )} - {page == "frigate+" && ( + {page == "snapshot" && ( {}} onEventUploaded={() => { @@ -164,6 +188,14 @@ export default function SearchDetailDialog({ /> )} {page == "video" && } + {page == "object lifecycle" && ( + {}} + /> + )}
); diff --git a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx index e96b53d63..0c4801f8e 100644 --- a/web/src/components/overlay/dialog/FrigatePlusDialog.tsx +++ b/web/src/components/overlay/dialog/FrigatePlusDialog.tsx @@ -79,7 +79,7 @@ export function FrigatePlusDialog({ const content = ( - + Submit To Frigate+ Objects in locations you want to avoid are not false positives. diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 6ff5e1590..b233c6ad4 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -539,6 +539,7 @@ function PreviewFramesPlayer({ {previewFrames?.length === 0 && ( diff --git a/web/src/hooks/use-api-filter.ts b/web/src/hooks/use-api-filter.ts index 79269baee..185d0df9a 100644 --- a/web/src/hooks/use-api-filter.ts +++ b/web/src/hooks/use-api-filter.ts @@ -65,11 +65,11 @@ export function useApiFilterArgs< const filter: { [key: string]: unknown } = {}; rawParams.forEach((value, key) => { - if (isNaN(parseFloat(value))) { + if (value != "true" && value != "false" && isNaN(parseFloat(value))) { filter[key] = value.includes(",") ? value.split(",") : [value]; } else { if (value != undefined) { - filter[key] = `${value}`; + filter[key] = JSON.parse(value); } } }); diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index 0db339c9b..e68476682 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { usePersistence } from "./use-persistence"; export function useOverlayState( @@ -103,33 +103,29 @@ export function useHashState(): [ export function useSearchEffect( key: string, - callback: (value: string) => void, + callback: (value: string) => boolean, ) { - const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const param = useMemo(() => { - if (!location || !location.search || location.search.length == 0) { + const param = searchParams.get(key); + + if (!param) { return undefined; } - const params = location.search.substring(1).split("&"); - - const foundParam = params - .find((p) => p.includes("=") && p.split("=")[0] == key) - ?.split("="); - - if (foundParam && foundParam.length === 2) { - return [foundParam[0], decodeURIComponent(foundParam[1])]; - } - - return undefined; - }, [location, key]); + return [key, decodeURIComponent(param)]; + }, [searchParams, key]); useEffect(() => { if (!param) { return; } - callback(param[1]); - }, [param, callback]); + const remove = callback(param[1]); + + if (remove) { + setSearchParams(); + } + }, [param, callback, setSearchParams]); } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 68e5c9adc..cbdd246f3 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; -import useApiFilter from "@/hooks/use-api-filter"; +import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useTimezone } from "@/hooks/use-date-utils"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; @@ -54,6 +54,8 @@ export default function Events() { } }) .catch(() => {}); + + return true; }); const [startTime, setStartTime] = useState(); @@ -69,7 +71,7 @@ export default function Events() { // review filter const [reviewFilter, setReviewFilter, reviewSearchParams] = - useApiFilter(); + useApiFilterArgs(); useSearchEffect("group", (reviewGroup) => { if (config && reviewGroup && reviewGroup != "default") { @@ -83,7 +85,11 @@ export default function Events() { cameras: group.cameras, }); } + + return true; } + + return false; }); const onUpdateFilter = useCallback( diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 750c75fde..f68bb5068 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -43,6 +43,7 @@ export default function Explore() { setSearch(`similarity:${similarityId}`); // @ts-expect-error we want to clear this setSearchFilter({ ...searchFilter, similarity_search_id: undefined }); + return false; }); useEffect(() => { diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 451d52052..a4659551b 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; +import { useSearchEffect } from "@/hooks/use-overlay-state"; import { cn } from "@/lib/utils"; import { DeleteClipType, Export } from "@/types/export"; import axios from "axios"; @@ -46,6 +47,20 @@ function Exports() { ); }, [exports, search]); + // Viewing + + const [selected, setSelected] = useState(); + const [selectedAspect, setSelectedAspect] = useState(0.0); + + useSearchEffect("id", (id) => { + if (!exports) { + return false; + } + + setSelected(exports.find((exp) => exp.id == id)); + return true; + }); + // Deleting const [deleteClip, setDeleteClip] = useState(); @@ -91,11 +106,6 @@ function Exports() { [mutate], ); - // Viewing - - const [selected, setSelected] = useState(); - const [selectedAspect, setSelectedAspect] = useState(0.0); - return (
diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index c088a5b04..9852852a7 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -29,7 +29,11 @@ function Live() { if (group) { setCameraGroup(cameraGroup); } + + return true; } + + return false; }); // fullscreen diff --git a/web/src/utils/browserUtil.ts b/web/src/utils/browserUtil.ts new file mode 100644 index 000000000..78f740649 --- /dev/null +++ b/web/src/utils/browserUtil.ts @@ -0,0 +1,16 @@ +import copy from "copy-to-clipboard"; +import { toast } from "sonner"; + +export function shareOrCopy(url: string, title?: string) { + if (window.isSecureContext && "share" in navigator) { + navigator.share({ + url: url, + title: title, + }); + } else { + copy(url); + toast.success("Copied to clipboard.", { + position: "top-center", + }); + } +} From 87ab4e7c9b6d251f02de418c3dde6586b4209605 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 12 Sep 2024 13:28:15 -0600 Subject: [PATCH 2/5] UI Fixes (#13703) * Respect ui configured timezone * Non alert videos are always 16:9 --- web/src/components/card/AnimatedEventCard.tsx | 8 ++++++-- web/src/components/card/ReviewCard.tsx | 1 + web/src/components/card/SearchThumbnail.tsx | 1 + web/src/components/overlay/detail/ReviewDetailDialog.tsx | 1 + web/src/components/overlay/detail/SearchDetailDialog.tsx | 1 + web/src/components/player/PreviewThumbnailPlayer.tsx | 1 + web/src/hooks/use-date-utils.ts | 9 +++++++-- 7 files changed, 18 insertions(+), 4 deletions(-) diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 1228ebd05..8ee4acdcf 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -91,13 +91,17 @@ export function AnimatedEventCard({ const [alertVideos] = usePersistence("alertVideos", true); const aspectRatio = useMemo(() => { - if (!config || !Object.keys(config.cameras).includes(event.camera)) { + if ( + !config || + !alertVideos || + !Object.keys(config.cameras).includes(event.camera) + ) { return 16 / 9; } const detect = config.cameras[event.camera].detect; return detect.width / detect.height; - }, [config, event]); + }, [alertVideos, config, event]); return ( diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index 359dd6536..a28b89783 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -50,6 +50,7 @@ export default function ReviewCard({ const formattedDate = useFormattedTimestamp( event.start_time, config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", + config?.ui.timezone, ); const isSelected = useMemo( () => diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index 3f9a4a6a5..385d5ebd4 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -42,6 +42,7 @@ export default function SearchThumbnail({ const formattedDate = useFormattedTimestamp( searchResult.start_time, config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", + config?.ui.timezone, ); return ( diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 0bfae33bc..87d84d5c9 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -73,6 +73,7 @@ export default function ReviewDetailDialog({ config?.ui.time_format == "24hour" ? "%b %-d %Y, %H:%M" : "%b %-d %Y, %I:%M %p", + config?.ui.timezone, ); // content diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 02c099b29..41dfbd332 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -227,6 +227,7 @@ function ObjectDetailsTab({ config?.ui.time_format == "24hour" ? "%b %-d %Y, %H:%M" : "%b %-d %Y, %I:%M %p", + config?.ui.timezone, ); const score = useMemo(() => { diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index d20ad1ec7..69a6ac6df 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -168,6 +168,7 @@ export default function PreviewThumbnailPlayer({ const formattedDate = useFormattedTimestamp( review.start_time, config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", + config?.ui?.timezone, ); return ( diff --git a/web/src/hooks/use-date-utils.ts b/web/src/hooks/use-date-utils.ts index c234cadd4..00f523920 100644 --- a/web/src/hooks/use-date-utils.ts +++ b/web/src/hooks/use-date-utils.ts @@ -2,12 +2,17 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useMemo } from "react"; -export function useFormattedTimestamp(timestamp: number, format: string) { +export function useFormattedTimestamp( + timestamp: number, + format: string, + timezone?: string, +) { const formattedTimestamp = useMemo(() => { return formatUnixTimestampToDateTime(timestamp, { + timezone, strftime_fmt: format, }); - }, [format, timestamp]); + }, [format, timestamp, timezone]); return formattedTimestamp; } From 644ea7be4ad495ab5e073c6c3f027f6fe66d4e33 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:39:35 -0500 Subject: [PATCH 3/5] UI tweaks (#13705) * mobile page component * object lifecycle pane tweaks * use mobile page component for review and search detail * fix frigate+ dialog when using mobile page component * small tweaks --- web/package-lock.json | 26 ++++ web/package.json | 1 + web/src/components/mobile/MobilePage.tsx | 121 +++++++++++++++++ .../overlay/detail/ObjectLifecycle.tsx | 14 +- .../overlay/detail/ReviewDetailDialog.tsx | 92 ++++++++----- .../overlay/detail/SearchDetailDialog.tsx | 53 ++++---- .../overlay/dialog/FrigatePlusDialog.tsx | 124 ++++++++++-------- web/src/utils/browserUtil.ts | 2 +- web/src/views/explore/ExploreView.tsx | 2 +- web/src/views/search/SearchView.tsx | 2 +- 10 files changed, 320 insertions(+), 117 deletions(-) create mode 100644 web/src/components/mobile/MobilePage.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 15bd003f3..de42a1ac7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,6 +37,7 @@ "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "embla-carousel-react": "^8.2.0", + "framer-motion": "^11.5.4", "hls.js": "^1.5.14", "idb-keyval": "^6.2.1", "immer": "^10.1.1", @@ -4717,6 +4718,31 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "11.5.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.5.4.tgz", + "integrity": "sha512-E+tb3/G6SO69POkdJT+3EpdMuhmtCh9EWuK4I1DnIC23L7tFPrl8vxP+LSovwaw6uUr73rUbpb4FgK011wbRJQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", diff --git a/web/package.json b/web/package.json index 148d74919..bb15ea4b8 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "embla-carousel-react": "^8.2.0", + "framer-motion": "^11.5.4", "hls.js": "^1.5.14", "idb-keyval": "^6.2.1", "immer": "^10.1.1", diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx new file mode 100644 index 000000000..8dbcc2f61 --- /dev/null +++ b/web/src/components/mobile/MobilePage.tsx @@ -0,0 +1,121 @@ +import { cn } from "@/lib/utils"; +import { isPWA } from "@/utils/isPWA"; +import { ReactNode, useEffect, useState } from "react"; +import { Button } from "../ui/button"; +import { IoMdArrowRoundBack } from "react-icons/io"; +import { motion, AnimatePresence } from "framer-motion"; + +type MobilePageProps = { + children: ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function MobilePage({ children, open, onOpenChange }: MobilePageProps) { + const [isVisible, setIsVisible] = useState(open); + + useEffect(() => { + if (open) { + setIsVisible(true); + } + }, [open]); + + const handleAnimationComplete = () => { + if (!open) { + setIsVisible(false); + onOpenChange(false); + } + }; + + return ( + + {isVisible && ( + + {children} + + )} + + ); +} + +type MobileComponentProps = { + children: ReactNode; + className?: string; +}; + +export function MobilePageContent({ + children, + className, + ...props +}: MobileComponentProps) { + return ( +
+ {children} +
+ ); +} + +export function MobilePageDescription({ + children, + className, + ...props +}: MobileComponentProps) { + return ( +

+ {children} +

+ ); +} + +interface MobilePageHeaderProps extends React.HTMLAttributes { + onClose: () => void; +} + +export function MobilePageHeader({ + children, + className, + onClose, + ...props +}: MobilePageHeaderProps) { + return ( +
+ +
{children}
+
+ ); +} + +export function MobilePageTitle({ + children, + className, + ...props +}: MobileComponentProps) { + return ( +

+ {children} +

+ ); +} diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index ca8851b92..377710b09 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -230,7 +230,7 @@ export default function ObjectLifecycle({ {!fullscreen && (
)} -
+
)} -
+
(
(); const [pane, setPane] = useState("overview"); - const Overlay = isDesktop ? Sheet : Drawer; - const Content = isDesktop ? SheetContent : DrawerContent; - const Header = isDesktop ? SheetHeader : DrawerHeader; - const Title = isDesktop ? SheetTitle : DrawerTitle; - const Description = isDesktop ? SheetDescription : DrawerDescription; + // dialog and mobile page + + const [isOpen, setIsOpen] = useState(review != undefined); + + useEffect(() => { + setIsOpen(review != undefined); + }, [review]); + + const Overlay = isDesktop ? Sheet : MobilePage; + const Content = isDesktop ? SheetContent : MobilePageContent; + const Header = isDesktop ? SheetHeader : MobilePageHeader; + const Title = isDesktop ? SheetTitle : MobilePageTitle; + const Description = isDesktop ? SheetDescription : MobilePageDescription; if (!review) { return; @@ -94,7 +102,7 @@ export default function ReviewDetailDialog({ return ( <> { if (!open) { setReview(undefined); @@ -115,19 +123,43 @@ export default function ReviewDetailDialog({ -
- Review Item Details - Review item details -
+
{pane == "overview" && ( -
+
setIsOpen(false)}> + Review Item Details + Review item details +
+ + + + + Share this review item + +
+
+ )} + {pane == "overview" && ( +
@@ -140,21 +172,11 @@ export default function ReviewDetailDialog({
Timestamp
{formattedDate}
-
Objects
-
+
{events?.map((event) => { return (
("details"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + // dialog and mobile page + + const [isOpen, setIsOpen] = useState(search != undefined); + + useEffect(() => { + setIsOpen(search != undefined); + }, [search]); + const searchTabs = useMemo(() => { if (!config || !search) { return []; @@ -102,15 +110,15 @@ export default function SearchDetailDialog({ // content - const Overlay = isDesktop ? Dialog : Drawer; - const Content = isDesktop ? DialogContent : DrawerContent; - const Header = isDesktop ? DialogHeader : DrawerHeader; - const Title = isDesktop ? DialogTitle : DrawerTitle; - const Description = isDesktop ? DialogDescription : DrawerDescription; + const Overlay = isDesktop ? Dialog : MobilePage; + const Content = isDesktop ? DialogContent : MobilePageContent; + const Header = isDesktop ? DialogHeader : MobilePageHeader; + const Title = isDesktop ? DialogTitle : MobilePageTitle; + const Description = isDesktop ? DialogDescription : MobilePageDescription; return ( { if (!open) { setSearch(undefined); @@ -118,15 +126,16 @@ export default function SearchDetailDialog({ }} > -
+
setIsOpen(false)}> Tracked Object Details - Tracked object details + Tracked object details
+
@@ -303,7 +312,7 @@ function ObjectDetailsTab({
{formattedDate}
-
+
{ if (!config || !upload) { return ""; @@ -79,60 +83,74 @@ export function FrigatePlusDialog({ const content = ( - - Submit To Frigate+ - - Objects in locations you want to avoid are not false positives. - Submitting them as false positives will confuse the model. - - - - {upload?.id && ( - {`${upload?.label}`} - )} - +
+ + + Submit To Frigate+ + + + Objects in locations you want to avoid are not false positives. + Submitting them as false positives will confuse the model. + + + + {upload?.id && ( + {`${upload?.label}`} + )} + - - {state == "reviewing" && ( - <> - {dialog && } - - - - )} - {state == "uploading" && } - + + {state == "reviewing" && ( + <> + {dialog && } + + + + )} + {state == "uploading" && } + +
); diff --git a/web/src/utils/browserUtil.ts b/web/src/utils/browserUtil.ts index 78f740649..b6a82fb54 100644 --- a/web/src/utils/browserUtil.ts +++ b/web/src/utils/browserUtil.ts @@ -9,7 +9,7 @@ export function shareOrCopy(url: string, title?: string) { }); } else { copy(url); - toast.success("Copied to clipboard.", { + toast.success("Copied URL to clipboard.", { position: "top-center", }); } diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index 46c7e6bc3..b8ab51d80 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -60,7 +60,7 @@ export default function ExploreView({ onSelectSearch }: ExploreViewProps) { } return ( -
+
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => ( )} {!uniqueResults && !isLoading && ( -
+
)} From 1f9ba1d62533dc16b48f6059fa469c0921851398 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 12 Sep 2024 16:06:56 -0500 Subject: [PATCH 4/5] Use MobilePage for camera group editor (#13710) * Use MobilePage for camera group editor * alignment * clear editing group name --- .../components/filter/CameraGroupSelector.tsx | 161 ++++++++++++------ web/src/components/mobile/MobilePage.tsx | 2 +- web/src/views/events/EventView.tsx | 2 +- 3 files changed, 114 insertions(+), 51 deletions(-) diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 6f14b8eb9..fdb01c5ee 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -7,8 +7,13 @@ import { Button } from "../ui/button"; import { useCallback, useMemo, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { LuPencil, LuPlus } from "react-icons/lu"; -import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; -import { Drawer, DrawerContent } from "../ui/drawer"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; import { Input } from "../ui/input"; import { Separator } from "../ui/separator"; import { @@ -24,6 +29,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuPortal, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { @@ -53,6 +59,13 @@ import { cn } from "@/lib/utils"; import * as LuIcons from "react-icons/lu"; import IconPicker, { IconName, IconRenderer } from "../icons/IconPicker"; import { isValidIconName } from "@/utils/iconUtil"; +import { + MobilePage, + MobilePageContent, + MobilePageDescription, + MobilePageHeader, + MobilePageTitle, +} from "../mobile/MobilePage"; type CameraGroupSelectorProps = { className?: string; @@ -278,6 +291,7 @@ function NewGroupDialog({ const onSave = () => { setOpen(false); setEditState("none"); + setEditingGroupName(""); }; const onCancel = () => { @@ -290,8 +304,11 @@ function NewGroupDialog({ setEditState("edit"); }, []); - const Overlay = isDesktop ? Dialog : Drawer; - const Content = isDesktop ? DialogContent : DrawerContent; + const Overlay = isDesktop ? Dialog : MobilePage; + const Content = isDesktop ? DialogContent : MobilePageContent; + const Header = isDesktop ? DialogHeader : MobilePageHeader; + const Description = isDesktop ? DialogDescription : MobilePageDescription; + const Title = isDesktop ? DialogTitle : MobilePageTitle; return ( <> @@ -308,16 +325,36 @@ function NewGroupDialog({ }} > -
- {editState === "none" && ( - <> -
- Camera Groups + {editState === "none" && ( + <> +
setOpen(false)} + > + Camera Groups + + Edit camera groups + +
+
+
{currentGroups.map((group) => ( onEditGroup(group)} /> ))} - - )} +
+ + )} - {editState != "none" && ( - <> -
- - {editState == "add" ? "Add" : "Edit"} Camera Group - -
- - - )} -
+ {editState != "none" && ( + <> +
{ + setEditState("none"); + setEditingGroupName(""); + }} + > + + {editState == "add" ? "Add" : "Edit"} Camera Group + + + Edit camera groups + +
+ + + )} @@ -372,6 +420,12 @@ export function EditGroupDialog({ currentGroups, activeGroup, }: EditGroupDialogProps) { + const Overlay = isDesktop ? Dialog : MobilePage; + const Content = isDesktop ? DialogContent : MobilePageContent; + const Header = isDesktop ? DialogHeader : MobilePageHeader; + const Description = isDesktop ? DialogDescription : MobilePageDescription; + const Title = isDesktop ? DialogTitle : MobilePageTitle; + // editing group and state const editingGroup = useMemo(() => { @@ -391,19 +445,24 @@ export function EditGroupDialog({ position="top-center" closeButton={true} /> - { setOpen(open); }} > - -
-
- Edit Camera Group -
+
+
setOpen(false)}> + Edit Camera Group + Edit camera group +
+ setOpen(false)} />
- -
+ + ); } @@ -440,7 +499,7 @@ export function CameraGroupRow({ <>

{group[0]}

@@ -472,12 +531,16 @@ export function CameraGroupRow({ - - Edit - setDeleteDialogOpen(true)}> - Delete - - + + + + Edit + + setDeleteDialogOpen(true)}> + Delete + + + )} @@ -659,7 +722,7 @@ export function CameraGroupEdit({ /> -
+
{filter?.before == undefined && ( Date: Thu, 12 Sep 2024 22:07:35 -0500 Subject: [PATCH 5/5] UI tweaks (#13711) * Fix dialog autofocus problems on mobile * set font size to prevent mobile zooming * Use arrow keys to navigate Explore view --- web/src/components/card/ExportCard.tsx | 12 +++- .../components/filter/CameraGroupSelector.tsx | 2 +- .../components/overlay/CreateUserDialog.tsx | 4 +- web/src/components/overlay/ExportDialog.tsx | 6 +- .../components/overlay/SetPasswordDialog.tsx | 4 +- .../overlay/detail/AnnotationSettingsPane.tsx | 2 +- web/src/components/settings/ZoneEditPane.tsx | 6 +- web/src/pages/Exports.tsx | 2 +- web/src/views/explore/ExploreView.tsx | 8 +-- web/src/views/search/SearchView.tsx | 71 +++++++++++++++++-- .../settings/NotificationsSettingsView.tsx | 2 +- 11 files changed, 92 insertions(+), 27 deletions(-) diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 045740a97..efbc61de2 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -2,7 +2,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { LuTrash } from "react-icons/lu"; import { Button } from "../ui/button"; import { useCallback, useState } from "react"; -import { isDesktop } from "react-device-detect"; +import { isDesktop, isMobile } from "react-device-detect"; import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa"; import Chip from "../indicators/Chip"; import { Skeleton } from "../ui/skeleton"; @@ -82,7 +82,13 @@ export default function ExportCard({ } }} > - + { + if (isMobile) { + e.preventDefault(); + } + }} + > Rename Export Enter a new name for this export. @@ -90,7 +96,7 @@ export default function ExportCard({ {editName && ( <> Name User @@ -89,7 +89,7 @@ export default function CreateUserDialog({ Password diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index b1360aa36..8e865c923 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -273,7 +273,7 @@ export function ExportContent({ /> )} - + e.preventDefault()}> Set Password setPassword(event.target.value)} diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index 6a14b8390..64f41d4d4 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -178,7 +178,7 @@ export function AnnotationSettingsPane({
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 3b9f0dd7e..7600c3b29 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -373,7 +373,7 @@ export default function ZoneEditPane({ Name @@ -395,7 +395,7 @@ export default function ZoneEditPane({ Inertia @@ -417,7 +417,7 @@ export default function ZoneEditPane({ Loitering Time diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index a4659551b..79555909c 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -175,7 +175,7 @@ function Exports() { {exports && (
setSearch(e.target.value)} diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index b8ab51d80..48fee439c 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -17,7 +17,7 @@ import useImageLoaded from "@/hooks/use-image-loaded"; import ActivityIndicator from "@/components/indicators/activity-indicator"; type ExploreViewProps = { - onSelectSearch: (searchResult: SearchResult) => void; + onSelectSearch: (searchResult: SearchResult, index: number) => void; }; export default function ExploreView({ onSelectSearch }: ExploreViewProps) { @@ -76,7 +76,7 @@ export default function ExploreView({ onSelectSearch }: ExploreViewProps) { type ThumbnailRowType = { objectType: string; searchResults?: SearchResult[]; - onSelectSearch: (searchResult: SearchResult) => void; + onSelectSearch: (searchResult: SearchResult, index: number) => void; }; function ThumbnailRow({ @@ -145,7 +145,7 @@ function ThumbnailRow({ type ExploreThumbnailImageProps = { event: SearchResult; - onSelectSearch: (searchResult: SearchResult) => void; + onSelectSearch: (searchResult: SearchResult, index: number) => void; }; function ExploreThumbnailImage({ event, @@ -176,7 +176,7 @@ function ExploreThumbnailImage({ loading={isSafari ? "eager" : "lazy"} draggable={false} src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} - onClick={() => onSelectSearch(event)} + onClick={() => onSelectSearch(event, 0)} onLoad={() => { onImgLoad(); }} diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index f1f706dab..3b6fb49e3 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -13,11 +13,15 @@ import { import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult } from "@/types/search"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isMobileOnly } from "react-device-detect"; import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; +import useKeyboardListener, { + KeyModifiers, +} from "@/hooks/use-keyboard-listener"; +import scrollIntoView from "scroll-into-view-if-needed"; type SearchViewProps = { search: string; @@ -59,8 +63,12 @@ export default function SearchView({ // search interaction - const onSelectSearch = useCallback((item: SearchResult) => { + const [selectedIndex, setSelectedIndex] = useState(null); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + + const onSelectSearch = useCallback((item: SearchResult, index: number) => { setSearchDetail(item); + setSelectedIndex(index); }, []); // confidence score - probably needs tweaking @@ -87,6 +95,56 @@ export default function SearchView({ [searchResults, searchFilter], ); + // keyboard listener + + const onKeyboardShortcut = useCallback( + (key: string | null, modifiers: KeyModifiers) => { + if (!modifiers.down || !uniqueResults) { + return; + } + + switch (key) { + case "ArrowLeft": + setSelectedIndex((prevIndex) => { + const newIndex = + prevIndex === null + ? uniqueResults.length - 1 + : (prevIndex - 1 + uniqueResults.length) % uniqueResults.length; + setSearchDetail(uniqueResults[newIndex]); + return newIndex; + }); + break; + case "ArrowRight": + setSelectedIndex((prevIndex) => { + const newIndex = + prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length; + setSearchDetail(uniqueResults[newIndex]); + return newIndex; + }); + break; + } + }, + [uniqueResults], + ); + + useKeyboardListener(["ArrowLeft", "ArrowRight"], onKeyboardShortcut); + + // scroll into view + + useEffect(() => { + if ( + selectedIndex !== null && + uniqueResults && + itemRefs.current?.[selectedIndex] + ) { + scrollIntoView(itemRefs.current[selectedIndex], { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + } + }, [selectedIndex, uniqueResults]); + return (
@@ -156,12 +214,13 @@ export default function SearchView({ {uniqueResults && (
{uniqueResults && - uniqueResults.map((value) => { - const selected = false; + uniqueResults.map((value, index) => { + const selected = selectedIndex === index; return (
(itemRefs.current[index] = item)} data-start={value.start_time} className="review-item relative rounded-lg" > @@ -173,7 +232,7 @@ export default function SearchView({ setSimilaritySearch(value)} - onClick={() => onSelectSearch(value)} + onClick={() => onSelectSearch(value, index)} /> {searchTerm && (
@@ -207,7 +266,7 @@ export default function SearchView({ )}
); diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index 5e592b7a2..51d467fc3 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -254,7 +254,7 @@ export default function NotificationView({ Email