From 7bc4c493d9b436385d046a81600aa0777b24cb36 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 10 Sep 2024 14:47:22 -0600 Subject: [PATCH] Refactor search hovering to give information --- web/src/components/card/SearchThumbnail.tsx | 302 +++++++++++++++++ .../player/SearchThumbnailPlayer.tsx | 308 ------------------ web/src/views/search/SearchView.tsx | 6 +- 3 files changed, 305 insertions(+), 311 deletions(-) create mode 100644 web/src/components/card/SearchThumbnail.tsx delete mode 100644 web/src/components/player/SearchThumbnailPlayer.tsx diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx new file mode 100644 index 000000000..58efd4737 --- /dev/null +++ b/web/src/components/card/SearchThumbnail.tsx @@ -0,0 +1,302 @@ +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"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Button } from "../ui/button"; + +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, () => { + onClick(searchResult, true); + }); + + // 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 ( + { + if (!open) { + setDetails(false); + } + }} + > + +
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} +
+
+
+ + + +
+
+
+ ); +} + +type SearchDetailProps = { + search?: SearchResult; + findSimilar: () => void; +}; +function SearchDetails({ search, findSimilar }: SearchDetailProps) { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + + const apiHost = useApiHost(); + + // data + + const formattedDate = useFormattedTimestamp( + search?.start_time ?? 0, + config?.ui.time_format == "24hour" + ? "%b %-d %Y, %H:%M" + : "%b %-d %Y, %I:%M %p", + ); + + const score = useMemo(() => { + if (!search) { + return 0; + } + + const value = search.score ?? search.data.top_score; + + return Math.round(value * 100); + }, [search]); + + const subLabelScore = useMemo(() => { + if (!search) { + return undefined; + } + + if (search.sub_label) { + return Math.round((search.data?.top_score ?? 0) * 100); + } else { + return undefined; + } + }, [search]); + + if (!search) { + return; + } + + return ( +
+
+
+
+
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}
+
+
+
+ + +
+
+
+ ); +} diff --git a/web/src/components/player/SearchThumbnailPlayer.tsx b/web/src/components/player/SearchThumbnailPlayer.tsx deleted file mode 100644 index 9b54086da..000000000 --- a/web/src/components/player/SearchThumbnailPlayer.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { useApiHost } from "@/api"; -import { isCurrentHour } from "@/utils/dateUtil"; -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 { InProgressPreview, VideoPreview } from "../preview/ScrubbablePreview"; -import { Preview } from "@/types/preview"; -import { SearchResult } from "@/types/search"; -import useContextMenu from "@/hooks/use-contextmenu"; -import { cn } from "@/lib/utils"; - -type SearchPlayerProps = { - searchResult: SearchResult; - allPreviews?: Preview[]; - scrollLock?: boolean; - onClick: (searchResult: SearchResult, detail: boolean) => void; -}; - -export default function SearchThumbnailPlayer({ - searchResult, - allPreviews, - scrollLock = false, - onClick, -}: SearchPlayerProps) { - const apiHost = useApiHost(); - const { data: config } = useSWR("config"); - const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); - - // interaction - - const [ignoreClick, setIgnoreClick] = useState(false); - const handleOnClick = useCallback( - (e: React.MouseEvent) => { - if (!ignoreClick) { - onClick(searchResult, e.metaKey); - } - }, - [ignoreClick, searchResult, onClick], - ); - - const swipeHandlers = useSwipeable({ - onSwipedLeft: () => setPlayback(false), - onSwipedRight: () => setPlayback(true), - preventScrollOnSwipe: true, - }); - - useContextMenu(imgRef, () => { - onClick(searchResult, true); - }); - - // playback - - const relevantPreview = useMemo(() => { - if (!allPreviews) { - return undefined; - } - - let multiHour = false; - const firstIndex = Object.values(allPreviews).findIndex((preview) => { - if ( - preview.camera != searchResult.camera || - preview.end < searchResult.start_time - ) { - return false; - } - - if ((searchResult.end_time ?? Date.now() / 1000) > preview.end) { - multiHour = true; - } - - return true; - }); - - if (firstIndex == -1) { - return undefined; - } - - if (!multiHour) { - return allPreviews[firstIndex]; - } - - const firstPrev = allPreviews[firstIndex]; - const firstDuration = firstPrev.end - searchResult.start_time; - const secondDuration = - (searchResult.end_time ?? Date.now() / 1000) - firstPrev.end; - - if (firstDuration > secondDuration) { - // the first preview is longer than the second, return the first - return firstPrev; - } else { - // the second preview is longer, return the second if it exists - if (firstIndex < allPreviews.length - 1) { - return allPreviews.find( - (preview, idx) => - idx > firstIndex && preview.camera == searchResult.camera, - ); - } - - return undefined; - } - }, [allPreviews, searchResult]); - - // Hover Playback - - const [hoverTimeout, setHoverTimeout] = useState(); - const [playback, setPlayback] = useState(false); - const [tooltipHovering, setTooltipHovering] = useState(false); - const playingBack = useMemo( - () => playback && !tooltipHovering, - [playback, tooltipHovering], - ); - const [isHovered, setIsHovered] = useState(false); - - useEffect(() => { - if (isHovered && scrollLock) { - return; - } - - if (isHovered && !tooltipHovering) { - setHoverTimeout( - setTimeout(() => { - setPlayback(true); - setHoverTimeout(null); - }, 500), - ); - } else { - if (hoverTimeout) { - clearTimeout(hoverTimeout); - } - - setPlayback(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} - > - {playingBack && ( -
- -
- )} - -
- { - 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", "")} - -
-
- {!playingBack && ( - <> -
-
-
- {searchResult.end_time ? ( - - ) : ( -
- -
- )} - {formattedDate} -
-
- - )} -
-
- ); -} - -type PreviewContentProps = { - searchResult: SearchResult; - relevantPreview: Preview | undefined; - setIgnoreClick: (ignore: boolean) => void; - isPlayingBack: (ended: boolean) => void; - onTimeUpdate?: (time: number | undefined) => void; -}; -function PreviewContent({ - searchResult, - relevantPreview, - setIgnoreClick, - isPlayingBack, - onTimeUpdate, -}: PreviewContentProps) { - // preview - const now = useMemo(() => Date.now() / 1000, []); - - if (relevantPreview) { - return ( - {}} - /> - ); - } else if (isCurrentHour(searchResult.start_time)) { - return ( - {}} - /> - ); - } -} diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 391deceb6..44bb8e8d6 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -1,8 +1,8 @@ +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 SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; import { @@ -184,10 +184,10 @@ export default function SearchView({ "aspect-square size-full overflow-hidden rounded-lg", )} > - setSimilaritySearch(value)} onClick={onSelectSearch} /> {(searchTerm || similaritySearch) && (