Rename Search to Explore

This commit is contained in:
Josh Hawkins 2024-09-10 17:21:39 -05:00
parent d4cdd218f9
commit e9a8c42734
4 changed files with 395 additions and 6 deletions

View File

@ -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() {
<Route index element={<Live />} />
<Route path="/events" element={<Redirect to="/review" />} />
<Route path="/review" element={<Events />} />
<Route path="/search" element={<Search />} />
<Route path="/explore" element={<Explore />} />
<Route path="/export" element={<Exports />} />
<Route path="/plus" element={<SubmitPlus />} />
<Route path="/system" element={<System />} />

View File

@ -11,7 +11,7 @@ import useSWR from "swr";
export const ID_LIVE = 1;
export const ID_REVIEW = 2;
export const ID_SEARCH = 3;
export const ID_EXPLORE = 3;
export const ID_EXPORT = 4;
export const ID_PLUS = 5;
export const ID_PLAYGROUND = 6;
@ -41,11 +41,11 @@ export default function useNavigation(
url: "/review",
},
{
id: ID_SEARCH,
id: ID_EXPLORE,
variant,
icon: IoSearch,
title: "Search",
url: "/search",
title: "Explore",
url: "/explore",
},
{
id: ID_EXPORT,

255
web/src/pages/Explore.tsx Normal file
View File

@ -0,0 +1,255 @@
import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig";
import { RecordingStartingPoint } from "@/types/record";
import {
PartialSearchResult,
SearchFilter,
SearchResult,
} from "@/types/search";
import { TimeRange } from "@/types/timeline";
import { RecordingView } from "@/views/recording/RecordingView";
import SearchView from "@/views/search/SearchView";
import ExploreView from "@/views/explore/ExploreView";
import { useCallback, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
export default function Explore() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
// search field handler
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
const [search, setSearch] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const [recording, setRecording] =
useOverlayState<RecordingStartingPoint>("recording");
// search filter
const [searchFilter, setSearchFilter, searchSearchParams] =
useApiFilterArgs<SearchFilter>();
const onUpdateFilter = useCallback(
(newFilter: SearchFilter) => {
setSearchFilter(newFilter);
},
[setSearchFilter],
);
// search api
const [similaritySearch, setSimilaritySearch] =
useState<PartialSearchResult>();
useEffect(() => {
if (
config?.semantic_search.enabled &&
searchSearchParams["search_type"] == "similarity" &&
searchSearchParams["event_id"]?.length != 0 &&
searchFilter
) {
setSimilaritySearch({
id: searchSearchParams["event_id"],
});
// remove event id from url params
const { event_id: _event_id, ...newFilter } = searchFilter;
setSearchFilter(newFilter);
}
// only run similarity search with event_id in the url when coming from review
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (similaritySearch) {
setSimilaritySearch(undefined);
}
if (searchTimeout) {
clearTimeout(searchTimeout);
}
setSearchTimeout(
setTimeout(() => {
setSearchTimeout(undefined);
setSearchTerm(search);
}, 750),
);
// we only want to update the searchTerm when search changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
const searchQuery = useMemo(() => {
if (similaritySearch) {
return [
"events/search",
{
query: similaritySearch.id,
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
include_thumbnails: 0,
search_type: "similarity",
},
];
}
if (searchTerm) {
return [
"events/search",
{
query: searchTerm,
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
include_thumbnails: 0,
},
];
}
return [
"events",
{
cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["subLabels"],
zones: searchSearchParams["zones"],
before: searchSearchParams["before"],
after: searchSearchParams["after"],
search_type: searchSearchParams["search_type"],
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
in_progress: 0,
include_thumbnails: 0,
},
];
}, [searchTerm, searchSearchParams, similaritySearch]);
const { data: searchResults, isLoading } =
useSWR<SearchResult[]>(searchQuery);
const previewTimeRange = useMemo<TimeRange>(() => {
if (!searchResults) {
return { after: 0, before: 0 };
}
return {
after: Math.min(...searchResults.map((res) => res.start_time)),
before: Math.max(
...searchResults.map((res) => res.end_time ?? Date.now() / 1000),
),
};
}, [searchResults]);
const allPreviews = useCameraPreviews(previewTimeRange, {
autoRefresh: false,
fetchPreviews: searchResults != undefined,
});
// selection
const onOpenSearch = useCallback(
(item: SearchResult) => {
setRecording({
camera: item.camera,
startTime: item.start_time,
severity: "alert",
});
},
[setRecording],
);
const selectedReviewData = useMemo(() => {
if (!recording) {
return undefined;
}
if (!config) {
return undefined;
}
if (!searchResults) {
return undefined;
}
const allCameras = searchFilter?.cameras ?? Object.keys(config.cameras);
return {
camera: recording.camera,
start_time: recording.startTime,
allCameras: allCameras,
};
// previews will not update after item is selected
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recording, searchResults]);
const selectedTimeRange = useMemo(() => {
if (!recording) {
return undefined;
}
const time = new Date(recording.startTime * 1000);
time.setUTCMinutes(0, 0, 0);
const start = time.getTime() / 1000;
time.setHours(time.getHours() + 2);
const end = time.getTime() / 1000;
return {
after: start,
before: end,
};
}, [recording]);
if (recording) {
if (selectedReviewData && selectedTimeRange) {
return (
<RecordingView
startCamera={selectedReviewData.camera}
startTime={selectedReviewData.start_time}
allCameras={selectedReviewData.allCameras}
allPreviews={allPreviews}
timeRange={selectedTimeRange}
updateFilter={onUpdateFilter}
/>
);
}
} else {
if (
search ||
similaritySearch ||
(searchFilter && Object.keys(searchFilter).length != 0)
) {
return (
<SearchView
search={search}
searchTerm={searchTerm}
searchFilter={searchFilter}
searchResults={searchResults}
isLoading={isLoading}
setSearch={setSearch}
similaritySearch={similaritySearch}
setSimilaritySearch={setSimilaritySearch}
onUpdateFilter={onUpdateFilter}
onOpenSearch={onOpenSearch}
/>
);
} else {
return (
<div className="flex size-full flex-col pt-2 md:py-2">
<ExploreView />
</div>
);
}
}
}

View File

@ -0,0 +1,134 @@
import { Event } from "@/types/event";
import { useEffect, useMemo, useState } from "react";
import { isIOS } 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";
export default function ImageAccordion() {
// title
useEffect(() => {
document.title = "Explore - Frigate";
}, []);
// data
const { data: events } = useSWR<Event[]>(
[
"events",
{
limit: 100,
},
],
{
revalidateOnFocus: false,
},
);
const eventsByLabel = useMemo(() => {
if (!events) return {};
return events.reduce<Record<string, Event[]>>((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} events={filteredEvents} objectType={label} />
))}
</div>
);
}
function ThumbnailRow({
objectType,
events,
}: {
objectType: string;
events?: Event[];
}) {
const apiHost = useApiHost();
const navigate = useNavigate();
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const handleSimilaritySearch = (eventId: string) => {
const similaritySearchParams = new URLSearchParams({
search_type: "similarity",
event_id: eventId,
}).toString();
navigate(`/explore?${similaritySearchParams}`);
};
return (
<div className="space-y-2">
<h2 className="text-lg capitalize">{objectType}</h2>
<div className="flex flex-row items-center space-x-2 p-2">
{events?.map((event, index) => (
<div
key={event.id}
className="relative aspect-square h-[50px] w-full max-w-[50px] md:h-[120px] md:max-w-[120px]"
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
>
<img
className={cn(
"absolute h-full w-full rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl",
hoveredIndex === index ? "z-10 scale-110" : "scale-100",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
}
alt={`${objectType} snapshot`}
/>
</div>
))}
<div
className="flex cursor-pointer items-center justify-center"
onClick={() =>
events &&
events.length > 0 &&
handleSimilaritySearch(events[events.length - 1].id)
}
>
<Tooltip>
<TooltipTrigger>
<LuArrowRightCircle
className="text-secondary-foreground"
size={24}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>View More</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>
</div>
);
}