From 828fdbfd2dbee0594eb2f151fd70a35685bf6806 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:01:01 -0500 Subject: [PATCH] UI tweaks (#14505) * Add reindex progress to mobile bottom bar status alert * move menu to new component * actions component in search footer thumbnail * context menu for explore summary thumbnail images * readd top_score to search query for old events --- frigate/api/event.py | 1 + .../components/card/SearchThumbnailFooter.tsx | 219 +++--------------- .../components/menu/SearchResultActions.tsx | 218 +++++++++++++++++ web/src/components/navigation/Bottombar.tsx | 19 +- web/src/views/explore/ExploreView.tsx | 119 +++++++--- web/src/views/search/SearchView.tsx | 2 + 6 files changed, 346 insertions(+), 232 deletions(-) create mode 100644 web/src/components/menu/SearchResultActions.tsx diff --git a/frigate/api/event.py b/frigate/api/event.py index 1ce23a9bc..89b2fedef 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -394,6 +394,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) Event.end_time, Event.has_clip, Event.has_snapshot, + Event.top_score, Event.data, Event.plus_id, ReviewSegment.thumb_path, diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx index b21361b18..8c4fb82b5 100644 --- a/web/src/components/card/SearchThumbnailFooter.tsx +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -1,38 +1,10 @@ -import { useCallback, useState } from "react"; import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import ActivityIndicator from "../indicators/activity-indicator"; import { SearchResult } from "@/types/search"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../ui/alert-dialog"; -import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu"; -import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; -import { FrigatePlusDialog } from "../overlay/dialog/FrigatePlusDialog"; -import { Event } from "@/types/event"; -import { FaArrowsRotate } from "react-icons/fa6"; -import { baseUrl } from "@/api/baseUrl"; -import axios from "axios"; -import { toast } from "sonner"; -import { MdImageSearch } from "react-icons/md"; -import { isMobileOnly } from "react-device-detect"; -import { buttonVariants } from "../ui/button"; +import ActivityIndicator from "../indicators/activity-indicator"; +import SearchResultActions from "../menu/SearchResultActions"; import { cn } from "@/lib/utils"; type SearchThumbnailProps = { @@ -52,31 +24,7 @@ export default function SearchThumbnailFooter({ }: SearchThumbnailProps) { const { data: config } = useSWR("config"); - // interactions - - const [showFrigatePlus, setShowFrigatePlus] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - - const handleDelete = useCallback(() => { - axios - .delete(`events/${searchResult.id}`) - .then((resp) => { - if (resp.status == 200) { - toast.success("Tracked object deleted successfully.", { - position: "top-center", - }); - refreshResults(); - } - }) - .catch(() => { - toast.error("Failed to delete tracked object.", { - position: "top-center", - }); - }); - }, [searchResult, refreshResults]); - // date - const formattedDate = useFormattedTimestamp( searchResult.start_time, config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", @@ -84,146 +32,31 @@ export default function SearchThumbnailFooter({ ); return ( - <> - setDeleteDialogOpen(!deleteDialogOpen)} - > - - - Confirm Delete - - - Are you sure you want to delete this tracked object? - - - Cancel - - Delete - - - - - setShowFrigatePlus(false)} - onEventUploaded={() => { - searchResult.plus_id = "submitted"; - }} - /> - -
4 && - "items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1", +
4 && + "items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1", + )} + > +
+ {searchResult.end_time ? ( + + ) : ( +
+ +
)} - > -
- {searchResult.end_time ? ( - - ) : ( -
- -
- )} - {formattedDate} -
-
- {!isMobileOnly && - config?.plus?.enabled && - searchResult.has_snapshot && - searchResult.end_time && - !searchResult.plus_id && ( - - - setShowFrigatePlus(true)} - /> - - Submit to Frigate+ - - )} - - {config?.semantic_search?.enabled && ( - - - - - Find similar - - )} - - - - - - - {searchResult.has_clip && ( - - - - Download video - - - )} - {searchResult.has_snapshot && ( - - - - Download snapshot - - - )} - - - View object lifecycle - - - {isMobileOnly && - config?.plus?.enabled && - searchResult.has_snapshot && - searchResult.end_time && - !searchResult.plus_id && ( - setShowFrigatePlus(true)} - > - - Submit to Frigate+ - - )} - setDeleteDialogOpen(true)} - > - - Delete - - - -
+ {formattedDate}
- +
+ +
+
); } diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx new file mode 100644 index 000000000..d95acc5a5 --- /dev/null +++ b/web/src/components/menu/SearchResultActions.tsx @@ -0,0 +1,218 @@ +import { useState, ReactNode } from "react"; +import { SearchResult } from "@/types/search"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { baseUrl } from "@/api/baseUrl"; +import { toast } from "sonner"; +import axios from "axios"; +import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu"; +import { FaArrowsRotate } from "react-icons/fa6"; +import { MdImageSearch } from "react-icons/md"; +import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; +import { isMobileOnly } from "react-device-detect"; +import { buttonVariants } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog"; +import useSWR from "swr"; +import { Event } from "@/types/event"; + +type SearchResultActionsProps = { + searchResult: SearchResult; + findSimilar: () => void; + refreshResults: () => void; + showObjectLifecycle: () => void; + isContextMenu?: boolean; + children?: ReactNode; +}; + +export default function SearchResultActions({ + searchResult, + findSimilar, + refreshResults, + showObjectLifecycle, + isContextMenu = false, + children, +}: SearchResultActionsProps) { + const { data: config } = useSWR("config"); + + const [showFrigatePlus, setShowFrigatePlus] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const handleDelete = () => { + axios + .delete(`events/${searchResult.id}`) + .then((resp) => { + if (resp.status == 200) { + toast.success("Tracked object deleted successfully.", { + position: "top-center", + }); + refreshResults(); + } + }) + .catch(() => { + toast.error("Failed to delete tracked object.", { + position: "top-center", + }); + }); + }; + + const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem; + + const menuItems = ( + <> + {searchResult.has_clip && ( + + + + Download video + + + )} + {searchResult.has_snapshot && ( + + + + Download snapshot + + + )} + + + View object lifecycle + + {config?.semantic_search?.enabled && isContextMenu && ( + + + Find similar + + )} + {isMobileOnly && + config?.plus?.enabled && + searchResult.has_snapshot && + searchResult.end_time && + !searchResult.plus_id && ( + setShowFrigatePlus(true)}> + + Submit to Frigate+ + + )} + setDeleteDialogOpen(true)}> + + Delete + + + ); + + return ( + <> + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete this tracked object? + + + Cancel + + Delete + + + + + setShowFrigatePlus(false)} + onEventUploaded={() => { + searchResult.plus_id = "submitted"; + }} + /> + + {isContextMenu ? ( + + {children} + {menuItems} + + ) : ( + <> + {config?.semantic_search?.enabled && ( + + + + + Find similar + + )} + + {!isMobileOnly && + config?.plus?.enabled && + searchResult.has_snapshot && + searchResult.end_time && + !searchResult.plus_id && ( + + + setShowFrigatePlus(true)} + /> + + Submit to Frigate+ + + )} + + + + + + {menuItems} + + + )} + + ); +} diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index c30f347e6..84d9fc4fc 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -3,7 +3,7 @@ import { IoIosWarning } from "react-icons/io"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import useSWR from "swr"; import { FrigateStats } from "@/types/stats"; -import { useFrigateStats } from "@/api/ws"; +import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws"; import { useContext, useEffect, useMemo } from "react"; import useStats from "@/hooks/use-stats"; import GeneralSettings from "../menu/GeneralSettings"; @@ -74,6 +74,23 @@ function StatusAlertNav({ className }: StatusAlertNavProps) { }); }, [potentialProblems, addMessage, clearMessages]); + const { payload: reindexState } = useEmbeddingsReindexProgress(); + + useEffect(() => { + if (reindexState) { + if (reindexState.status == "indexing") { + clearMessages("embeddings-reindex"); + addMessage( + "embeddings-reindex", + `Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`, + ); + } + if (reindexState.status === "completed") { + clearMessages("embeddings-reindex"); + } + } + }, [reindexState, addMessage, clearMessages]); + if (!messages || Object.keys(messages).length === 0) { return; } diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index 3a0b9cc7b..00ed19ab2 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -18,15 +18,22 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; import { useEventUpdate } from "@/api/ws"; import { isEqual } from "lodash"; import TimeAgo from "@/components/dynamic/TimeAgo"; +import SearchResultActions from "@/components/menu/SearchResultActions"; +import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog"; +import { FrigateConfig } from "@/types/frigateConfig"; type ExploreViewProps = { searchDetail: SearchResult | undefined; setSearchDetail: (search: SearchResult | undefined) => void; + setSimilaritySearch: (search: SearchResult) => void; + onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; }; export default function ExploreView({ searchDetail, setSearchDetail, + setSimilaritySearch, + onSelectSearch, }: ExploreViewProps) { // title @@ -102,6 +109,9 @@ export default function ExploreView({ isValidating={isValidating} objectType={label} setSearchDetail={setSearchDetail} + mutate={mutate} + setSimilaritySearch={setSimilaritySearch} + onSelectSearch={onSelectSearch} /> ))}
@@ -113,6 +123,9 @@ type ThumbnailRowType = { searchResults?: SearchResult[]; isValidating: boolean; setSearchDetail: (search: SearchResult | undefined) => void; + mutate: () => void; + setSimilaritySearch: (search: SearchResult) => void; + onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; }; function ThumbnailRow({ @@ -120,6 +133,9 @@ function ThumbnailRow({ searchResults, isValidating, setSearchDetail, + mutate, + setSimilaritySearch, + onSelectSearch, }: ThumbnailRowType) { const navigate = useNavigate(); @@ -155,6 +171,9 @@ function ThumbnailRow({ ))} @@ -184,54 +203,78 @@ function ThumbnailRow({ type ExploreThumbnailImageProps = { event: SearchResult; setSearchDetail: (search: SearchResult | undefined) => void; + mutate: () => void; + setSimilaritySearch: (search: SearchResult) => void; + onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; }; function ExploreThumbnailImage({ event, setSearchDetail, + mutate, + setSimilaritySearch, + onSelectSearch, }: ExploreThumbnailImageProps) { const apiHost = useApiHost(); + const { data: config } = useSWR("config"); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); - return ( - <> - + const handleFindSimilar = () => { + if (config?.semantic_search.enabled) { + setSimilaritySearch(event); + } + }; - setSearchDetail(event)} - onLoad={() => { - onImgLoad(); - }} - /> - {isDesktop && ( -
- {event.end_time ? ( - - ) : ( -
- -
+ const handleShowObjectLifecycle = () => { + onSelectSearch(event, 0, "object lifecycle"); + }; + + return ( + +
+ + - )} - + style={ + isIOS + ? { + WebkitUserSelect: "none", + WebkitTouchCallout: "none", + } + : undefined + } + loading={isSafari ? "eager" : "lazy"} + draggable={false} + src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} + onClick={() => setSearchDetail(event)} + onLoad={onImgLoad} + alt={`${event.label} thumbnail`} + /> + {isDesktop && ( +
+ {event.end_time ? ( + + ) : ( +
+ +
+ )} +
+ )} +
+
); } diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 4b84d0ac5..5fd6c98fa 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -489,6 +489,8 @@ export default function SearchView({
)}