From 644069fb239f2774d500b5a4e9b2477c1c8737c0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 15 Oct 2024 08:24:47 -0500 Subject: [PATCH] Explore layout changes (#14348) * Reset selected index on new searches * Remove right click for similarity search * Fix sub label icon * add card footer * Add Frigate+ dialog * Move buttons and menu to thumbnail footer * Add similarity search * Show object score * Implement download buttons * remove confidence score * conditionally show submenu items * Implement delete * fix icon color * Add object lifecycle button * fix score * delete confirmation * small tweaks * consistent icons --------- Co-authored-by: Nicolas Mowen --- web/src/components/card/SearchThumbnail.tsx | 57 +++-- .../components/card/SearchThumbnailFooter.tsx | 198 ++++++++++++++++++ web/src/components/input/InputWithTags.tsx | 6 +- .../overlay/detail/SearchDetailDialog.tsx | 17 +- web/src/pages/Explore.tsx | 3 +- web/src/types/frigateConfig.ts | 1 + web/src/views/search/SearchView.tsx | 97 ++++----- 7 files changed, 283 insertions(+), 96 deletions(-) create mode 100644 web/src/components/card/SearchThumbnailFooter.tsx diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index fe174e968..4ad7d7c5c 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -1,50 +1,56 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useApiHost } from "@/api"; import { getIconForLabel } from "@/utils/iconUtil"; -import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { isIOS, isSafari } from "react-device-detect"; import Chip from "@/components/indicators/Chip"; -import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useImageLoaded from "@/hooks/use-image-loaded"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; -import ActivityIndicator from "../indicators/activity-indicator"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { SearchResult } from "@/types/search"; -import useContextMenu from "@/hooks/use-contextmenu"; import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; type SearchThumbnailProps = { searchResult: SearchResult; - findSimilar: () => void; onClick: (searchResult: SearchResult) => void; }; export default function SearchThumbnail({ searchResult, - findSimilar, onClick, }: SearchThumbnailProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); - useContextMenu(imgRef, findSimilar); + // interactions const handleOnClick = useCallback(() => { onClick(searchResult); }, [searchResult, onClick]); - // date + const objectLabel = useMemo(() => { + if ( + !config || + !searchResult.sub_label || + !config.model.attributes_map[searchResult.label] + ) { + return searchResult.label; + } - const formattedDate = useFormattedTimestamp( - searchResult.start_time, - config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", - config?.ui.timezone, - ); + if ( + config.model.attributes_map[searchResult.label].includes( + searchResult.sub_label, + ) + ) { + return searchResult.sub_label; + } + + return `${searchResult.label}-verified`; + }, [config, searchResult]); return (
@@ -80,17 +86,21 @@ export default function SearchThumbnail({
onClick(searchResult)} > - {getIconForLabel(searchResult.label, "size-3 text-white")} + {getIconForLabel(objectLabel, "size-3 text-white")} + {Math.floor( + searchResult.score ?? searchResult.data.top_score * 100, + )} + %
- {[...new Set([searchResult.label])] + {[objectLabel] .filter( (item) => item !== undefined && !item.includes("-verified"), ) @@ -103,18 +113,7 @@ export default function SearchThumbnail({
-
-
- {searchResult.end_time ? ( - - ) : ( -
- -
- )} - {formattedDate} -
-
+
); diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx new file mode 100644 index 000000000..3e5dbe236 --- /dev/null +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -0,0 +1,198 @@ +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, + DropdownMenuLabel, + DropdownMenuSeparator, + 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"; + +type SearchThumbnailProps = { + searchResult: SearchResult; + findSimilar: () => void; + refreshResults: () => void; + showObjectLifecycle: () => void; +}; + +export default function SearchThumbnailFooter({ + searchResult, + findSimilar, + refreshResults, + showObjectLifecycle, +}: 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", + config?.ui.timezone, + ); + + return ( + <> + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete this tracked object? + + + Cancel + + Delete + + + + + setShowFrigatePlus(false)} + onEventUploaded={() => {}} + /> + +
+ {searchResult.end_time ? ( + + ) : ( +
+ +
+ )} + {formattedDate} +
+
+ {config?.plus?.enabled && + searchResult.has_snapshot && + searchResult.end_time && ( + + + setShowFrigatePlus(true)} + /> + + Submit to Frigate+ + + )} + + {config?.semantic_search?.enabled && ( + + + + + Find similar + + )} + + + + + + + + Tracked Object Actions + + + {searchResult.has_clip && ( + + + + Download video + + + )} + {searchResult.has_snapshot && ( + + + + Download snapshot + + + )} + + + View object lifecycle + + setDeleteDialogOpen(true)}> + + Delete + + + +
+ + ); +} diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx index 5d0786346..9ca1e4093 100644 --- a/web/src/components/input/InputWithTags.tsx +++ b/web/src/components/input/InputWithTags.tsx @@ -2,7 +2,6 @@ import React, { useState, useRef, useEffect, useCallback } from "react"; import { LuX, LuFilter, - LuImage, LuChevronDown, LuChevronUp, LuTrash2, @@ -44,6 +43,7 @@ import { import { toast } from "sonner"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; +import { MdImageSearch } from "react-icons/md"; type InputWithTagsProps = { inputFocused: boolean; @@ -514,7 +514,7 @@ export default function InputWithTags({ onFocus={handleInputFocus} onBlur={handleInputBlur} onKeyDown={handleInputKeyDown} - className="text-md h-9 pr-24" + className="text-md h-9 pr-32" placeholder="Search..." />
@@ -549,7 +549,7 @@ export default function InputWithTags({ {isSimilaritySearch && ( - diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 1cee70aaa..23734ea90 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -69,16 +69,20 @@ const SEARCH_TABS = [ "video", "object lifecycle", ] as const; -type SearchTab = (typeof SEARCH_TABS)[number]; +export type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { search?: SearchResult; + page: SearchTab; setSearch: (search: SearchResult | undefined) => void; + setSearchPage: (page: SearchTab) => void; setSimilarity?: () => void; }; export default function SearchDetailDialog({ search, + page, setSearch, + setSearchPage, setSimilarity, }: SearchDetailDialogProps) { const { data: config } = useSWR("config", { @@ -87,8 +91,11 @@ export default function SearchDetailDialog({ // tabs - const [page, setPage] = useState("details"); - const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + const [pageToggle, setPageToggle] = useOptimisticState( + page, + setSearchPage, + 100, + ); // dialog and mobile page @@ -130,9 +137,9 @@ export default function SearchDetailDialog({ } if (!searchTabs.includes(pageToggle)) { - setPage("details"); + setSearchPage("details"); } - }, [pageToggle, searchTabs]); + }, [pageToggle, searchTabs, setSearchPage]); if (!search) { return; diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 816618fe5..ffbef1060 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -384,6 +384,7 @@ export default function Explore() { searchFilter={searchFilter} searchResults={searchResults} isLoading={(isLoadingInitialData || isLoadingMore) ?? true} + hasMore={!isReachingEnd} setSearch={setSearch} setSimilaritySearch={(search) => { setSearchFilter({ @@ -395,7 +396,7 @@ export default function Explore() { setSearchFilter={setSearchFilter} onUpdateFilter={setSearchFilter} loadMore={loadMore} - hasMore={!isReachingEnd} + refresh={mutate} /> )} diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index fe889ed9d..2c54b289e 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -340,6 +340,7 @@ export interface FrigateConfig { path: string | null; width: number; colormap: { [key: string]: [number, number, number] }; + attributes_map: { [key: string]: [string] }; }; motion: Record | null; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index e64affa36..bc4f5b54d 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -1,8 +1,9 @@ import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import ActivityIndicator from "@/components/indicators/activity-indicator"; -import Chip from "@/components/indicators/Chip"; -import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog"; +import SearchDetailDialog, { + SearchTab, +} from "@/components/overlay/detail/SearchDetailDialog"; import { Toaster } from "@/components/ui/sonner"; import { Tooltip, @@ -14,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobileOnly } from "react-device-detect"; -import { LuColumns, LuImage, LuSearchX, LuText } from "react-icons/lu"; +import { LuColumns, LuSearchX } from "react-icons/lu"; import useSWR from "swr"; import ExploreView from "../explore/ExploreView"; import useKeyboardListener, { @@ -25,7 +26,6 @@ import InputWithTags from "@/components/input/InputWithTags"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { isEqual } from "lodash"; import { formatDateToLocaleString } from "@/utils/dateUtil"; -import { TooltipPortal } from "@radix-ui/react-tooltip"; import { Slider } from "@/components/ui/slider"; import { Popover, @@ -33,6 +33,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { usePersistence } from "@/hooks/use-persistence"; +import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter"; type SearchViewProps = { search: string; @@ -40,12 +41,13 @@ type SearchViewProps = { searchFilter?: SearchFilter; searchResults?: SearchResult[]; isLoading: boolean; + hasMore: boolean; setSearch: (search: string) => void; setSimilaritySearch: (search: SearchResult) => void; setSearchFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void; loadMore: () => void; - hasMore: boolean; + refresh: () => void; }; export default function SearchView({ search, @@ -53,12 +55,13 @@ export default function SearchView({ searchFilter, searchResults, isLoading, + hasMore, setSearch, setSimilaritySearch, setSearchFilter, onUpdateFilter, loadMore, - hasMore, + refresh, }: SearchViewProps) { const contentRef = useRef(null); const { data: config } = useSWR("config", { @@ -76,8 +79,6 @@ export default function SearchView({ "sm:grid-cols-4": effectiveColumnCount === 4, "sm:grid-cols-5": effectiveColumnCount === 5, "sm:grid-cols-6": effectiveColumnCount === 6, - "sm:grid-cols-7": effectiveColumnCount === 7, - "sm:grid-cols-8": effectiveColumnCount >= 8, }); // suggestions values @@ -161,16 +162,25 @@ export default function SearchView({ // detail const [searchDetail, setSearchDetail] = useState(); + const [page, setPage] = useState("details"); // search interaction const [selectedIndex, setSelectedIndex] = useState(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); - const onSelectSearch = useCallback((item: SearchResult, index: number) => { - setSearchDetail(item); - setSelectedIndex(index); - }, []); + const onSelectSearch = useCallback( + (item: SearchResult, index: number, page: SearchTab = "details") => { + setPage(page); + setSearchDetail(item); + setSelectedIndex(index); + }, + [], + ); + + useEffect(() => { + setSelectedIndex(0); + }, [searchTerm, searchFilter]); // update search detail when results change @@ -187,21 +197,6 @@ export default function SearchView({ } }, [searchResults, searchDetail]); - // confidence score - - const zScoreToConfidence = (score: number) => { - // Normalizing is not needed for similarity searches - // Sigmoid function for normalized: 1 / (1 + e^x) - // Cosine for similarity - if (searchFilter) { - const notNormalized = searchFilter?.search_type?.includes("similarity"); - - const confidence = notNormalized ? 1 - score : 1 / (1 + Math.exp(score)); - - return Math.round(confidence * 100); - } - }; - const hasExistingSearch = useMemo( () => searchResults != undefined || searchFilter != undefined, [searchResults, searchFilter], @@ -310,7 +305,9 @@ export default function SearchView({ setSimilaritySearch(searchDetail)) } @@ -388,47 +385,31 @@ export default function SearchView({ >
onSelectSearch(value, index)} + /> +
+
+
+ { if (config?.semantic_search.enabled) { setSimilaritySearch(value); } }} - onClick={() => onSelectSearch(value, index)} + refreshResults={refresh} + showObjectLifecycle={() => + onSelectSearch(value, index, "object lifecycle") + } /> - {(searchTerm || - searchFilter?.search_type?.includes("similarity")) && ( -
- - - - {value.search_source == "thumbnail" ? ( - - ) : ( - - )} - {zScoreToConfidence(value.search_distance)}% - - - - - Matched {value.search_source} at{" "} - {zScoreToConfidence(value.search_distance)}% - - - -
- )}
-
); })} @@ -467,7 +448,7 @@ export default function SearchView({ setColumnCount(value)} - max={8} + max={6} min={2} step={1} className="flex-grow"