forked from Github/frigate
Refactor search details into Explore Page (#13665)
This commit is contained in:
158
web/src/views/explore/ExploreView.tsx
Normal file
158
web/src/views/explore/ExploreView.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { isIOS, isMobileOnly } 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";
|
||||
import { SearchResult } from "@/types/search";
|
||||
|
||||
type ExploreViewProps = {
|
||||
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
|
||||
// title
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Explore - Frigate";
|
||||
}, []);
|
||||
|
||||
// data
|
||||
|
||||
const { data: events } = useSWR<SearchResult[]>(
|
||||
[
|
||||
"events/explore",
|
||||
{
|
||||
limit: isMobileOnly ? 5 : 10,
|
||||
},
|
||||
],
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const eventsByLabel = useMemo(() => {
|
||||
if (!events) return {};
|
||||
return events.reduce<Record<string, SearchResult[]>>((acc, event) => {
|
||||
const label = event.label || "Unknown";
|
||||
if (!acc[label]) {
|
||||
acc[label] = [];
|
||||
}
|
||||
acc[label].push(event);
|
||||
return acc;
|
||||
}, {});
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-x-hidden p-2">
|
||||
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
|
||||
<ThumbnailRow
|
||||
key={label}
|
||||
searchResults={filteredEvents}
|
||||
objectType={label}
|
||||
onSelectSearch={onSelectSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ThumbnailRowType = {
|
||||
objectType: string;
|
||||
searchResults?: SearchResult[];
|
||||
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
|
||||
};
|
||||
|
||||
function ThumbnailRow({
|
||||
objectType,
|
||||
searchResults,
|
||||
onSelectSearch,
|
||||
}: ThumbnailRowType) {
|
||||
const apiHost = useApiHost();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (label: string) => {
|
||||
const similaritySearchParams = new URLSearchParams({
|
||||
labels: label,
|
||||
}).toString();
|
||||
navigate(`/explore?${similaritySearchParams}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-background_alt p-2 md:p-4">
|
||||
<div className="text-lg capitalize">
|
||||
{objectType.replaceAll("_", " ")}
|
||||
{searchResults && (
|
||||
<span className="ml-3 text-sm text-secondary-foreground">
|
||||
(
|
||||
{
|
||||
// @ts-expect-error we know this is correct
|
||||
searchResults[0].event_count
|
||||
}{" "}
|
||||
detected objects){" "}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-2 py-2">
|
||||
{searchResults?.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="relative aspect-square h-auto max-w-[20%] flex-grow md:max-w-[10%]"
|
||||
>
|
||||
<img
|
||||
className={cn(
|
||||
"absolute h-full w-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl",
|
||||
)}
|
||||
style={
|
||||
isIOS
|
||||
? {
|
||||
WebkitUserSelect: "none",
|
||||
WebkitTouchCallout: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
|
||||
alt={`${objectType} snapshot`}
|
||||
onClick={() => onSelectSearch(event, true)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-center"
|
||||
onClick={() => handleSearch(objectType)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LuArrowRightCircle
|
||||
className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
|
||||
size={24}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="capitalize">
|
||||
<ExploreMoreLink objectType={objectType} />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExploreMoreLink({ objectType }: { objectType: string }) {
|
||||
const formattedType = objectType.replaceAll("_", " ");
|
||||
const label = formattedType.endsWith("s")
|
||||
? `${formattedType}es`
|
||||
: `${formattedType}s`;
|
||||
|
||||
return <div>Explore More {label}</div>;
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Preview } from "@/types/preview";
|
||||
import {
|
||||
PartialSearchResult,
|
||||
SearchFilter,
|
||||
@@ -22,13 +21,13 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { isMobileOnly } from "react-device-detect";
|
||||
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
|
||||
import useSWR from "swr";
|
||||
import ExploreView from "../explore/ExploreView";
|
||||
|
||||
type SearchViewProps = {
|
||||
search: string;
|
||||
searchTerm: string;
|
||||
searchFilter?: SearchFilter;
|
||||
searchResults?: SearchResult[];
|
||||
allPreviews?: Preview[];
|
||||
isLoading: boolean;
|
||||
similaritySearch?: PartialSearchResult;
|
||||
setSearch: (search: string) => void;
|
||||
@@ -41,13 +40,11 @@ export default function SearchView({
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
searchResults,
|
||||
allPreviews,
|
||||
isLoading,
|
||||
similaritySearch,
|
||||
setSearch,
|
||||
setSimilaritySearch,
|
||||
onUpdateFilter,
|
||||
onOpenSearch,
|
||||
}: SearchViewProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -68,16 +65,13 @@ export default function SearchView({
|
||||
|
||||
// search interaction
|
||||
|
||||
const onSelectSearch = useCallback(
|
||||
(item: SearchResult, detail: boolean) => {
|
||||
if (detail) {
|
||||
setSearchDetail(item);
|
||||
} else {
|
||||
onOpenSearch(item);
|
||||
}
|
||||
},
|
||||
[onOpenSearch],
|
||||
);
|
||||
const onSelectSearch = useCallback((item: SearchResult, detail: boolean) => {
|
||||
if (detail) {
|
||||
setSearchDetail(item);
|
||||
} else {
|
||||
setSearchDetail(item);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// confidence score - probably needs tweaking
|
||||
|
||||
@@ -116,25 +110,23 @@ export default function SearchView({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative mb-2 flex h-11 items-center pl-2 pr-2 md:pl-3",
|
||||
"flex h-11 items-center pl-2 pr-2 md:pl-3",
|
||||
config?.semantic_search?.enabled
|
||||
? "justify-between"
|
||||
: "justify-center",
|
||||
isMobileOnly && "h-[88px] flex-wrap gap-2",
|
||||
isMobileOnly && "mb-3 h-auto flex-wrap gap-2",
|
||||
)}
|
||||
>
|
||||
{config?.semantic_search?.enabled && (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
hasExistingSearch ? "mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2",
|
||||
hasExistingSearch ? "md:mr-3 md:w-1/3" : "md:ml-[25%] md:w-1/2",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
className="text-md w-full bg-muted pr-10"
|
||||
placeholder={
|
||||
isMobileOnly ? "Search" : "Search for a detected object..."
|
||||
}
|
||||
placeholder={"Search for a detected object..."}
|
||||
value={similaritySearch ? "" : search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
@@ -168,66 +160,73 @@ export default function SearchView({
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
|
||||
<div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
|
||||
{uniqueResults &&
|
||||
uniqueResults.map((value) => {
|
||||
const selected = false;
|
||||
{uniqueResults && (
|
||||
<div className="mt-2 grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
|
||||
{uniqueResults &&
|
||||
uniqueResults.map((value) => {
|
||||
const selected = false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={value.id}
|
||||
data-start={value.start_time}
|
||||
className="review-item relative rounded-lg"
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"aspect-square size-full overflow-hidden rounded-lg",
|
||||
)}
|
||||
key={value.id}
|
||||
data-start={value.start_time}
|
||||
className="review-item relative rounded-lg"
|
||||
>
|
||||
<SearchThumbnailPlayer
|
||||
searchResult={value}
|
||||
allPreviews={allPreviews}
|
||||
scrollLock={false}
|
||||
onClick={onSelectSearch}
|
||||
/>
|
||||
{(searchTerm || similaritySearch) && (
|
||||
<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" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"aspect-square size-full overflow-hidden rounded-lg",
|
||||
)}
|
||||
>
|
||||
<SearchThumbnail
|
||||
searchResult={value}
|
||||
scrollLock={false}
|
||||
findSimilar={() => setSimilaritySearch(value)}
|
||||
onClick={onSelectSearch}
|
||||
/>
|
||||
{(searchTerm || similaritySearch) && (
|
||||
<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,
|
||||
value.search_source,
|
||||
)}
|
||||
%
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Matched {value.search_source} at{" "}
|
||||
{zScoreToConfidence(
|
||||
value.search_distance,
|
||||
value.search_source,
|
||||
)}
|
||||
%
|
||||
</Chip>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Matched {value.search_source} at{" "}
|
||||
{zScoreToConfidence(
|
||||
value.search_distance,
|
||||
value.search_source,
|
||||
)}
|
||||
%
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
</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-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!uniqueResults && !isLoading && (
|
||||
<div className="flex size-full flex-col">
|
||||
<ExploreView onSelectSearch={onSelectSearch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user