diff --git a/frigate/api/event.py b/frigate/api/event.py index efe9412df..6e75602e9 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -251,6 +251,61 @@ def events(): return jsonify(list(events)) +@EventBp.route("/events/explore") +def events_explore(): + limit = request.args.get("limit", 10, type=int) + + subquery = Event.select( + Event.id, + Event.camera, + Event.label, + Event.zones, + Event.start_time, + Event.end_time, + Event.has_clip, + Event.has_snapshot, + Event.plus_id, + Event.retain_indefinitely, + Event.sub_label, + Event.top_score, + Event.false_positive, + Event.box, + Event.data, + fn.rank() + .over(partition_by=[Event.label], order_by=[Event.start_time.desc()]) + .alias("rank"), + fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count"), + ).alias("subquery") + + query = ( + Event.select( + subquery.c.id, + subquery.c.camera, + subquery.c.label, + subquery.c.zones, + subquery.c.start_time, + subquery.c.end_time, + subquery.c.has_clip, + subquery.c.has_snapshot, + subquery.c.plus_id, + subquery.c.retain_indefinitely, + subquery.c.sub_label, + subquery.c.top_score, + subquery.c.false_positive, + subquery.c.box, + subquery.c.data, + subquery.c.event_count, + ) + .from_(subquery) + .where(subquery.c.rank <= limit) + .order_by(subquery.c.event_count.desc(), subquery.c.start_time.desc()) + .dicts() + ) + + events = query.iterator() + return jsonify(list(events)) + + @EventBp.route("/event_ids") def event_ids(): idString = request.args.get("ids") @@ -317,7 +372,10 @@ def events_search(): Event.zones, Event.start_time, Event.end_time, + Event.has_clip, + Event.has_snapshot, Event.data, + Event.plus_id, ReviewSegment.thumb_path, ] 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/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx new file mode 100644 index 000000000..5ce653a4c --- /dev/null +++ b/web/src/components/card/SearchThumbnail.tsx @@ -0,0 +1,185 @@ +import React, { useCallback, useEffect, useMemo, useState } 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, isMobile, 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 { useSwipeable } from "react-swipeable"; +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"; + +type SearchThumbnailProps = { + searchResult: SearchResult; + scrollLock?: boolean; + findSimilar: () => void; + onClick: (searchResult: SearchResult, detail: boolean) => void; +}; + +export default function SearchThumbnail({ + searchResult, + scrollLock = false, + findSimilar, + onClick, +}: SearchThumbnailProps) { + const apiHost = useApiHost(); + const { data: config } = useSWR("config"); + const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); + + // interaction + + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => setDetails(false), + onSwipedRight: () => setDetails(true), + preventScrollOnSwipe: true, + }); + + useContextMenu(imgRef, findSimilar); + + // Hover Details + + const [hoverTimeout, setHoverTimeout] = useState(); + const [details, setDetails] = useState(false); + const [tooltipHovering, setTooltipHovering] = useState(false); + const showingMoreDetail = useMemo( + () => details && !tooltipHovering, + [details, tooltipHovering], + ); + const [isHovered, setIsHovered] = useState(false); + + const handleOnClick = useCallback( + (e: React.MouseEvent) => { + if (!showingMoreDetail) { + onClick(searchResult, e.metaKey); + } + }, + [searchResult, showingMoreDetail, onClick], + ); + + useEffect(() => { + if (isHovered && scrollLock) { + return; + } + + if (isHovered && !tooltipHovering) { + setHoverTimeout( + setTimeout(() => { + setDetails(true); + setHoverTimeout(null); + }, 500), + ); + } else { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + } + + setDetails(false); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isHovered, scrollLock, tooltipHovering]); + + // date + + const formattedDate = useFormattedTimestamp( + searchResult.start_time, + config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", + ); + + return ( +
setIsHovered(true)} + onMouseLeave={isMobile ? undefined : () => setIsHovered(false)} + onClick={handleOnClick} + {...swipeHandlers} + > + +
+ { + onImgLoad(); + }} + /> + +
+ +
setTooltipHovering(true)} + onMouseLeave={() => setTooltipHovering(false)} + > + +
+ { + <> + onClick(searchResult, true)} + > + {getIconForLabel( + searchResult.label, + "size-3 text-white", + )} + + + } +
+
+
+ + {[...new Set([searchResult.label])] + .filter( + (item) => item !== undefined && !item.includes("-verified"), + ) + .map((text) => capitalizeFirstLetter(text)) + .sort() + .join(", ") + .replaceAll("-verified", "")} + +
+
+
+
+
+ {searchResult.end_time ? ( + + ) : ( +
+ +
+ )} + {formattedDate} +
+
+
+
+ ); +} diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx index cfa69faf6..5c98aca79 100644 --- a/web/src/components/filter/CalendarFilterButton.tsx +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -110,7 +110,7 @@ export function CalendarRangeFilterButton({ className={`${range == undefined ? "text-secondary-foreground" : "text-selected-foreground"}`} />
{range == undefined ? defaultText : selectedDate}
diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index b1878bf12..dfe3fdaa1 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -1,6 +1,6 @@ import { Button } from "../ui/button"; import { CameraGroupConfig } from "@/types/frigateConfig"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -17,12 +17,14 @@ type CameraFilterButtonProps = { allCameras: string[]; groups: [string, CameraGroupConfig][]; selectedCameras: string[] | undefined; + hideText?: boolean; updateCameraFilter: (cameras: string[] | undefined) => void; }; export function CamerasFilterButton({ allCameras, groups, selectedCameras, + hideText = isMobile, updateCameraFilter, }: CameraFilterButtonProps) { const [open, setOpen] = useState(false); @@ -30,6 +32,18 @@ export function CamerasFilterButton({ selectedCameras, ); + const buttonText = useMemo(() => { + if (isMobile) { + return "Cameras"; + } + + if (!selectedCameras || selectedCameras.length == 0) { + return "All Cameras"; + } + + return `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`; + }, [selectedCameras]); + const trigger = ( ); diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index c66ab9438..261738d82 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -5,7 +5,6 @@ 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 { isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Switch } from "../ui/switch"; @@ -19,6 +18,8 @@ import { DateRange } from "react-day-picker"; import { cn } from "@/lib/utils"; import SubFilterIcon from "../icons/SubFilterIcon"; import { FaLocationDot } from "react-icons/fa6"; +import { MdLabel } from "react-icons/md"; +import SearchSourceIcon from "../icons/SearchSourceIcon"; const SEARCH_FILTERS = [ "cameras", @@ -154,12 +155,18 @@ export default function SearchFilterGroup({ ); return ( -
+
{filters.includes("cameras") && ( { onUpdateFilter({ ...filter, cameras: newCameras }); }} @@ -175,19 +182,10 @@ export default function SearchFilterGroup({ to: new Date(filter.before * 1000), } } - defaultText="All Dates" + defaultText={isMobile ? "Dates" : "All Dates"} updateSelectedRange={onUpdateSelectedRange} /> )} - {filters.includes("general") && ( - { - onUpdateFilter({ ...filter, labels: newLabels }); - }} - /> - )} {filters.includes("zone") && allZones.length > 0 && ( )} + {filters.includes("general") && ( + { + onUpdateFilter({ ...filter, labels: newLabels }); + }} + /> + )} {filters.includes("sub") && ( { + if (isMobile) { + return "Labels"; + } + + if (!selectedLabels || selectedLabels.length == 0) { + return "All Labels"; + } + + if (selectedLabels.length == 1) { + return selectedLabels[0]; + } + + return `${selectedLabels.length} Labels`; + }, [selectedLabels]); + const trigger = ( ); @@ -405,6 +428,22 @@ function ZoneFilterButton({ selectedZones, ); + const buttonText = useMemo(() => { + if (isMobile) { + return "Zones"; + } + + if (!selectedZones || selectedZones.length == 0) { + return "All Zones"; + } + + if (selectedZones.length == 1) { + return selectedZones[0]; + } + + return `${selectedZones.length} Zones`; + }, [selectedZones]); + const trigger = ( ); @@ -585,6 +622,22 @@ function SubFilterButton({ string[] | undefined >(selectedSubLabels); + const buttonText = useMemo(() => { + if (isMobile) { + return "Sub Labels"; + } + + if (!selectedSubLabels || selectedSubLabels.length == 0) { + return "All Sub Labels"; + } + + if (selectedSubLabels.length == 1) { + return selectedSubLabels[0]; + } + + return `${selectedSubLabels.length} Sub Labels`; + }, [selectedSubLabels]); + const trigger = ( ); @@ -745,17 +796,34 @@ export function SubFilterContent({ } type SearchTypeButtonProps = { - selectedSearchSources: SearchSource[]; - updateSearchSourceFilter: (sources: SearchSource[]) => void; + selectedSearchSources: SearchSource[] | undefined; + updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void; }; function SearchTypeButton({ selectedSearchSources, updateSearchSourceFilter, }: SearchTypeButtonProps) { const [open, setOpen] = useState(false); - const [currentSearchSources, setCurrentSearchSources] = useState< - SearchSource[] - >(selectedSearchSources); + + const buttonText = useMemo(() => { + if (isMobile) { + return "Sources"; + } + + if ( + !selectedSearchSources || + selectedSearchSources.length == 0 || + selectedSearchSources.length == 2 + ) { + return "All Search Sources"; + } + + if (selectedSearchSources.length == 1) { + return selectedSearchSources[0]; + } + + return `${selectedSearchSources.length} Search Sources`; + }, [selectedSearchSources]); const trigger = ( ); const content = ( setOpen(false)} /> @@ -790,10 +854,6 @@ function SearchTypeButton({ { - if (!open) { - setCurrentSearchSources(selectedSearchSources); - } - setOpen(open); }} > @@ -809,10 +869,6 @@ function SearchTypeButton({ { - if (!open) { - setCurrentSearchSources(selectedSearchSources); - } - setOpen(open); }} > @@ -823,26 +879,26 @@ function SearchTypeButton({ } type SearchTypeContentProps = { - selectedSearchSources: SearchSource[]; - currentSearchSources: SearchSource[]; - setCurrentSearchSources: (sources: SearchSource[]) => void; - updateSearchSourceFilter: (sources: SearchSource[]) => void; + selectedSearchSources: SearchSource[] | undefined; + updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void; onClose: () => void; }; export function SearchTypeContent({ selectedSearchSources, - currentSearchSources, - setCurrentSearchSources, updateSearchSourceFilter, onClose, }: SearchTypeContentProps) { + const [currentSearchSources, setCurrentSearchSources] = useState< + SearchSource[] | undefined + >(selectedSearchSources); + return ( <>
{ const updatedSources = currentSearchSources ? [...currentSearchSources] @@ -897,10 +953,8 @@ export function SearchTypeContent({
); }, diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index d134d7079..25dd79711 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -338,12 +338,7 @@ function EventItem({ { - const similaritySearchParams = new URLSearchParams({ - search_type: "similarity", - event_id: event.id, - }).toString(); - - navigate(`/search?${similaritySearchParams}`); + navigate(`/explore?similarity_search_id=${event.id}`); }} > diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index bec7589db..754712933 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -1,11 +1,4 @@ -import { isDesktop, isIOS } from "react-device-detect"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "../../ui/sheet"; +import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, @@ -20,10 +13,28 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { getIconForLabel } from "@/utils/iconUtil"; import { useApiHost } from "@/api"; import { Button } from "../../ui/button"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; import { toast } from "sonner"; import { Textarea } from "../../ui/textarea"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import useOptimisticState from "@/hooks/use-optimistic-state"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; +import { Event } from "@/types/event"; +import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; +import { baseUrl } from "@/api/baseUrl"; +import { cn } from "@/lib/utils"; + +const SEARCH_TABS = ["details", "Frigate+", "video"] as const; +type SearchTab = (typeof SEARCH_TABS)[number]; type SearchDetailDialogProps = { search?: SearchResult; @@ -39,6 +50,130 @@ export default function SearchDetailDialog({ revalidateOnFocus: false, }); + // tabs + + const [page, setPage] = useState("details"); + const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + + const searchTabs = useMemo(() => { + if (!config || !search) { + return []; + } + + const views = [...SEARCH_TABS]; + + if (!config.plus.enabled || !search.has_snapshot) { + const index = views.indexOf("Frigate+"); + views.splice(index, 1); + } + + // TODO implement + //if (!config.semantic_search.enabled) { + // const index = views.indexOf("similar-calendar"); + // views.splice(index, 1); + // } + + return views; + }, [config, search]); + + if (!search) { + return; + } + + // 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; + + return ( + { + if (!open) { + setSearch(undefined); + } + }} + > + +
+ Tracked Object Details + Tracked object details +
+ +
+ { + if (value) { + setPageToggle(value); + } + }} + > + {Object.values(searchTabs).map((item) => ( + +
{item}
+
+ ))} +
+ +
+
+ {page == "details" && ( + + )} + {page == "Frigate+" && ( + {}} + onEventUploaded={() => { + search.plus_id = "new_upload"; + }} + /> + )} + {page == "video" && } +
+
+ ); +} + +type ObjectDetailsTabProps = { + search: SearchResult; + config?: FrigateConfig; + setSearch: (search: SearchResult | undefined) => void; + setSimilarity?: () => void; +}; +function ObjectDetailsTab({ + search, + config, + setSearch, + setSimilarity, +}: ObjectDetailsTabProps) { const apiHost = useApiHost(); // data @@ -77,8 +212,6 @@ export default function SearchDetailDialog({ } }, [search]); - // api - const updateDescription = useCallback(() => { if (!search) { return; @@ -101,105 +234,97 @@ export default function SearchDetailDialog({ }); }, [desc, search]); - // content - - 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; - return ( - { - if (!open) { - setSearch(undefined); - } - }} - > - -
- Tracked Object Details - Tracked object details -
- {search && ( -
-
-
-
-
Label
-
- {getIconForLabel(search.label, "size-4 text-primary")} - {search.label} - {search.sub_label && ` (${search.sub_label})`} -
-
-
-
Score
-
- {score}%{subLabelScore && ` (${subLabelScore}%)`} -
-
-
-
Camera
-
- {search.camera.replaceAll("_", " ")} -
-
-
-
Timestamp
-
{formattedDate}
-
-
-
- - -
-
-
-
Description
-