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 <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins 2024-10-15 08:24:47 -05:00 committed by GitHub
parent 0eccb6a610
commit 644069fb23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 283 additions and 96 deletions

View File

@ -1,50 +1,56 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import TimeAgo from "../dynamic/TimeAgo";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { isIOS, isSafari } from "react-device-detect"; import { isIOS, isSafari } from "react-device-detect";
import Chip from "@/components/indicators/Chip"; import Chip from "@/components/indicators/Chip";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import ActivityIndicator from "../indicators/activity-indicator";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import useContextMenu from "@/hooks/use-contextmenu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
type SearchThumbnailProps = { type SearchThumbnailProps = {
searchResult: SearchResult; searchResult: SearchResult;
findSimilar: () => void;
onClick: (searchResult: SearchResult) => void; onClick: (searchResult: SearchResult) => void;
}; };
export default function SearchThumbnail({ export default function SearchThumbnail({
searchResult, searchResult,
findSimilar,
onClick, onClick,
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
useContextMenu(imgRef, findSimilar); // interactions
const handleOnClick = useCallback(() => { const handleOnClick = useCallback(() => {
onClick(searchResult); onClick(searchResult);
}, [searchResult, onClick]); }, [searchResult, onClick]);
// date const objectLabel = useMemo(() => {
if (
!config ||
!searchResult.sub_label ||
!config.model.attributes_map[searchResult.label]
) {
return searchResult.label;
}
const formattedDate = useFormattedTimestamp( if (
searchResult.start_time, config.model.attributes_map[searchResult.label].includes(
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", searchResult.sub_label,
config?.ui.timezone, )
); ) {
return searchResult.sub_label;
}
return `${searchResult.label}-verified`;
}, [config, searchResult]);
return ( return (
<div className="relative size-full cursor-pointer" onClick={handleOnClick}> <div className="relative size-full cursor-pointer" onClick={handleOnClick}>
@ -80,17 +86,21 @@ export default function SearchThumbnail({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="mx-3 pb-1 text-sm text-white"> <div className="mx-3 pb-1 text-sm text-white">
<Chip <Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`} className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`}
onClick={() => onClick(searchResult)} onClick={() => onClick(searchResult)}
> >
{getIconForLabel(searchResult.label, "size-3 text-white")} {getIconForLabel(objectLabel, "size-3 text-white")}
{Math.floor(
searchResult.score ?? searchResult.data.top_score * 100,
)}
%
</Chip> </Chip>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipPortal> <TooltipPortal>
<TooltipContent className="capitalize"> <TooltipContent className="capitalize">
{[...new Set([searchResult.label])] {[objectLabel]
.filter( .filter(
(item) => item !== undefined && !item.includes("-verified"), (item) => item !== undefined && !item.includes("-verified"),
) )
@ -103,18 +113,7 @@ export default function SearchThumbnail({
</Tooltip> </Tooltip>
</div> </div>
<div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div> <div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent"> <div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-[20%] items-end bg-gradient-to-t from-black/60 to-transparent"></div>
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
{searchResult.end_time ? (
<TimeAgo time={searchResult.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={24} />
</div>
)}
{formattedDate}
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -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<FrigateConfig>("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 (
<>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete this tracked object?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<FrigatePlusDialog
upload={
showFrigatePlus ? (searchResult as unknown as Event) : undefined
}
onClose={() => setShowFrigatePlus(false)}
onEventUploaded={() => {}}
/>
<div className="flex flex-col items-start">
{searchResult.end_time ? (
<TimeAgo time={searchResult.start_time * 1000} dense />
) : (
<div>
<ActivityIndicator size={24} />
</div>
)}
{formattedDate}
</div>
<div className="flex flex-row items-center justify-end gap-8 md:gap-4">
{config?.plus?.enabled &&
searchResult.has_snapshot &&
searchResult.end_time && (
<Tooltip>
<TooltipTrigger>
<FrigatePlusIcon
className="size-5 cursor-pointer text-primary"
onClick={() => setShowFrigatePlus(true)}
/>
</TooltipTrigger>
<TooltipContent>Submit to Frigate+</TooltipContent>
</Tooltip>
)}
{config?.semantic_search?.enabled && (
<Tooltip>
<TooltipTrigger>
<MdImageSearch
className="size-5 cursor-pointer text-primary"
onClick={findSimilar}
/>
</TooltipTrigger>
<TooltipContent>Find similar</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger>
<LuMoreVertical className="size-5 cursor-pointer text-primary" />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
<DropdownMenuLabel className="mt-0.5">
Tracked Object Actions
</DropdownMenuLabel>
<DropdownMenuSeparator className="mt-1" />
{searchResult.has_clip && (
<DropdownMenuItem>
<a
className="justify_start flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
download={`${searchResult.camera}_${searchResult.label}.mp4`}
>
<LuDownload className="mr-2 size-4" />
<span>Download video</span>
</a>
</DropdownMenuItem>
)}
{searchResult.has_snapshot && (
<DropdownMenuItem>
<a
className="justify_start flex items-center"
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
download={`${searchResult.camera}_${searchResult.label}.jpg`}
>
<LuCamera className="mr-2 size-4" />
<span>Download snapshot</span>
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={showObjectLifecycle}>
<FaArrowsRotate className="mr-2 size-4" />
<span>View object lifecycle</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<LuTrash2 className="mr-2 size-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}

View File

@ -2,7 +2,6 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
import { import {
LuX, LuX,
LuFilter, LuFilter,
LuImage,
LuChevronDown, LuChevronDown,
LuChevronUp, LuChevronUp,
LuTrash2, LuTrash2,
@ -44,6 +43,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md";
type InputWithTagsProps = { type InputWithTagsProps = {
inputFocused: boolean; inputFocused: boolean;
@ -514,7 +514,7 @@ export default function InputWithTags({
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
className="text-md h-9 pr-24" className="text-md h-9 pr-32"
placeholder="Search..." placeholder="Search..."
/> />
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5"> <div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
@ -549,7 +549,7 @@ export default function InputWithTags({
{isSimilaritySearch && ( {isSimilaritySearch && (
<Tooltip> <Tooltip>
<TooltipTrigger className="cursor-default"> <TooltipTrigger className="cursor-default">
<LuImage <MdImageSearch
aria-label="Similarity search active" aria-label="Similarity search active"
className="size-4 text-selected" className="size-4 text-selected"
/> />

View File

@ -69,16 +69,20 @@ const SEARCH_TABS = [
"video", "video",
"object lifecycle", "object lifecycle",
] as const; ] as const;
type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
search?: SearchResult; search?: SearchResult;
page: SearchTab;
setSearch: (search: SearchResult | undefined) => void; setSearch: (search: SearchResult | undefined) => void;
setSearchPage: (page: SearchTab) => void;
setSimilarity?: () => void; setSimilarity?: () => void;
}; };
export default function SearchDetailDialog({ export default function SearchDetailDialog({
search, search,
page,
setSearch, setSearch,
setSearchPage,
setSimilarity, setSimilarity,
}: SearchDetailDialogProps) { }: SearchDetailDialogProps) {
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -87,8 +91,11 @@ export default function SearchDetailDialog({
// tabs // tabs
const [page, setPage] = useState<SearchTab>("details"); const [pageToggle, setPageToggle] = useOptimisticState(
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); page,
setSearchPage,
100,
);
// dialog and mobile page // dialog and mobile page
@ -130,9 +137,9 @@ export default function SearchDetailDialog({
} }
if (!searchTabs.includes(pageToggle)) { if (!searchTabs.includes(pageToggle)) {
setPage("details"); setSearchPage("details");
} }
}, [pageToggle, searchTabs]); }, [pageToggle, searchTabs, setSearchPage]);
if (!search) { if (!search) {
return; return;

View File

@ -384,6 +384,7 @@ export default function Explore() {
searchFilter={searchFilter} searchFilter={searchFilter}
searchResults={searchResults} searchResults={searchResults}
isLoading={(isLoadingInitialData || isLoadingMore) ?? true} isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
hasMore={!isReachingEnd}
setSearch={setSearch} setSearch={setSearch}
setSimilaritySearch={(search) => { setSimilaritySearch={(search) => {
setSearchFilter({ setSearchFilter({
@ -395,7 +396,7 @@ export default function Explore() {
setSearchFilter={setSearchFilter} setSearchFilter={setSearchFilter}
onUpdateFilter={setSearchFilter} onUpdateFilter={setSearchFilter}
loadMore={loadMore} loadMore={loadMore}
hasMore={!isReachingEnd} refresh={mutate}
/> />
)} )}
</> </>

View File

@ -340,6 +340,7 @@ export interface FrigateConfig {
path: string | null; path: string | null;
width: number; width: number;
colormap: { [key: string]: [number, number, number] }; colormap: { [key: string]: [number, number, number] };
attributes_map: { [key: string]: [string] };
}; };
motion: Record<string, unknown> | null; motion: Record<string, unknown> | null;

View File

@ -1,8 +1,9 @@
import SearchThumbnail from "@/components/card/SearchThumbnail"; import SearchThumbnail from "@/components/card/SearchThumbnail";
import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import Chip from "@/components/indicators/Chip"; import SearchDetailDialog, {
import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog"; SearchTab,
} from "@/components/overlay/detail/SearchDetailDialog";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { import {
Tooltip, Tooltip,
@ -14,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchResult, SearchSource } from "@/types/search"; import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isDesktop, isMobileOnly } from "react-device-detect"; 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 useSWR from "swr";
import ExploreView from "../explore/ExploreView"; import ExploreView from "../explore/ExploreView";
import useKeyboardListener, { import useKeyboardListener, {
@ -25,7 +26,6 @@ import InputWithTags from "@/components/input/InputWithTags";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil"; import { formatDateToLocaleString } from "@/utils/dateUtil";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { import {
Popover, Popover,
@ -33,6 +33,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
type SearchViewProps = { type SearchViewProps = {
search: string; search: string;
@ -40,12 +41,13 @@ type SearchViewProps = {
searchFilter?: SearchFilter; searchFilter?: SearchFilter;
searchResults?: SearchResult[]; searchResults?: SearchResult[];
isLoading: boolean; isLoading: boolean;
hasMore: boolean;
setSearch: (search: string) => void; setSearch: (search: string) => void;
setSimilaritySearch: (search: SearchResult) => void; setSimilaritySearch: (search: SearchResult) => void;
setSearchFilter: (filter: SearchFilter) => void; setSearchFilter: (filter: SearchFilter) => void;
onUpdateFilter: (filter: SearchFilter) => void; onUpdateFilter: (filter: SearchFilter) => void;
loadMore: () => void; loadMore: () => void;
hasMore: boolean; refresh: () => void;
}; };
export default function SearchView({ export default function SearchView({
search, search,
@ -53,12 +55,13 @@ export default function SearchView({
searchFilter, searchFilter,
searchResults, searchResults,
isLoading, isLoading,
hasMore,
setSearch, setSearch,
setSimilaritySearch, setSimilaritySearch,
setSearchFilter, setSearchFilter,
onUpdateFilter, onUpdateFilter,
loadMore, loadMore,
hasMore, refresh,
}: SearchViewProps) { }: SearchViewProps) {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
@ -76,8 +79,6 @@ export default function SearchView({
"sm:grid-cols-4": effectiveColumnCount === 4, "sm:grid-cols-4": effectiveColumnCount === 4,
"sm:grid-cols-5": effectiveColumnCount === 5, "sm:grid-cols-5": effectiveColumnCount === 5,
"sm:grid-cols-6": effectiveColumnCount === 6, "sm:grid-cols-6": effectiveColumnCount === 6,
"sm:grid-cols-7": effectiveColumnCount === 7,
"sm:grid-cols-8": effectiveColumnCount >= 8,
}); });
// suggestions values // suggestions values
@ -161,16 +162,25 @@ export default function SearchView({
// detail // detail
const [searchDetail, setSearchDetail] = useState<SearchResult>(); const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("details");
// search interaction // search interaction
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback((item: SearchResult, index: number) => { const onSelectSearch = useCallback(
setSearchDetail(item); (item: SearchResult, index: number, page: SearchTab = "details") => {
setSelectedIndex(index); setPage(page);
}, []); setSearchDetail(item);
setSelectedIndex(index);
},
[],
);
useEffect(() => {
setSelectedIndex(0);
}, [searchTerm, searchFilter]);
// update search detail when results change // update search detail when results change
@ -187,21 +197,6 @@ export default function SearchView({
} }
}, [searchResults, searchDetail]); }, [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( const hasExistingSearch = useMemo(
() => searchResults != undefined || searchFilter != undefined, () => searchResults != undefined || searchFilter != undefined,
[searchResults, searchFilter], [searchResults, searchFilter],
@ -310,7 +305,9 @@ export default function SearchView({
<Toaster closeButton={true} /> <Toaster closeButton={true} />
<SearchDetailDialog <SearchDetailDialog
search={searchDetail} search={searchDetail}
page={page}
setSearch={setSearchDetail} setSearch={setSearchDetail}
setSearchPage={setPage}
setSimilarity={ setSimilarity={
searchDetail && (() => setSimilaritySearch(searchDetail)) searchDetail && (() => setSimilaritySearch(searchDetail))
} }
@ -388,47 +385,31 @@ export default function SearchView({
> >
<div <div
className={cn( className={cn(
"aspect-square size-full overflow-hidden rounded-lg", "aspect-square w-full overflow-hidden rounded-t-lg border",
)} )}
> >
<SearchThumbnail <SearchThumbnail
searchResult={value}
onClick={() => onSelectSearch(value, index)}
/>
</div>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`}
/>
<div className="flex w-full items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
<SearchThumbnailFooter
searchResult={value} searchResult={value}
findSimilar={() => { findSimilar={() => {
if (config?.semantic_search.enabled) { if (config?.semantic_search.enabled) {
setSimilaritySearch(value); setSimilaritySearch(value);
} }
}} }}
onClick={() => onSelectSearch(value, index)} refreshResults={refresh}
showObjectLifecycle={() =>
onSelectSearch(value, index, "object lifecycle")
}
/> />
{(searchTerm ||
searchFilter?.search_type?.includes("similarity")) && (
<div className={cn("absolute right-2 top-2 z-40")}>
<Tooltip>
<TooltipTrigger>
<Chip
className={`flex select-none items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize text-white`}
>
{value.search_source == "thumbnail" ? (
<LuImage className="mr-1 size-3" />
) : (
<LuText className="mr-1 size-3" />
)}
{zScoreToConfidence(value.search_distance)}%
</Chip>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
Matched {value.search_source} at{" "}
{zScoreToConfidence(value.search_distance)}%
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
)}
</div> </div>
<div
className={`review-item-ring pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${selected ? `shadow-selected outline-selected` : "outline-transparent duration-500"}`}
/>
</div> </div>
); );
})} })}
@ -467,7 +448,7 @@ export default function SearchView({
<Slider <Slider
value={[effectiveColumnCount]} value={[effectiveColumnCount]}
onValueChange={([value]) => setColumnCount(value)} onValueChange={([value]) => setColumnCount(value)}
max={8} max={6}
min={2} min={2}
step={1} step={1}
className="flex-grow" className="flex-grow"