mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-14 15:15:22 +03:00
explore view with new api endpoint
This commit is contained in:
parent
52c8386b52
commit
e5e074ab84
@ -251,6 +251,61 @@ def events():
|
|||||||
return jsonify(list(events))
|
return jsonify(list(events))
|
||||||
|
|
||||||
|
|
||||||
|
@EventBp.route("/events/explore")
|
||||||
|
def events_explore():
|
||||||
|
limit = request.args.get("limit", 10, type=int)
|
||||||
|
|
||||||
|
subquery = Event.select(
|
||||||
|
Event.id,
|
||||||
|
Event.camera,
|
||||||
|
Event.label,
|
||||||
|
Event.zones,
|
||||||
|
Event.start_time,
|
||||||
|
Event.end_time,
|
||||||
|
Event.has_clip,
|
||||||
|
Event.has_snapshot,
|
||||||
|
Event.plus_id,
|
||||||
|
Event.retain_indefinitely,
|
||||||
|
Event.sub_label,
|
||||||
|
Event.top_score,
|
||||||
|
Event.false_positive,
|
||||||
|
Event.box,
|
||||||
|
Event.data,
|
||||||
|
fn.rank()
|
||||||
|
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
|
||||||
|
.alias("rank"),
|
||||||
|
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count"),
|
||||||
|
).alias("subquery")
|
||||||
|
|
||||||
|
query = (
|
||||||
|
Event.select(
|
||||||
|
subquery.c.id,
|
||||||
|
subquery.c.camera,
|
||||||
|
subquery.c.label,
|
||||||
|
subquery.c.zones,
|
||||||
|
subquery.c.start_time,
|
||||||
|
subquery.c.end_time,
|
||||||
|
subquery.c.has_clip,
|
||||||
|
subquery.c.has_snapshot,
|
||||||
|
subquery.c.plus_id,
|
||||||
|
subquery.c.retain_indefinitely,
|
||||||
|
subquery.c.sub_label,
|
||||||
|
subquery.c.top_score,
|
||||||
|
subquery.c.false_positive,
|
||||||
|
subquery.c.box,
|
||||||
|
subquery.c.data,
|
||||||
|
subquery.c.event_count,
|
||||||
|
)
|
||||||
|
.from_(subquery)
|
||||||
|
.where(subquery.c.rank <= limit)
|
||||||
|
.order_by(subquery.c.event_count.desc(), subquery.c.start_time.desc())
|
||||||
|
.dicts()
|
||||||
|
)
|
||||||
|
|
||||||
|
events = query.iterator()
|
||||||
|
return jsonify(list(events))
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/event_ids")
|
@EventBp.route("/event_ids")
|
||||||
def event_ids():
|
def event_ids():
|
||||||
idString = request.args.get("ids")
|
idString = request.args.get("ids")
|
||||||
|
|||||||
@ -179,15 +179,6 @@ export default function SearchFilterGroup({
|
|||||||
updateSelectedRange={onUpdateSelectedRange}
|
updateSelectedRange={onUpdateSelectedRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filters.includes("general") && (
|
|
||||||
<GeneralFilterButton
|
|
||||||
allLabels={filterValues.labels}
|
|
||||||
selectedLabels={filter?.labels}
|
|
||||||
updateLabelFilter={(newLabels) => {
|
|
||||||
onUpdateFilter({ ...filter, labels: newLabels });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters.includes("zone") && allZones.length > 0 && (
|
{filters.includes("zone") && allZones.length > 0 && (
|
||||||
<ZoneFilterButton
|
<ZoneFilterButton
|
||||||
allZones={filterValues.zones}
|
allZones={filterValues.zones}
|
||||||
@ -197,6 +188,15 @@ export default function SearchFilterGroup({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{filters.includes("general") && (
|
||||||
|
<GeneralFilterButton
|
||||||
|
allLabels={filterValues.labels}
|
||||||
|
selectedLabels={filter?.labels}
|
||||||
|
updateLabelFilter={(newLabels) => {
|
||||||
|
onUpdateFilter({ ...filter, labels: newLabels });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{filters.includes("sub") && (
|
{filters.includes("sub") && (
|
||||||
<SubFilterButton
|
<SubFilterButton
|
||||||
allSubLabels={allSubLabels}
|
allSubLabels={allSubLabels}
|
||||||
@ -247,7 +247,7 @@ function GeneralFilterButton({
|
|||||||
<div
|
<div
|
||||||
className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
|
className={`hidden md:block ${selectedLabels?.length ? "text-selected-foreground" : "text-primary"}`}
|
||||||
>
|
>
|
||||||
Filter
|
All Labels
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import { RecordingView } from "@/views/recording/RecordingView";
|
import { RecordingView } from "@/views/recording/RecordingView";
|
||||||
import SearchView from "@/views/search/SearchView";
|
import SearchView from "@/views/search/SearchView";
|
||||||
import ExploreView from "@/views/explore/ExploreView";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@ -119,21 +118,25 @@ export default function Explore() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
if (searchSearchParams && Object.keys(searchSearchParams).length !== 0) {
|
||||||
"events",
|
return [
|
||||||
{
|
"events",
|
||||||
cameras: searchSearchParams["cameras"],
|
{
|
||||||
labels: searchSearchParams["labels"],
|
cameras: searchSearchParams["cameras"],
|
||||||
sub_labels: searchSearchParams["subLabels"],
|
labels: searchSearchParams["labels"],
|
||||||
zones: searchSearchParams["zones"],
|
sub_labels: searchSearchParams["subLabels"],
|
||||||
before: searchSearchParams["before"],
|
zones: searchSearchParams["zones"],
|
||||||
after: searchSearchParams["after"],
|
before: searchSearchParams["before"],
|
||||||
search_type: searchSearchParams["search_type"],
|
after: searchSearchParams["after"],
|
||||||
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
|
search_type: searchSearchParams["search_type"],
|
||||||
in_progress: 0,
|
limit: Object.keys(searchSearchParams).length == 0 ? 20 : null,
|
||||||
include_thumbnails: 0,
|
in_progress: 0,
|
||||||
},
|
include_thumbnails: 0,
|
||||||
];
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}, [searchTerm, searchSearchParams, similaritySearch]);
|
}, [searchTerm, searchSearchParams, similaritySearch]);
|
||||||
|
|
||||||
const { data: searchResults, isLoading } =
|
const { data: searchResults, isLoading } =
|
||||||
@ -225,31 +228,19 @@ export default function Explore() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (
|
return (
|
||||||
search ||
|
<SearchView
|
||||||
similaritySearch ||
|
search={search}
|
||||||
(searchFilter && Object.keys(searchFilter).length != 0)
|
searchTerm={searchTerm}
|
||||||
) {
|
searchFilter={searchFilter}
|
||||||
return (
|
searchResults={searchResults}
|
||||||
<SearchView
|
isLoading={isLoading}
|
||||||
search={search}
|
setSearch={setSearch}
|
||||||
searchTerm={searchTerm}
|
similaritySearch={similaritySearch}
|
||||||
searchFilter={searchFilter}
|
setSimilaritySearch={setSimilaritySearch}
|
||||||
searchResults={searchResults}
|
onUpdateFilter={onUpdateFilter}
|
||||||
isLoading={isLoading}
|
onOpenSearch={onOpenSearch}
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,242 +0,0 @@
|
|||||||
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 { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import useSWR from "swr";
|
|
||||||
|
|
||||||
export default function Search() {
|
|
||||||
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 {
|
|
||||||
return (
|
|
||||||
<SearchView
|
|
||||||
search={search}
|
|
||||||
searchTerm={searchTerm}
|
|
||||||
searchFilter={searchFilter}
|
|
||||||
searchResults={searchResults}
|
|
||||||
isLoading={isLoading}
|
|
||||||
setSearch={setSearch}
|
|
||||||
similaritySearch={similaritySearch}
|
|
||||||
setSimilaritySearch={setSimilaritySearch}
|
|
||||||
onUpdateFilter={onUpdateFilter}
|
|
||||||
onOpenSearch={onOpenSearch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { Event } from "@/types/event";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { isIOS } from "react-device-detect";
|
import { isIOS, isMobileOnly } from "react-device-detect";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -12,8 +11,13 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
import { SearchResult } from "@/types/search";
|
||||||
|
|
||||||
export default function ImageAccordion() {
|
type ExploreViewProps = {
|
||||||
|
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExploreView({ onSelectSearch }: ExploreViewProps) {
|
||||||
// title
|
// title
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -22,11 +26,11 @@ export default function ImageAccordion() {
|
|||||||
|
|
||||||
// data
|
// data
|
||||||
|
|
||||||
const { data: events } = useSWR<Event[]>(
|
const { data: events } = useSWR<SearchResult[]>(
|
||||||
[
|
[
|
||||||
"events",
|
"events/explore",
|
||||||
{
|
{
|
||||||
limit: 100,
|
limit: isMobileOnly ? 5 : 10,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
@ -36,7 +40,7 @@ export default function ImageAccordion() {
|
|||||||
|
|
||||||
const eventsByLabel = useMemo(() => {
|
const eventsByLabel = useMemo(() => {
|
||||||
if (!events) return {};
|
if (!events) return {};
|
||||||
return events.reduce<Record<string, Event[]>>((acc, event) => {
|
return events.reduce<Record<string, SearchResult[]>>((acc, event) => {
|
||||||
const label = event.label || "Unknown";
|
const label = event.label || "Unknown";
|
||||||
if (!acc[label]) {
|
if (!acc[label]) {
|
||||||
acc[label] = [];
|
acc[label] = [];
|
||||||
@ -49,19 +53,28 @@ export default function ImageAccordion() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4 overflow-x-hidden p-2">
|
<div className="space-y-4 overflow-x-hidden p-2">
|
||||||
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
|
{Object.entries(eventsByLabel).map(([label, filteredEvents]) => (
|
||||||
<ThumbnailRow key={label} events={filteredEvents} objectType={label} />
|
<ThumbnailRow
|
||||||
|
key={label}
|
||||||
|
searchResults={filteredEvents}
|
||||||
|
objectType={label}
|
||||||
|
onSelectSearch={onSelectSearch}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThumbnailRowType = {
|
||||||
|
objectType: string;
|
||||||
|
searchResults?: SearchResult[];
|
||||||
|
onSelectSearch: (searchResult: SearchResult, detail: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function ThumbnailRow({
|
function ThumbnailRow({
|
||||||
objectType,
|
objectType,
|
||||||
events,
|
searchResults,
|
||||||
}: {
|
onSelectSearch,
|
||||||
objectType: string;
|
}: ThumbnailRowType) {
|
||||||
events?: Event[];
|
|
||||||
}) {
|
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
@ -74,20 +87,34 @@ function ThumbnailRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg bg-background_alt p-2 md:p-4">
|
||||||
<h2 className="text-lg capitalize">{objectType}</h2>
|
<div className="text-lg capitalize">
|
||||||
<div className="flex flex-row items-center space-x-2 p-2">
|
{objectType.replaceAll("_", " ")}
|
||||||
{events?.map((event, index) => (
|
{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, index) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="relative aspect-square h-[50px] w-full max-w-[50px] md:h-[120px] md:max-w-[120px]"
|
className="relative aspect-square h-auto max-w-[20%] flex-grow md:max-w-[10%]"
|
||||||
onMouseEnter={() => setHoveredIndex(index)}
|
onMouseEnter={() => setHoveredIndex(index)}
|
||||||
onMouseLeave={() => setHoveredIndex(null)}
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute h-full w-full rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl",
|
"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",
|
hoveredIndex === index
|
||||||
|
? "z-10 scale-110 cursor-pointer"
|
||||||
|
: "scale-100",
|
||||||
)}
|
)}
|
||||||
style={
|
style={
|
||||||
isIOS
|
isIOS
|
||||||
@ -98,12 +125,9 @@ function ThumbnailRow({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={
|
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`}
|
||||||
event.has_snapshot
|
|
||||||
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
|
||||||
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
|
||||||
}
|
|
||||||
alt={`${objectType} snapshot`}
|
alt={`${objectType} snapshot`}
|
||||||
|
onClick={() => onSelectSearch(event, true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -114,13 +138,13 @@ function ThumbnailRow({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<LuArrowRightCircle
|
<LuArrowRightCircle
|
||||||
className="text-secondary-foreground"
|
className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="capitalize">
|
||||||
Explore More {objectType}s
|
<ExploreMoreLink objectType={objectType} />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -129,3 +153,12 @@ function ThumbnailRow({
|
|||||||
</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>;
|
||||||
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import { isMobileOnly } from "react-device-detect";
|
import { isMobileOnly } from "react-device-detect";
|
||||||
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
|
import { LuImage, LuSearchX, LuText, LuXCircle } from "react-icons/lu";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import ExploreView from "../explore/ExploreView";
|
||||||
|
|
||||||
type SearchViewProps = {
|
type SearchViewProps = {
|
||||||
search: string;
|
search: string;
|
||||||
@ -109,7 +110,7 @@ export default function SearchView({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative mb-2 flex h-11 items-center pl-2 pr-2 md:pl-3",
|
"relative flex h-11 items-center pl-2 pr-2 md:pl-3",
|
||||||
config?.semantic_search?.enabled
|
config?.semantic_search?.enabled
|
||||||
? "justify-between"
|
? "justify-between"
|
||||||
: "justify-center",
|
: "justify-center",
|
||||||
@ -120,7 +121,7 @@ export default function SearchView({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full",
|
"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
|
<Input
|
||||||
@ -161,66 +162,73 @@ export default function SearchView({
|
|||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<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 &&
|
<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.map((value) => {
|
{uniqueResults &&
|
||||||
const selected = false;
|
uniqueResults.map((value) => {
|
||||||
|
const selected = false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
key={value.id}
|
|
||||||
data-start={value.start_time}
|
|
||||||
className="review-item relative rounded-lg"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
key={value.id}
|
||||||
"aspect-square size-full overflow-hidden rounded-lg",
|
data-start={value.start_time}
|
||||||
)}
|
className="review-item relative rounded-lg"
|
||||||
>
|
>
|
||||||
<SearchThumbnail
|
<div
|
||||||
searchResult={value}
|
className={cn(
|
||||||
scrollLock={false}
|
"aspect-square size-full overflow-hidden rounded-lg",
|
||||||
findSimilar={() => setSimilaritySearch(value)}
|
)}
|
||||||
onClick={onSelectSearch}
|
>
|
||||||
/>
|
<SearchThumbnail
|
||||||
{(searchTerm || similaritySearch) && (
|
searchResult={value}
|
||||||
<div className={cn("absolute right-2 top-2 z-40")}>
|
scrollLock={false}
|
||||||
<Tooltip>
|
findSimilar={() => setSimilaritySearch(value)}
|
||||||
<TooltipTrigger>
|
onClick={onSelectSearch}
|
||||||
<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`}
|
{(searchTerm || similaritySearch) && (
|
||||||
>
|
<div className={cn("absolute right-2 top-2 z-40")}>
|
||||||
{value.search_source == "thumbnail" ? (
|
<Tooltip>
|
||||||
<LuImage className="mr-1 size-3" />
|
<TooltipTrigger>
|
||||||
) : (
|
<Chip
|
||||||
<LuText className="mr-1 size-3" />
|
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(
|
{zScoreToConfidence(
|
||||||
value.search_distance,
|
value.search_distance,
|
||||||
value.search_source,
|
value.search_source,
|
||||||
)}
|
)}
|
||||||
%
|
%
|
||||||
</Chip>
|
</TooltipContent>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
<TooltipContent>
|
</div>
|
||||||
Matched {value.search_source} at{" "}
|
)}
|
||||||
{zScoreToConfidence(
|
</div>
|
||||||
value.search_distance,
|
<div
|
||||||
value.search_source,
|
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"}`}
|
||||||
)}
|
/>
|
||||||
%
|
|
||||||
</TooltipContent>
|
|
||||||
</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-severity_alert outline-severity_alert` : "outline-transparent duration-500"}`}
|
})}
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
{!uniqueResults && !isLoading && (
|
||||||
})}
|
<div className="flex size-full flex-col">
|
||||||
</div>
|
<ExploreView onSelectSearch={onSelectSearch} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user