mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 15:15:22 +03:00
Rename Search to Explore
This commit is contained in:
parent
d4cdd218f9
commit
e9a8c42734
@ -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 />} />
|
||||
|
||||
@ -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
255
web/src/pages/Explore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
web/src/views/explore/ExploreView.tsx
Normal file
134
web/src/views/explore/ExploreView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user