mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 23:25:25 +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 Live = lazy(() => import("@/pages/Live"));
|
||||||
const Events = lazy(() => import("@/pages/Events"));
|
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 Exports = lazy(() => import("@/pages/Exports"));
|
||||||
const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
|
const SubmitPlus = lazy(() => import("@/pages/SubmitPlus"));
|
||||||
const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
|
const ConfigEditor = lazy(() => import("@/pages/ConfigEditor"));
|
||||||
@ -45,7 +45,7 @@ function App() {
|
|||||||
<Route index element={<Live />} />
|
<Route index element={<Live />} />
|
||||||
<Route path="/events" element={<Redirect to="/review" />} />
|
<Route path="/events" element={<Redirect to="/review" />} />
|
||||||
<Route path="/review" element={<Events />} />
|
<Route path="/review" element={<Events />} />
|
||||||
<Route path="/search" element={<Search />} />
|
<Route path="/explore" element={<Explore />} />
|
||||||
<Route path="/export" element={<Exports />} />
|
<Route path="/export" element={<Exports />} />
|
||||||
<Route path="/plus" element={<SubmitPlus />} />
|
<Route path="/plus" element={<SubmitPlus />} />
|
||||||
<Route path="/system" element={<System />} />
|
<Route path="/system" element={<System />} />
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import useSWR from "swr";
|
|||||||
|
|
||||||
export const ID_LIVE = 1;
|
export const ID_LIVE = 1;
|
||||||
export const ID_REVIEW = 2;
|
export const ID_REVIEW = 2;
|
||||||
export const ID_SEARCH = 3;
|
export const ID_EXPLORE = 3;
|
||||||
export const ID_EXPORT = 4;
|
export const ID_EXPORT = 4;
|
||||||
export const ID_PLUS = 5;
|
export const ID_PLUS = 5;
|
||||||
export const ID_PLAYGROUND = 6;
|
export const ID_PLAYGROUND = 6;
|
||||||
@ -41,11 +41,11 @@ export default function useNavigation(
|
|||||||
url: "/review",
|
url: "/review",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ID_SEARCH,
|
id: ID_EXPLORE,
|
||||||
variant,
|
variant,
|
||||||
icon: IoSearch,
|
icon: IoSearch,
|
||||||
title: "Search",
|
title: "Explore",
|
||||||
url: "/search",
|
url: "/explore",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ID_EXPORT,
|
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