From e9a8c42734cccad5f2f10d75e6ea1c509f7a60f8 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:21:39 -0500 Subject: [PATCH] Rename Search to Explore --- web/src/App.tsx | 4 +- web/src/hooks/use-navigation.ts | 8 +- web/src/pages/Explore.tsx | 255 ++++++++++++++++++++++++++ web/src/views/explore/ExploreView.tsx | 134 ++++++++++++++ 4 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 web/src/pages/Explore.tsx create mode 100644 web/src/views/explore/ExploreView.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 53491c6aa..5123e3b0c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +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 Explore = lazy(() => import("@/pages/Explore")); const Exports = lazy(() => import("@/pages/Exports")); const SubmitPlus = lazy(() => import("@/pages/SubmitPlus")); const ConfigEditor = lazy(() => import("@/pages/ConfigEditor")); @@ -45,7 +45,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/hooks/use-navigation.ts b/web/src/hooks/use-navigation.ts index 86ca7b1b7..6a43483f8 100644 --- a/web/src/hooks/use-navigation.ts +++ b/web/src/hooks/use-navigation.ts @@ -11,7 +11,7 @@ import useSWR from "swr"; export const ID_LIVE = 1; export const ID_REVIEW = 2; -export const ID_SEARCH = 3; +export const ID_EXPLORE = 3; export const ID_EXPORT = 4; export const ID_PLUS = 5; export const ID_PLAYGROUND = 6; @@ -41,11 +41,11 @@ export default function useNavigation( url: "/review", }, { - id: ID_SEARCH, + id: ID_EXPLORE, variant, icon: IoSearch, - title: "Search", - url: "/search", + title: "Explore", + url: "/explore", }, { id: ID_EXPORT, diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx new file mode 100644 index 000000000..e26cd067e --- /dev/null +++ b/web/src/pages/Explore.tsx @@ -0,0 +1,255 @@ +import { useApiFilterArgs } from "@/hooks/use-api-filter"; +import { useCameraPreviews } from "@/hooks/use-camera-previews"; +import { useOverlayState } from "@/hooks/use-overlay-state"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { RecordingStartingPoint } from "@/types/record"; +import { + PartialSearchResult, + SearchFilter, + SearchResult, +} from "@/types/search"; +import { TimeRange } from "@/types/timeline"; +import { RecordingView } from "@/views/recording/RecordingView"; +import SearchView from "@/views/search/SearchView"; +import ExploreView from "@/views/explore/ExploreView"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; + +export default function Explore() { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + // search field handler + + const [searchTimeout, setSearchTimeout] = useState(); + const [search, setSearch] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + + const [recording, setRecording] = + useOverlayState("recording"); + + // search filter + + const [searchFilter, setSearchFilter, searchSearchParams] = + useApiFilterArgs(); + + const onUpdateFilter = useCallback( + (newFilter: SearchFilter) => { + setSearchFilter(newFilter); + }, + [setSearchFilter], + ); + + // search api + + const [similaritySearch, setSimilaritySearch] = + useState(); + + useEffect(() => { + if ( + config?.semantic_search.enabled && + searchSearchParams["search_type"] == "similarity" && + searchSearchParams["event_id"]?.length != 0 && + searchFilter + ) { + setSimilaritySearch({ + id: searchSearchParams["event_id"], + }); + + // remove event id from url params + const { event_id: _event_id, ...newFilter } = searchFilter; + setSearchFilter(newFilter); + } + // only run similarity search with event_id in the url when coming from review + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (similaritySearch) { + setSimilaritySearch(undefined); + } + + if (searchTimeout) { + clearTimeout(searchTimeout); + } + + setSearchTimeout( + setTimeout(() => { + setSearchTimeout(undefined); + setSearchTerm(search); + }, 750), + ); + // we only want to update the searchTerm when search changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search]); + + const searchQuery = useMemo(() => { + if (similaritySearch) { + return [ + "events/search", + { + query: similaritySearch.id, + cameras: searchSearchParams["cameras"], + labels: searchSearchParams["labels"], + sub_labels: searchSearchParams["subLabels"], + zones: searchSearchParams["zones"], + before: searchSearchParams["before"], + after: searchSearchParams["after"], + include_thumbnails: 0, + search_type: "similarity", + }, + ]; + } + + if (searchTerm) { + return [ + "events/search", + { + query: searchTerm, + cameras: searchSearchParams["cameras"], + labels: searchSearchParams["labels"], + sub_labels: searchSearchParams["subLabels"], + zones: searchSearchParams["zones"], + before: searchSearchParams["before"], + after: searchSearchParams["after"], + search_type: searchSearchParams["search_type"], + include_thumbnails: 0, + }, + ]; + } + + return [ + "events", + { + cameras: searchSearchParams["cameras"], + labels: searchSearchParams["labels"], + sub_labels: searchSearchParams["subLabels"], + zones: searchSearchParams["zones"], + before: searchSearchParams["before"], + after: searchSearchParams["after"], + search_type: searchSearchParams["search_type"], + limit: Object.keys(searchSearchParams).length == 0 ? 20 : null, + in_progress: 0, + include_thumbnails: 0, + }, + ]; + }, [searchTerm, searchSearchParams, similaritySearch]); + + const { data: searchResults, isLoading } = + useSWR(searchQuery); + + const previewTimeRange = useMemo(() => { + if (!searchResults) { + return { after: 0, before: 0 }; + } + + return { + after: Math.min(...searchResults.map((res) => res.start_time)), + before: Math.max( + ...searchResults.map((res) => res.end_time ?? Date.now() / 1000), + ), + }; + }, [searchResults]); + + const allPreviews = useCameraPreviews(previewTimeRange, { + autoRefresh: false, + fetchPreviews: searchResults != undefined, + }); + + // selection + + const onOpenSearch = useCallback( + (item: SearchResult) => { + setRecording({ + camera: item.camera, + startTime: item.start_time, + severity: "alert", + }); + }, + [setRecording], + ); + + const selectedReviewData = useMemo(() => { + if (!recording) { + return undefined; + } + + if (!config) { + return undefined; + } + + if (!searchResults) { + return undefined; + } + + const allCameras = searchFilter?.cameras ?? Object.keys(config.cameras); + + return { + camera: recording.camera, + start_time: recording.startTime, + allCameras: allCameras, + }; + + // previews will not update after item is selected + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recording, searchResults]); + + const selectedTimeRange = useMemo(() => { + if (!recording) { + return undefined; + } + + const time = new Date(recording.startTime * 1000); + time.setUTCMinutes(0, 0, 0); + const start = time.getTime() / 1000; + time.setHours(time.getHours() + 2); + const end = time.getTime() / 1000; + return { + after: start, + before: end, + }; + }, [recording]); + + if (recording) { + if (selectedReviewData && selectedTimeRange) { + return ( + + ); + } + } else { + if ( + search || + similaritySearch || + (searchFilter && Object.keys(searchFilter).length != 0) + ) { + return ( + + ); + } else { + return ( +
+ +
+ ); + } + } +} diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx new file mode 100644 index 000000000..5e6b211f0 --- /dev/null +++ b/web/src/views/explore/ExploreView.tsx @@ -0,0 +1,134 @@ +import { Event } from "@/types/event"; +import { useEffect, useMemo, useState } from "react"; +import { isIOS } from "react-device-detect"; +import useSWR from "swr"; +import { useApiHost } from "@/api"; +import { cn } from "@/lib/utils"; +import { LuArrowRightCircle } from "react-icons/lu"; +import { useNavigate } from "react-router-dom"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; + +export default function ImageAccordion() { + // title + + useEffect(() => { + document.title = "Explore - Frigate"; + }, []); + + // data + + const { data: events } = useSWR( + [ + "events", + { + limit: 100, + }, + ], + { + revalidateOnFocus: false, + }, + ); + + const eventsByLabel = useMemo(() => { + if (!events) return {}; + return events.reduce>((acc, event) => { + const label = event.label || "Unknown"; + if (!acc[label]) { + acc[label] = []; + } + acc[label].push(event); + return acc; + }, {}); + }, [events]); + + return ( +
+ {Object.entries(eventsByLabel).map(([label, filteredEvents]) => ( + + ))} +
+ ); +} + +function ThumbnailRow({ + objectType, + events, +}: { + objectType: string; + events?: Event[]; +}) { + const apiHost = useApiHost(); + const navigate = useNavigate(); + const [hoveredIndex, setHoveredIndex] = useState(null); + + const handleSimilaritySearch = (eventId: string) => { + const similaritySearchParams = new URLSearchParams({ + search_type: "similarity", + event_id: eventId, + }).toString(); + navigate(`/explore?${similaritySearchParams}`); + }; + + return ( +
+

{objectType}

+
+ {events?.map((event, index) => ( +
setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + > + {`${objectType} +
+ ))} +
+ events && + events.length > 0 && + handleSimilaritySearch(events[events.length - 1].id) + } + > + + + + + + View More + + +
+
+
+ ); +}