mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
0eccb6a610
commit
644069fb23
@ -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>
|
||||||
);
|
);
|
||||||
|
198
web/src/components/card/SearchThumbnailFooter.tsx
Normal file
198
web/src/components/card/SearchThumbnailFooter.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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;
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user