mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Add score filter to Explore view (#14397)
* backend score filtering and sorting * score filter frontend * use input for score filtering * use correct score on search thumbnail * add popover to explain top_score * revert sublabel score calc * update filters logic * fix rounding on score * wait until default view is loaded * don't turn button to selected style for similarity searches * clarify language * fix alert dialog buttons to use correct destructive variant * use root level top_score for very old events * better arrangement of thumbnail footer items on smaller screens
This commit is contained in:
parent
edaccd86d6
commit
8173cd7776
@ -45,6 +45,9 @@ class EventsSearchQueryParams(BaseModel):
|
|||||||
before: Optional[float] = None
|
before: Optional[float] = None
|
||||||
time_range: Optional[str] = DEFAULT_TIME_RANGE
|
time_range: Optional[str] = DEFAULT_TIME_RANGE
|
||||||
timezone: Optional[str] = "utc"
|
timezone: Optional[str] = "utc"
|
||||||
|
min_score: Optional[float] = None
|
||||||
|
max_score: Optional[float] = None
|
||||||
|
sort: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class EventsSummaryQueryParams(BaseModel):
|
class EventsSummaryQueryParams(BaseModel):
|
||||||
|
@ -348,6 +348,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
|||||||
search_type = params.search_type
|
search_type = params.search_type
|
||||||
include_thumbnails = params.include_thumbnails
|
include_thumbnails = params.include_thumbnails
|
||||||
limit = params.limit
|
limit = params.limit
|
||||||
|
sort = params.sort
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
cameras = params.cameras
|
cameras = params.cameras
|
||||||
@ -355,6 +356,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
|||||||
zones = params.zones
|
zones = params.zones
|
||||||
after = params.after
|
after = params.after
|
||||||
before = params.before
|
before = params.before
|
||||||
|
min_score = params.min_score
|
||||||
|
max_score = params.max_score
|
||||||
time_range = params.time_range
|
time_range = params.time_range
|
||||||
|
|
||||||
# for similarity search
|
# for similarity search
|
||||||
@ -430,6 +433,14 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
|||||||
if before:
|
if before:
|
||||||
event_filters.append((Event.start_time < before))
|
event_filters.append((Event.start_time < before))
|
||||||
|
|
||||||
|
if min_score is not None and max_score is not None:
|
||||||
|
event_filters.append((Event.data["score"].between(min_score, max_score)))
|
||||||
|
else:
|
||||||
|
if min_score is not None:
|
||||||
|
event_filters.append((Event.data["score"] >= min_score))
|
||||||
|
if max_score is not None:
|
||||||
|
event_filters.append((Event.data["score"] <= max_score))
|
||||||
|
|
||||||
if time_range != DEFAULT_TIME_RANGE:
|
if time_range != DEFAULT_TIME_RANGE:
|
||||||
tz_name = params.timezone
|
tz_name = params.timezone
|
||||||
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
|
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||||
@ -554,11 +565,19 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
|||||||
|
|
||||||
processed_events.append(processed_event)
|
processed_events.append(processed_event)
|
||||||
|
|
||||||
# Sort by search distance if search_results are available, otherwise by start_time
|
# Sort by search distance if search_results are available, otherwise by start_time as default
|
||||||
if search_results:
|
if search_results:
|
||||||
processed_events.sort(key=lambda x: x.get("search_distance", float("inf")))
|
processed_events.sort(key=lambda x: x.get("search_distance", float("inf")))
|
||||||
else:
|
else:
|
||||||
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
if sort == "score_asc":
|
||||||
|
processed_events.sort(key=lambda x: x["score"])
|
||||||
|
elif sort == "score_desc":
|
||||||
|
processed_events.sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
elif sort == "date_asc":
|
||||||
|
processed_events.sort(key=lambda x: x["start_time"])
|
||||||
|
else:
|
||||||
|
# "date_desc" default
|
||||||
|
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
||||||
|
|
||||||
# Limit the number of events returned
|
# Limit the number of events returned
|
||||||
processed_events = processed_events[:limit]
|
processed_events = processed_events[:limit]
|
||||||
|
@ -34,6 +34,7 @@ import { toast } from "sonner";
|
|||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||||
|
import { buttonVariants } from "../ui/button";
|
||||||
|
|
||||||
type ReviewCardProps = {
|
type ReviewCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
@ -228,7 +229,10 @@ export default function ReviewCard({
|
|||||||
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
|
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@ -295,7 +299,10 @@ export default function ReviewCard({
|
|||||||
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
|
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
@ -90,8 +90,10 @@ export default function SearchThumbnail({
|
|||||||
onClick={() => onClick(searchResult)}
|
onClick={() => onClick(searchResult)}
|
||||||
>
|
>
|
||||||
{getIconForLabel(objectLabel, "size-3 text-white")}
|
{getIconForLabel(objectLabel, "size-3 text-white")}
|
||||||
{Math.floor(
|
{Math.round(
|
||||||
searchResult.score ?? searchResult.data.top_score * 100,
|
(searchResult.data.score ??
|
||||||
|
searchResult.data.top_score ??
|
||||||
|
searchResult.top_score) * 100,
|
||||||
)}
|
)}
|
||||||
%
|
%
|
||||||
</Chip>
|
</Chip>
|
||||||
|
@ -32,9 +32,12 @@ import axios from "axios";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MdImageSearch } from "react-icons/md";
|
import { MdImageSearch } from "react-icons/md";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
import { isMobileOnly } from "react-device-detect";
|
||||||
|
import { buttonVariants } from "../ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SearchThumbnailProps = {
|
type SearchThumbnailProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
|
columns: number;
|
||||||
findSimilar: () => void;
|
findSimilar: () => void;
|
||||||
refreshResults: () => void;
|
refreshResults: () => void;
|
||||||
showObjectLifecycle: () => void;
|
showObjectLifecycle: () => void;
|
||||||
@ -42,6 +45,7 @@ type SearchThumbnailProps = {
|
|||||||
|
|
||||||
export default function SearchThumbnailFooter({
|
export default function SearchThumbnailFooter({
|
||||||
searchResult,
|
searchResult,
|
||||||
|
columns,
|
||||||
findSimilar,
|
findSimilar,
|
||||||
refreshResults,
|
refreshResults,
|
||||||
showObjectLifecycle,
|
showObjectLifecycle,
|
||||||
@ -95,7 +99,7 @@ export default function SearchThumbnailFooter({
|
|||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive"
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@ -113,104 +117,112 @@ export default function SearchThumbnailFooter({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-start text-xs text-primary-variant">
|
<div
|
||||||
{searchResult.end_time ? (
|
className={cn(
|
||||||
<TimeAgo time={searchResult.start_time * 1000} dense />
|
"flex w-full flex-row items-center justify-between",
|
||||||
) : (
|
columns > 4 &&
|
||||||
<div>
|
"items-start sm:flex-col sm:gap-2 lg:flex-row lg:items-center lg:gap-1",
|
||||||
<ActivityIndicator size={14} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{formattedDate}
|
>
|
||||||
</div>
|
<div className="flex flex-col items-start text-xs text-primary-variant">
|
||||||
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
|
{searchResult.end_time ? (
|
||||||
{!isMobileOnly &&
|
<TimeAgo time={searchResult.start_time * 1000} dense />
|
||||||
config?.plus?.enabled &&
|
) : (
|
||||||
searchResult.has_snapshot &&
|
<div>
|
||||||
searchResult.end_time &&
|
<ActivityIndicator size={14} />
|
||||||
!searchResult.plus_id && (
|
</div>
|
||||||
|
)}
|
||||||
|
{formattedDate}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center justify-end gap-6 md:gap-4">
|
||||||
|
{!isMobileOnly &&
|
||||||
|
config?.plus?.enabled &&
|
||||||
|
searchResult.has_snapshot &&
|
||||||
|
searchResult.end_time &&
|
||||||
|
!searchResult.plus_id && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<FrigatePlusIcon
|
||||||
|
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
||||||
|
onClick={() => setShowFrigatePlus(true)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Submit to Frigate+</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config?.semantic_search?.enabled && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<FrigatePlusIcon
|
<MdImageSearch
|
||||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
||||||
onClick={() => setShowFrigatePlus(true)}
|
onClick={findSimilar}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Submit to Frigate+</TooltipContent>
|
<TooltipContent>Find similar</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config?.semantic_search?.enabled && (
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<DropdownMenuTrigger>
|
||||||
<TooltipTrigger>
|
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
|
||||||
<MdImageSearch
|
</DropdownMenuTrigger>
|
||||||
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
<DropdownMenuContent align={"end"}>
|
||||||
onClick={findSimilar}
|
{searchResult.has_clip && (
|
||||||
/>
|
<DropdownMenuItem>
|
||||||
</TooltipTrigger>
|
<a
|
||||||
<TooltipContent>Find similar</TooltipContent>
|
className="justify_start flex items-center"
|
||||||
</Tooltip>
|
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
||||||
)}
|
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
||||||
|
>
|
||||||
<DropdownMenu>
|
<LuDownload className="mr-2 size-4" />
|
||||||
<DropdownMenuTrigger>
|
<span>Download video</span>
|
||||||
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
|
</a>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align={"end"}>
|
|
||||||
{searchResult.has_clip && (
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<a
|
|
||||||
className="justify_start flex items-center"
|
|
||||||
href={`${baseUrl}api/events/${searchResult.id}/clip.mp4`}
|
|
||||||
download={`${searchResult.camera}_${searchResult.label}.mp4`}
|
|
||||||
>
|
|
||||||
<LuDownload className="mr-2 size-4" />
|
|
||||||
<span>Download video</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{searchResult.has_snapshot && (
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<a
|
|
||||||
className="justify_start flex items-center"
|
|
||||||
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
|
||||||
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
|
||||||
>
|
|
||||||
<LuCamera className="mr-2 size-4" />
|
|
||||||
<span>Download snapshot</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={showObjectLifecycle}
|
|
||||||
>
|
|
||||||
<FaArrowsRotate className="mr-2 size-4" />
|
|
||||||
<span>View object lifecycle</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{isMobileOnly &&
|
|
||||||
config?.plus?.enabled &&
|
|
||||||
searchResult.has_snapshot &&
|
|
||||||
searchResult.end_time &&
|
|
||||||
!searchResult.plus_id && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => setShowFrigatePlus(true)}
|
|
||||||
>
|
|
||||||
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
|
|
||||||
<span>Submit to Frigate+</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
{searchResult.has_snapshot && (
|
||||||
className="cursor-pointer"
|
<DropdownMenuItem>
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
<a
|
||||||
>
|
className="justify_start flex items-center"
|
||||||
<LuTrash2 className="mr-2 size-4" />
|
href={`${baseUrl}api/events/${searchResult.id}/snapshot.jpg`}
|
||||||
<span>Delete</span>
|
download={`${searchResult.camera}_${searchResult.label}.jpg`}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DropdownMenuContent>
|
<LuCamera className="mr-2 size-4" />
|
||||||
</DropdownMenu>
|
<span>Download snapshot</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={showObjectLifecycle}
|
||||||
|
>
|
||||||
|
<FaArrowsRotate className="mr-2 size-4" />
|
||||||
|
<span>View object lifecycle</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{isMobileOnly &&
|
||||||
|
config?.plus?.enabled &&
|
||||||
|
searchResult.has_snapshot &&
|
||||||
|
searchResult.end_time &&
|
||||||
|
!searchResult.plus_id && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setShowFrigatePlus(true)}
|
||||||
|
>
|
||||||
|
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
|
||||||
|
<span>Submit to Frigate+</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="mr-2 size-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -3,7 +3,7 @@ import { isDesktop, isMobile } from "react-device-detect";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||||
import { Button } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||||
@ -518,7 +518,10 @@ export function CameraGroupRow({
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={onDeleteGroup}>
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={onDeleteGroup}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { FaCircleCheck } from "react-icons/fa6";
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Button } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { FaCompactDisc } from "react-icons/fa";
|
import { FaCompactDisc } from "react-icons/fa";
|
||||||
import { HiTrash } from "react-icons/hi";
|
import { HiTrash } from "react-icons/hi";
|
||||||
@ -79,7 +79,10 @@ export default function ReviewActionGroup({
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { buttonVariants } from "../ui/button";
|
||||||
|
|
||||||
type DeleteSearchDialogProps = {
|
type DeleteSearchDialogProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -35,7 +36,7 @@ export function DeleteSearchDialog({
|
|||||||
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="bg-destructive text-white"
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
@ -201,10 +201,13 @@ export default function InputWithTags({
|
|||||||
allSuggestions[type as FilterType]?.includes(value) ||
|
allSuggestions[type as FilterType]?.includes(value) ||
|
||||||
type == "before" ||
|
type == "before" ||
|
||||||
type == "after" ||
|
type == "after" ||
|
||||||
type == "time_range"
|
type == "time_range" ||
|
||||||
|
type == "min_score" ||
|
||||||
|
type == "max_score"
|
||||||
) {
|
) {
|
||||||
const newFilters = { ...filters };
|
const newFilters = { ...filters };
|
||||||
let timestamp = 0;
|
let timestamp = 0;
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "before":
|
case "before":
|
||||||
@ -244,6 +247,40 @@ export default function InputWithTags({
|
|||||||
newFilters[type] = timestamp / 1000;
|
newFilters[type] = timestamp / 1000;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "min_score":
|
||||||
|
case "max_score":
|
||||||
|
score = parseInt(value);
|
||||||
|
if (score >= 0) {
|
||||||
|
// Check for conflicts between min_score and max_score
|
||||||
|
if (
|
||||||
|
type === "min_score" &&
|
||||||
|
filters.max_score !== undefined &&
|
||||||
|
score > filters.max_score * 100
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"The 'min_score' must be less than or equal to the 'max_score'.",
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type === "max_score" &&
|
||||||
|
filters.min_score !== undefined &&
|
||||||
|
score < filters.min_score * 100
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
"The 'max_score' must be greater than or equal to the 'min_score'.",
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newFilters[type] = score / 100;
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "time_range":
|
case "time_range":
|
||||||
newFilters[type] = value;
|
newFilters[type] = value;
|
||||||
break;
|
break;
|
||||||
@ -302,6 +339,8 @@ export default function InputWithTags({
|
|||||||
} - ${
|
} - ${
|
||||||
config?.ui.time_format === "24hour" ? endTime : convertTo12Hour(endTime)
|
config?.ui.time_format === "24hour" ? endTime : convertTo12Hour(endTime)
|
||||||
}`;
|
}`;
|
||||||
|
} else if (filterType === "min_score" || filterType === "max_score") {
|
||||||
|
return Math.round(Number(filterValues) * 100).toString() + "%";
|
||||||
} else {
|
} else {
|
||||||
return filterValues as string;
|
return filterValues as string;
|
||||||
}
|
}
|
||||||
@ -320,7 +359,11 @@ export default function InputWithTags({
|
|||||||
isValidTimeRange(
|
isValidTimeRange(
|
||||||
trimmedValue.replace("-", ","),
|
trimmedValue.replace("-", ","),
|
||||||
config?.ui.time_format,
|
config?.ui.time_format,
|
||||||
))
|
)) ||
|
||||||
|
((filterType === "min_score" || filterType === "max_score") &&
|
||||||
|
!isNaN(Number(trimmedValue)) &&
|
||||||
|
Number(trimmedValue) >= 50 &&
|
||||||
|
Number(trimmedValue) <= 100)
|
||||||
) {
|
) {
|
||||||
createFilter(
|
createFilter(
|
||||||
filterType,
|
filterType,
|
||||||
|
@ -62,6 +62,12 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||||
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
||||||
import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer";
|
import { GenericVideoPlayer } from "@/components/player/GenericVideoPlayer";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { LuInfo } from "react-icons/lu";
|
||||||
|
|
||||||
const SEARCH_TABS = [
|
const SEARCH_TABS = [
|
||||||
"details",
|
"details",
|
||||||
@ -279,7 +285,7 @@ function ObjectDetailsTab({
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = search.score ?? search.data.top_score;
|
const value = search.data.top_score;
|
||||||
|
|
||||||
return Math.round(value * 100);
|
return Math.round(value * 100);
|
||||||
}, [search]);
|
}, [search]);
|
||||||
@ -369,7 +375,24 @@ function ObjectDetailsTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="text-sm text-primary/40">Score</div>
|
<div className="text-sm text-primary/40">
|
||||||
|
<div className="flex flex-row items-center gap-1">
|
||||||
|
Top Score
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div className="cursor-pointer p-0">
|
||||||
|
<LuInfo className="size-4" />
|
||||||
|
<span className="sr-only">Info</span>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
The top score is the highest median score for the tracked
|
||||||
|
object, so this may differ from the score shown on the
|
||||||
|
search result thumbnail.
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{score}%{subLabelScore && ` (${subLabelScore}%)`}
|
{score}%{subLabelScore && ` (${subLabelScore}%)`}
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,6 +23,8 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DualThumbSlider } from "@/components/ui/slider";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type SearchFilterDialogProps = {
|
type SearchFilterDialogProps = {
|
||||||
config?: FrigateConfig;
|
config?: FrigateConfig;
|
||||||
@ -46,6 +48,12 @@ export default function SearchFilterDialog({
|
|||||||
const [currentFilter, setCurrentFilter] = useState(filter ?? {});
|
const [currentFilter, setCurrentFilter] = useState(filter ?? {});
|
||||||
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
|
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filter) {
|
||||||
|
setCurrentFilter(filter);
|
||||||
|
}
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
// state
|
// state
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -54,9 +62,12 @@ export default function SearchFilterDialog({
|
|||||||
() =>
|
() =>
|
||||||
currentFilter &&
|
currentFilter &&
|
||||||
(currentFilter.time_range ||
|
(currentFilter.time_range ||
|
||||||
|
(currentFilter.min_score ?? 0) > 0.5 ||
|
||||||
|
(currentFilter.max_score ?? 1) < 1 ||
|
||||||
(currentFilter.zones?.length ?? 0) > 0 ||
|
(currentFilter.zones?.length ?? 0) > 0 ||
|
||||||
(currentFilter.sub_labels?.length ?? 0) > 0 ||
|
(currentFilter.sub_labels?.length ?? 0) > 0 ||
|
||||||
(currentFilter.search_type?.length ?? 2) !== 2),
|
(!currentFilter.search_type?.includes("similarity") &&
|
||||||
|
(currentFilter.search_type?.length ?? 2) !== 2)),
|
||||||
[currentFilter],
|
[currentFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -97,6 +108,13 @@ export default function SearchFilterDialog({
|
|||||||
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
|
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ScoreFilterContent
|
||||||
|
minScore={currentFilter.min_score}
|
||||||
|
maxScore={currentFilter.max_score}
|
||||||
|
setScoreRange={(min, max) =>
|
||||||
|
setCurrentFilter({ ...currentFilter, min_score: min, max_score: max })
|
||||||
|
}
|
||||||
|
/>
|
||||||
{config?.semantic_search?.enabled &&
|
{config?.semantic_search?.enabled &&
|
||||||
!currentFilter?.search_type?.includes("similarity") && (
|
!currentFilter?.search_type?.includes("similarity") && (
|
||||||
<SearchTypeContent
|
<SearchTypeContent
|
||||||
@ -133,6 +151,8 @@ export default function SearchFilterDialog({
|
|||||||
zones: undefined,
|
zones: undefined,
|
||||||
sub_labels: undefined,
|
sub_labels: undefined,
|
||||||
search_type: ["thumbnail", "description"],
|
search_type: ["thumbnail", "description"],
|
||||||
|
min_score: undefined,
|
||||||
|
max_score: undefined,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -420,6 +440,58 @@ export function SubFilterContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScoreFilterContentProps = {
|
||||||
|
minScore: number | undefined;
|
||||||
|
maxScore: number | undefined;
|
||||||
|
setScoreRange: (min: number | undefined, max: number | undefined) => void;
|
||||||
|
};
|
||||||
|
export function ScoreFilterContent({
|
||||||
|
minScore,
|
||||||
|
maxScore,
|
||||||
|
setScoreRange,
|
||||||
|
}: ScoreFilterContentProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden">
|
||||||
|
<DropdownMenuSeparator className="mb-3" />
|
||||||
|
<div className="mb-3 text-lg">Score</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
className="w-12"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={Math.round((minScore ?? 0.5) * 100)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
setScoreRange(parseInt(value) / 100.0, maxScore ?? 1.0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DualThumbSlider
|
||||||
|
className="w-full"
|
||||||
|
min={0.5}
|
||||||
|
max={1.0}
|
||||||
|
step={0.01}
|
||||||
|
value={[minScore ?? 0.5, maxScore ?? 1.0]}
|
||||||
|
onValueChange={([min, max]) => setScoreRange(min, max)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="w-12"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={Math.round((maxScore ?? 1.0) * 100)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
setScoreRange(minScore ?? 0.5, parseInt(value) / 100.0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type SearchTypeContentProps = {
|
type SearchTypeContentProps = {
|
||||||
searchSources: SearchSource[] | undefined;
|
searchSources: SearchSource[] | undefined;
|
||||||
setSearchSources: (sources: SearchSource[] | undefined) => void;
|
setSearchSources: (sources: SearchSource[] | undefined) => void;
|
||||||
|
@ -35,6 +35,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
import { reviewQueries } from "@/utils/zoneEdutUtil";
|
||||||
import IconWrapper from "../ui/icon-wrapper";
|
import IconWrapper from "../ui/icon-wrapper";
|
||||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
|
import { buttonVariants } from "../ui/button";
|
||||||
|
|
||||||
type PolygonItemProps = {
|
type PolygonItemProps = {
|
||||||
polygon: Polygon;
|
polygon: Polygon;
|
||||||
@ -257,7 +258,10 @@ export default function PolygonItem({
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete}>
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop, isMobileOnly } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
||||||
import { FaCog } from "react-icons/fa";
|
import { FaCog } from "react-icons/fa";
|
||||||
@ -40,7 +40,7 @@ export default function SearchSettings({
|
|||||||
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
|
<div className={cn(className, "my-3 space-y-5 py-3 md:mt-0 md:py-0")}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-md">Default Search View</div>
|
<div className="text-md">Default View</div>
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
When no filters are selected, display a summary of the most recent
|
When no filters are selected, display a summary of the most recent
|
||||||
tracked objects per label, or display an unfiltered grid.
|
tracked objects per label, or display an unfiltered grid.
|
||||||
@ -68,26 +68,32 @@ export default function SearchSettings({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
{!isMobileOnly && (
|
||||||
<div className="flex w-full flex-col space-y-4">
|
<>
|
||||||
<div className="space-y-0.5">
|
<DropdownMenuSeparator />
|
||||||
<div className="text-md">Grid Columns</div>
|
<div className="flex w-full flex-col space-y-4">
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
<div className="space-y-0.5">
|
||||||
Select the number of columns in the results grid.
|
<div className="text-md">Grid Columns</div>
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
Select the number of columns in the grid view.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Slider
|
||||||
|
value={[columns]}
|
||||||
|
onValueChange={([value]) => setColumns(value)}
|
||||||
|
max={6}
|
||||||
|
min={2}
|
||||||
|
step={1}
|
||||||
|
className="flex-grow"
|
||||||
|
/>
|
||||||
|
<span className="w-9 text-center text-sm font-medium">
|
||||||
|
{columns}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<div className="flex items-center space-x-4">
|
)}
|
||||||
<Slider
|
|
||||||
value={[columns]}
|
|
||||||
onValueChange={([value]) => setColumns(value)}
|
|
||||||
max={6}
|
|
||||||
min={2}
|
|
||||||
step={1}
|
|
||||||
className="flex-grow"
|
|
||||||
/>
|
|
||||||
<span className="w-9 text-center text-sm font-medium">{columns}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ import { ModelState } from "@/types/ws";
|
|||||||
import { formatSecondsToDuration } from "@/utils/dateUtil";
|
import { formatSecondsToDuration } from "@/utils/dateUtil";
|
||||||
import SearchView from "@/views/search/SearchView";
|
import SearchView from "@/views/search/SearchView";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { isMobileOnly } from "react-device-detect";
|
||||||
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||||
import { TbExclamationCircle } from "react-icons/tb";
|
import { TbExclamationCircle } from "react-icons/tb";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@ -32,11 +33,16 @@ export default function Explore() {
|
|||||||
// grid
|
// grid
|
||||||
|
|
||||||
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
||||||
const gridColumns = useMemo(() => columnCount ?? 4, [columnCount]);
|
const gridColumns = useMemo(() => {
|
||||||
|
if (isMobileOnly) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return columnCount ?? 4;
|
||||||
|
}, [columnCount]);
|
||||||
|
|
||||||
// default layout
|
// default layout
|
||||||
|
|
||||||
const [defaultView, setDefaultView] = usePersistence(
|
const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence(
|
||||||
"exploreDefaultView",
|
"exploreDefaultView",
|
||||||
"summary",
|
"summary",
|
||||||
);
|
);
|
||||||
@ -103,6 +109,8 @@ export default function Explore() {
|
|||||||
after: searchSearchParams["after"],
|
after: searchSearchParams["after"],
|
||||||
time_range: searchSearchParams["time_range"],
|
time_range: searchSearchParams["time_range"],
|
||||||
search_type: searchSearchParams["search_type"],
|
search_type: searchSearchParams["search_type"],
|
||||||
|
min_score: searchSearchParams["min_score"],
|
||||||
|
max_score: searchSearchParams["max_score"],
|
||||||
limit:
|
limit:
|
||||||
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
|
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
|
||||||
timezone,
|
timezone,
|
||||||
@ -129,6 +137,8 @@ export default function Explore() {
|
|||||||
after: searchSearchParams["after"],
|
after: searchSearchParams["after"],
|
||||||
time_range: searchSearchParams["time_range"],
|
time_range: searchSearchParams["time_range"],
|
||||||
search_type: searchSearchParams["search_type"],
|
search_type: searchSearchParams["search_type"],
|
||||||
|
min_score: searchSearchParams["min_score"],
|
||||||
|
max_score: searchSearchParams["max_score"],
|
||||||
event_id: searchSearchParams["event_id"],
|
event_id: searchSearchParams["event_id"],
|
||||||
timezone,
|
timezone,
|
||||||
include_thumbnails: 0,
|
include_thumbnails: 0,
|
||||||
@ -270,12 +280,13 @@ export default function Explore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config?.semantic_search.enabled &&
|
!defaultViewLoaded ||
|
||||||
(!reindexState ||
|
(config?.semantic_search.enabled &&
|
||||||
!textModelState ||
|
(!reindexState ||
|
||||||
!textTokenizerState ||
|
!textModelState ||
|
||||||
!visionModelState ||
|
!textTokenizerState ||
|
||||||
!visionFeatureExtractorState)
|
!visionModelState ||
|
||||||
|
!visionFeatureExtractorState))
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<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" />
|
||||||
|
@ -35,6 +35,7 @@ export type SearchResult = {
|
|||||||
zones: string[];
|
zones: string[];
|
||||||
search_source: SearchSource;
|
search_source: SearchSource;
|
||||||
search_distance: number;
|
search_distance: number;
|
||||||
|
top_score: number; // for old events
|
||||||
data: {
|
data: {
|
||||||
top_score: number;
|
top_score: number;
|
||||||
score: number;
|
score: number;
|
||||||
@ -56,6 +57,8 @@ export type SearchFilter = {
|
|||||||
zones?: string[];
|
zones?: string[];
|
||||||
before?: number;
|
before?: number;
|
||||||
after?: number;
|
after?: number;
|
||||||
|
min_score?: number;
|
||||||
|
max_score?: number;
|
||||||
time_range?: string;
|
time_range?: string;
|
||||||
search_type?: SearchSource[];
|
search_type?: SearchSource[];
|
||||||
event_id?: string;
|
event_id?: string;
|
||||||
@ -71,6 +74,8 @@ export type SearchQueryParams = {
|
|||||||
zones?: string[];
|
zones?: string[];
|
||||||
before?: string;
|
before?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
|
min_score?: number;
|
||||||
|
max_score?: number;
|
||||||
search_type?: string;
|
search_type?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
in_progress?: number;
|
in_progress?: number;
|
||||||
|
@ -144,6 +144,8 @@ export default function SearchView({
|
|||||||
: ["12:00AM-11:59PM"],
|
: ["12:00AM-11:59PM"],
|
||||||
before: [formatDateToLocaleString()],
|
before: [formatDateToLocaleString()],
|
||||||
after: [formatDateToLocaleString(-5)],
|
after: [formatDateToLocaleString(-5)],
|
||||||
|
min_score: ["50"],
|
||||||
|
max_score: ["100"],
|
||||||
}),
|
}),
|
||||||
[config, allLabels, allZones, allSubLabels],
|
[config, allLabels, allZones, allSubLabels],
|
||||||
);
|
);
|
||||||
@ -385,7 +387,7 @@ export default function SearchView({
|
|||||||
key={value.id}
|
key={value.id}
|
||||||
ref={(item) => (itemRefs.current[index] = item)}
|
ref={(item) => (itemRefs.current[index] = item)}
|
||||||
data-start={value.start_time}
|
data-start={value.start_time}
|
||||||
className="review-item relative rounded-lg"
|
className="review-item relative flex flex-col rounded-lg"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -400,9 +402,10 @@ export default function SearchView({
|
|||||||
<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-selected outline-selected` : "outline-transparent duration-500"}`}
|
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-selected outline-selected` : "outline-transparent duration-500"}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex w-full items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
|
<div className="flex w-full grow items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
|
||||||
<SearchThumbnailFooter
|
<SearchThumbnailFooter
|
||||||
searchResult={value}
|
searchResult={value}
|
||||||
|
columns={columns}
|
||||||
findSimilar={() => {
|
findSimilar={() => {
|
||||||
if (config?.semantic_search.enabled) {
|
if (config?.semantic_search.enabled) {
|
||||||
setSimilaritySearch(value);
|
setSimilaritySearch(value);
|
||||||
|
Loading…
Reference in New Issue
Block a user