mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-15 15:45:27 +03:00
Merge branch 'dev' into updated-documentation
This commit is contained in:
commit
39afee784b
@ -357,6 +357,7 @@ def create_user(request: Request, body: AppPostUsersBody):
|
|||||||
{
|
{
|
||||||
User.username: body.username,
|
User.username: body.username,
|
||||||
User.password_hash: password_hash,
|
User.password_hash: password_hash,
|
||||||
|
User.notification_tokens: [],
|
||||||
}
|
}
|
||||||
).execute()
|
).execute()
|
||||||
return JSONResponse(content={"username": body.username})
|
return JSONResponse(content={"username": body.username})
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import os
|
|||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path as FilePath
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@ -450,8 +451,27 @@ def recording_clip(
|
|||||||
camera_name: str,
|
camera_name: str,
|
||||||
start_ts: float,
|
start_ts: float,
|
||||||
end_ts: float,
|
end_ts: float,
|
||||||
download: bool = False,
|
|
||||||
):
|
):
|
||||||
|
def run_download(ffmpeg_cmd: list[str], file_path: str):
|
||||||
|
with sp.Popen(
|
||||||
|
ffmpeg_cmd,
|
||||||
|
stderr=sp.PIPE,
|
||||||
|
stdout=sp.PIPE,
|
||||||
|
text=False,
|
||||||
|
) as ffmpeg:
|
||||||
|
while True:
|
||||||
|
data = ffmpeg.stdout.read(1024)
|
||||||
|
if data is not None:
|
||||||
|
yield data
|
||||||
|
else:
|
||||||
|
if ffmpeg.returncode and ffmpeg.returncode != 0:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
FilePath(file_path).unlink(missing_ok=True)
|
||||||
|
break
|
||||||
|
|
||||||
recordings = (
|
recordings = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.path,
|
Recordings.path,
|
||||||
@ -467,18 +487,18 @@ def recording_clip(
|
|||||||
.order_by(Recordings.start_time.asc())
|
.order_by(Recordings.start_time.asc())
|
||||||
)
|
)
|
||||||
|
|
||||||
playlist_lines = []
|
file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
|
||||||
clip: Recordings
|
file_path = f"/tmp/cache/{file_name}"
|
||||||
for clip in recordings:
|
with open(file_path, "w") as file:
|
||||||
playlist_lines.append(f"file '{clip.path}'")
|
clip: Recordings
|
||||||
# if this is the starting clip, add an inpoint
|
for clip in recordings:
|
||||||
if clip.start_time < start_ts:
|
file.write(f"file '{clip.path}'\n")
|
||||||
playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}")
|
# if this is the starting clip, add an inpoint
|
||||||
# if this is the ending clip, add an outpoint
|
if clip.start_time < start_ts:
|
||||||
if clip.end_time > end_ts:
|
file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
|
||||||
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}")
|
# if this is the ending clip, add an outpoint
|
||||||
|
if clip.end_time > end_ts:
|
||||||
file_name = sanitize_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4")
|
file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
|
||||||
|
|
||||||
if len(file_name) > 1000:
|
if len(file_name) > 1000:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -489,67 +509,32 @@ def recording_clip(
|
|||||||
status_code=403,
|
status_code=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
path = os.path.join(CLIPS_DIR, f"cache/{file_name}")
|
|
||||||
|
|
||||||
config: FrigateConfig = request.app.frigate_config
|
config: FrigateConfig = request.app.frigate_config
|
||||||
|
|
||||||
if not os.path.exists(path):
|
ffmpeg_cmd = [
|
||||||
ffmpeg_cmd = [
|
config.ffmpeg.ffmpeg_path,
|
||||||
config.ffmpeg.ffmpeg_path,
|
"-hide_banner",
|
||||||
"-hide_banner",
|
"-y",
|
||||||
"-y",
|
"-protocol_whitelist",
|
||||||
"-protocol_whitelist",
|
"pipe,file",
|
||||||
"pipe,file",
|
"-f",
|
||||||
"-f",
|
"concat",
|
||||||
"concat",
|
"-safe",
|
||||||
"-safe",
|
"0",
|
||||||
"0",
|
"-i",
|
||||||
"-i",
|
file_path,
|
||||||
"/dev/stdin",
|
"-c",
|
||||||
"-c",
|
"copy",
|
||||||
"copy",
|
"-movflags",
|
||||||
"-movflags",
|
"frag_keyframe+empty_moov",
|
||||||
"+faststart",
|
"-f",
|
||||||
path,
|
"mp4",
|
||||||
]
|
"pipe:",
|
||||||
p = sp.run(
|
]
|
||||||
ffmpeg_cmd,
|
|
||||||
input="\n".join(playlist_lines),
|
|
||||||
encoding="ascii",
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if p.returncode != 0:
|
return StreamingResponse(
|
||||||
logger.error(p.stderr)
|
run_download(ffmpeg_cmd, file_path),
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "Could not create clip from recordings",
|
|
||||||
},
|
|
||||||
status_code=500,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
f"Ignoring subsequent request for {path} as it already exists in the cache."
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Description": "File Transfer",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Content-Type": "video/mp4",
|
|
||||||
"Content-Length": str(os.path.getsize(path)),
|
|
||||||
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
|
||||||
"X-Accel-Redirect": f"/clips/cache/{file_name}",
|
|
||||||
}
|
|
||||||
|
|
||||||
if download:
|
|
||||||
headers["Content-Disposition"] = "attachment; filename=%s" % file_name
|
|
||||||
|
|
||||||
return FileResponse(
|
|
||||||
path,
|
|
||||||
media_type="video/mp4",
|
media_type="video/mp4",
|
||||||
filename=file_name,
|
|
||||||
headers=headers,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1028,7 +1013,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/events/{event_id}/clip.mp4")
|
@router.get("/events/{event_id}/clip.mp4")
|
||||||
def event_clip(request: Request, event_id: str, download: bool = False):
|
def event_clip(request: Request, event_id: str):
|
||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == event_id)
|
event: Event = Event.get(Event.id == event_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
@ -1048,7 +1033,7 @@ def event_clip(request: Request, event_id: str, download: bool = False):
|
|||||||
end_ts = (
|
end_ts = (
|
||||||
datetime.now().timestamp() if event.end_time is None else event.end_time
|
datetime.now().timestamp() if event.end_time is None else event.end_time
|
||||||
)
|
)
|
||||||
return recording_clip(request, event.camera, event.start_time, end_ts, download)
|
return recording_clip(request, event.camera, event.start_time, end_ts)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Description": "File Transfer",
|
"Content-Description": "File Transfer",
|
||||||
@ -1059,9 +1044,6 @@ def event_clip(request: Request, event_id: str, download: bool = False):
|
|||||||
"X-Accel-Redirect": f"/clips/{file_name}",
|
"X-Accel-Redirect": f"/clips/{file_name}",
|
||||||
}
|
}
|
||||||
|
|
||||||
if download:
|
|
||||||
headers["Content-Disposition"] = "attachment; filename=%s" % file_name
|
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
clip_path,
|
clip_path,
|
||||||
media_type="video/mp4",
|
media_type="video/mp4",
|
||||||
|
|||||||
@ -174,7 +174,12 @@ class Embeddings:
|
|||||||
return embedding
|
return embedding
|
||||||
|
|
||||||
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray:
|
def batch_upsert_description(self, event_descriptions: dict[str, str]) -> ndarray:
|
||||||
embeddings = self.text_embedding(list(event_descriptions.values()))
|
# upsert embeddings one by one to avoid token limit
|
||||||
|
embeddings = []
|
||||||
|
|
||||||
|
for desc in event_descriptions.values():
|
||||||
|
embeddings.append(self.text_embedding([desc])[0])
|
||||||
|
|
||||||
ids = list(event_descriptions.keys())
|
ids = list(event_descriptions.keys())
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import importlib
|
|||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
|
|
||||||
@ -36,8 +38,9 @@ class GenAIClient:
|
|||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Generate a description for the frame."""
|
"""Generate a description for the frame."""
|
||||||
prompt = camera_config.genai.object_prompts.get(
|
prompt = camera_config.genai.object_prompts.get(
|
||||||
event.label, camera_config.genai.prompt
|
event.label,
|
||||||
).format(**event)
|
camera_config.genai.prompt,
|
||||||
|
).format(**model_to_dict(event))
|
||||||
return self._send(prompt, thumbnails)
|
return self._send(prompt, thumbnails)
|
||||||
|
|
||||||
def _init_provider(self):
|
def _init_provider(self):
|
||||||
|
|||||||
@ -142,6 +142,8 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# see if the recording mover is too slow and segments need to be deleted
|
||||||
if processed_segment_count > keep_count:
|
if processed_segment_count > keep_count:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {processed_segment_count} and discarding the rest..."
|
f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {processed_segment_count} and discarding the rest..."
|
||||||
@ -153,6 +155,21 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
self.end_time_cache.pop(cache_path, None)
|
self.end_time_cache.pop(cache_path, None)
|
||||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||||
|
|
||||||
|
# see if detection has failed and unprocessed segments need to be deleted
|
||||||
|
unprocessed_segment_count = (
|
||||||
|
len(grouped_recordings[camera]) - processed_segment_count
|
||||||
|
)
|
||||||
|
if unprocessed_segment_count > keep_count:
|
||||||
|
logger.warning(
|
||||||
|
f"Too many unprocessed recording segments in cache for {camera}. This likely indicates an issue with the detect stream, keeping the {keep_count} most recent segments out of {unprocessed_segment_count} and discarding the rest..."
|
||||||
|
)
|
||||||
|
to_remove = grouped_recordings[camera][:-keep_count]
|
||||||
|
for rec in to_remove:
|
||||||
|
cache_path = rec["cache_path"]
|
||||||
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
|
self.end_time_cache.pop(cache_path, None)
|
||||||
|
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
for camera, recordings in grouped_recordings.items():
|
for camera, recordings in grouped_recordings.items():
|
||||||
# clear out all the object recording info for old frames
|
# clear out all the object recording info for old frames
|
||||||
|
|||||||
@ -183,16 +183,11 @@ def update_yaml_from_url(file_path, url):
|
|||||||
update_yaml_file(file_path, key_path, new_value_list)
|
update_yaml_file(file_path, key_path, new_value_list)
|
||||||
else:
|
else:
|
||||||
value = new_value_list[0]
|
value = new_value_list[0]
|
||||||
if "," in value:
|
try:
|
||||||
# Skip conversion if we're a mask or zone string
|
# no need to convert if we have a mask/zone string
|
||||||
update_yaml_file(file_path, key_path, value)
|
value = ast.literal_eval(value) if "," not in value else value
|
||||||
else:
|
except (ValueError, SyntaxError):
|
||||||
try:
|
pass
|
||||||
value = ast.literal_eval(value)
|
|
||||||
except (ValueError, SyntaxError):
|
|
||||||
pass
|
|
||||||
update_yaml_file(file_path, key_path, value)
|
|
||||||
|
|
||||||
update_yaml_file(file_path, key_path, value)
|
update_yaml_file(file_path, key_path, value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
web/src/components/button/DownloadVideoButton.tsx
Normal file
65
web/src/components/button/DownloadVideoButton.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
import { FaDownload } from "react-icons/fa";
|
||||||
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
|
||||||
|
type DownloadVideoButtonProps = {
|
||||||
|
source: string;
|
||||||
|
camera: string;
|
||||||
|
startTime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DownloadVideoButton({
|
||||||
|
source,
|
||||||
|
camera,
|
||||||
|
startTime,
|
||||||
|
}: DownloadVideoButtonProps) {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const formattedDate = formatUnixTimestampToDateTime(startTime, {
|
||||||
|
strftime_fmt: "%D-%T",
|
||||||
|
time_style: "medium",
|
||||||
|
date_style: "medium",
|
||||||
|
});
|
||||||
|
const filename = `${camera}_${formattedDate}.mp4`;
|
||||||
|
|
||||||
|
const handleDownloadStart = () => {
|
||||||
|
setIsDownloading(true);
|
||||||
|
toast.success("Your review item video has started downloading.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadEnd = () => {
|
||||||
|
setIsDownloading(false);
|
||||||
|
toast.success("Download completed successfully.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
disabled={isDownloading}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={source}
|
||||||
|
download={filename}
|
||||||
|
onClick={handleDownloadStart}
|
||||||
|
onBlur={handleDownloadEnd}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
) : (
|
||||||
|
<FaDownload className="size-4 text-secondary-foreground" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,50 +1,56 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import TimeAgo from "../dynamic/TimeAgo";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { isIOS, isSafari } from "react-device-detect";
|
import { isIOS, isSafari } from "react-device-detect";
|
||||||
import Chip from "@/components/indicators/Chip";
|
import Chip from "@/components/indicators/Chip";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
|
||||||
import useImageLoaded from "@/hooks/use-image-loaded";
|
import useImageLoaded from "@/hooks/use-image-loaded";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
|
||||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
import useContextMenu from "@/hooks/use-contextmenu";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
type SearchThumbnailProps = {
|
type SearchThumbnailProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
findSimilar: () => void;
|
|
||||||
onClick: (searchResult: SearchResult) => void;
|
onClick: (searchResult: SearchResult) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SearchThumbnail({
|
export default function SearchThumbnail({
|
||||||
searchResult,
|
searchResult,
|
||||||
findSimilar,
|
|
||||||
onClick,
|
onClick,
|
||||||
}: SearchThumbnailProps) {
|
}: SearchThumbnailProps) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||||
|
|
||||||
useContextMenu(imgRef, findSimilar);
|
// interactions
|
||||||
|
|
||||||
const handleOnClick = useCallback(() => {
|
const handleOnClick = useCallback(() => {
|
||||||
onClick(searchResult);
|
onClick(searchResult);
|
||||||
}, [searchResult, onClick]);
|
}, [searchResult, onClick]);
|
||||||
|
|
||||||
// date
|
const objectLabel = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!config ||
|
||||||
|
!searchResult.sub_label ||
|
||||||
|
!config.model.attributes_map[searchResult.label]
|
||||||
|
) {
|
||||||
|
return searchResult.label;
|
||||||
|
}
|
||||||
|
|
||||||
const formattedDate = useFormattedTimestamp(
|
if (
|
||||||
searchResult.start_time,
|
config.model.attributes_map[searchResult.label].includes(
|
||||||
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
|
searchResult.sub_label,
|
||||||
config?.ui.timezone,
|
)
|
||||||
);
|
) {
|
||||||
|
return searchResult.sub_label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${searchResult.label}-verified`;
|
||||||
|
}, [config, searchResult]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative size-full cursor-pointer" onClick={handleOnClick}>
|
<div className="relative size-full cursor-pointer" onClick={handleOnClick}>
|
||||||
@ -80,17 +86,21 @@ export default function SearchThumbnail({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="mx-3 pb-1 text-sm text-white">
|
<div className="mx-3 pb-1 text-sm text-white">
|
||||||
<Chip
|
<Chip
|
||||||
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
|
className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs`}
|
||||||
onClick={() => onClick(searchResult)}
|
onClick={() => onClick(searchResult)}
|
||||||
>
|
>
|
||||||
{getIconForLabel(searchResult.label, "size-3 text-white")}
|
{getIconForLabel(objectLabel, "size-3 text-white")}
|
||||||
|
{Math.floor(
|
||||||
|
searchResult.score ?? searchResult.data.top_score * 100,
|
||||||
|
)}
|
||||||
|
%
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent className="capitalize">
|
<TooltipContent className="capitalize">
|
||||||
{[...new Set([searchResult.label])]
|
{[objectLabel]
|
||||||
.filter(
|
.filter(
|
||||||
(item) => item !== undefined && !item.includes("-verified"),
|
(item) => item !== undefined && !item.includes("-verified"),
|
||||||
)
|
)
|
||||||
@ -103,18 +113,7 @@ export default function SearchThumbnail({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
|
<div className="rounded-t-l pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent"></div>
|
||||||
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent">
|
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-[20%] items-end bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||||
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
|
|
||||||
{searchResult.end_time ? (
|
|
||||||
<TimeAgo time={searchResult.start_time * 1000} dense />
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<ActivityIndicator size={24} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{formattedDate}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
217
web/src/components/card/SearchThumbnailFooter.tsx
Normal file
217
web/src/components/card/SearchThumbnailFooter.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
|
import { SearchResult } from "@/types/search";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "../ui/alert-dialog";
|
||||||
|
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
|
||||||
|
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||||
|
import { FrigatePlusDialog } from "../overlay/dialog/FrigatePlusDialog";
|
||||||
|
import { Event } from "@/types/event";
|
||||||
|
import { FaArrowsRotate } from "react-icons/fa6";
|
||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MdImageSearch } from "react-icons/md";
|
||||||
|
import { isMobileOnly } from "react-device-detect";
|
||||||
|
|
||||||
|
type SearchThumbnailProps = {
|
||||||
|
searchResult: SearchResult;
|
||||||
|
findSimilar: () => void;
|
||||||
|
refreshResults: () => void;
|
||||||
|
showObjectLifecycle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SearchThumbnailFooter({
|
||||||
|
searchResult,
|
||||||
|
findSimilar,
|
||||||
|
refreshResults,
|
||||||
|
showObjectLifecycle,
|
||||||
|
}: SearchThumbnailProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
// interactions
|
||||||
|
|
||||||
|
const [showFrigatePlus, setShowFrigatePlus] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
axios
|
||||||
|
.delete(`events/${searchResult.id}`)
|
||||||
|
.then((resp) => {
|
||||||
|
if (resp.status == 200) {
|
||||||
|
toast.success("Tracked object deleted successfully.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
refreshResults();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to delete tracked object.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [searchResult, refreshResults]);
|
||||||
|
|
||||||
|
// date
|
||||||
|
|
||||||
|
const formattedDate = useFormattedTimestamp(
|
||||||
|
searchResult.start_time,
|
||||||
|
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p",
|
||||||
|
config?.ui.timezone,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this tracked object?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<FrigatePlusDialog
|
||||||
|
upload={
|
||||||
|
showFrigatePlus ? (searchResult as unknown as Event) : undefined
|
||||||
|
}
|
||||||
|
onClose={() => setShowFrigatePlus(false)}
|
||||||
|
onEventUploaded={() => {
|
||||||
|
searchResult.plus_id = "submitted";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start text-xs text-primary-variant">
|
||||||
|
{searchResult.end_time ? (
|
||||||
|
<TimeAgo time={searchResult.start_time * 1000} dense />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<ActivityIndicator size={24} />
|
||||||
|
</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>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<MdImageSearch
|
||||||
|
className="size-5 cursor-pointer text-primary-variant hover:text-primary"
|
||||||
|
onClick={findSimilar}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Find similar</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
|
||||||
|
</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
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="mr-2 size-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -643,6 +643,11 @@ export function CameraGroupEdit({
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
let renamingQuery = "";
|
||||||
|
if (editingGroup && editingGroup[0] !== values.name) {
|
||||||
|
renamingQuery = `camera_groups.${editingGroup[0]}&`;
|
||||||
|
}
|
||||||
|
|
||||||
const order =
|
const order =
|
||||||
editingGroup === undefined
|
editingGroup === undefined
|
||||||
? currentGroups.length + 1
|
? currentGroups.length + 1
|
||||||
@ -655,9 +660,12 @@ export function CameraGroupEdit({
|
|||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, {
|
.put(
|
||||||
requires_restart: 0,
|
`config/set?${renamingQuery}${orderQuery}&${iconQuery}${cameraQueries}`,
|
||||||
})
|
{
|
||||||
|
requires_restart: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success(`Camera group (${values.name}) has been saved.`, {
|
toast.success(`Camera group (${values.name}) has been saved.`, {
|
||||||
@ -712,7 +720,6 @@ export function CameraGroupEdit({
|
|||||||
<Input
|
<Input
|
||||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
placeholder="Enter a name..."
|
placeholder="Enter a name..."
|
||||||
disabled={editingGroup !== undefined}
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@ -69,6 +69,70 @@ export function CamerasFilterButton({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
const content = (
|
const content = (
|
||||||
|
<CamerasFilterContent
|
||||||
|
allCameras={allCameras}
|
||||||
|
groups={groups}
|
||||||
|
currentCameras={currentCameras}
|
||||||
|
setCurrentCameras={setCurrentCameras}
|
||||||
|
setOpen={setOpen}
|
||||||
|
updateCameraFilter={updateCameraFilter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCurrentCameras(selectedCameras);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
||||||
|
{content}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
modal={false}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCurrentCameras(selectedCameras);
|
||||||
|
}
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>{content}</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CamerasFilterContentProps = {
|
||||||
|
allCameras: string[];
|
||||||
|
currentCameras: string[] | undefined;
|
||||||
|
groups: [string, CameraGroupConfig][];
|
||||||
|
setCurrentCameras: (cameras: string[] | undefined) => void;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
updateCameraFilter: (cameras: string[] | undefined) => void;
|
||||||
|
};
|
||||||
|
export function CamerasFilterContent({
|
||||||
|
allCameras,
|
||||||
|
currentCameras,
|
||||||
|
groups,
|
||||||
|
setCurrentCameras,
|
||||||
|
setOpen,
|
||||||
|
updateCameraFilter,
|
||||||
|
}: CamerasFilterContentProps) {
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<>
|
<>
|
||||||
@ -158,40 +222,4 @@ export function CamerasFilterButton({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setCurrentCameras(selectedCameras);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
|
||||||
<DrawerContent className="max-h-[75dvh] overflow-hidden">
|
|
||||||
{content}
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu
|
|
||||||
modal={false}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setCurrentCameras(selectedCameras);
|
|
||||||
}
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>{content}</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@ -10,25 +9,19 @@ import { Switch } from "../ui/switch";
|
|||||||
import { Label } from "../ui/label";
|
import { Label } from "../ui/label";
|
||||||
import FilterSwitch from "./FilterSwitch";
|
import FilterSwitch from "./FilterSwitch";
|
||||||
import { FilterList } from "@/types/filter";
|
import { FilterList } from "@/types/filter";
|
||||||
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
|
|
||||||
import { CamerasFilterButton } from "./CamerasFilterButton";
|
import { CamerasFilterButton } from "./CamerasFilterButton";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SEARCH_FILTERS,
|
DEFAULT_SEARCH_FILTERS,
|
||||||
SearchFilter,
|
SearchFilter,
|
||||||
SearchFilters,
|
SearchFilters,
|
||||||
SearchSource,
|
SearchSource,
|
||||||
DEFAULT_TIME_RANGE_AFTER,
|
|
||||||
DEFAULT_TIME_RANGE_BEFORE,
|
|
||||||
} from "@/types/search";
|
} from "@/types/search";
|
||||||
import { DateRange } from "react-day-picker";
|
import { DateRange } from "react-day-picker";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import SubFilterIcon from "../icons/SubFilterIcon";
|
|
||||||
import { FaLocationDot } from "react-icons/fa6";
|
|
||||||
import { MdLabel } from "react-icons/md";
|
import { MdLabel } from "react-icons/md";
|
||||||
import SearchSourceIcon from "../icons/SearchSourceIcon";
|
|
||||||
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
||||||
import { FaArrowRight, FaClock } from "react-icons/fa";
|
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
|
||||||
import { useFormattedHour } from "@/hooks/use-date-utils";
|
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
|
||||||
|
|
||||||
type SearchFilterGroupProps = {
|
type SearchFilterGroupProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -79,8 +72,6 @@ export default function SearchFilterGroup({
|
|||||||
return [...labels].sort();
|
return [...labels].sort();
|
||||||
}, [config, filterList, filter]);
|
}, [config, filterList, filter]);
|
||||||
|
|
||||||
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
|
|
||||||
|
|
||||||
const allZones = useMemo<string[]>(() => {
|
const allZones = useMemo<string[]>(() => {
|
||||||
if (filterList?.zones) {
|
if (filterList?.zones) {
|
||||||
return filterList.zones;
|
return filterList.zones;
|
||||||
@ -159,6 +150,15 @@ export default function SearchFilterGroup({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{filters.includes("general") && (
|
||||||
|
<GeneralFilterButton
|
||||||
|
allLabels={filterValues.labels}
|
||||||
|
selectedLabels={filter?.labels}
|
||||||
|
updateLabelFilter={(newLabels) => {
|
||||||
|
onUpdateFilter({ ...filter, labels: newLabels });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{filters.includes("date") && (
|
{filters.includes("date") && (
|
||||||
<CalendarRangeFilterButton
|
<CalendarRangeFilterButton
|
||||||
range={
|
range={
|
||||||
@ -173,54 +173,12 @@ export default function SearchFilterGroup({
|
|||||||
updateSelectedRange={onUpdateSelectedRange}
|
updateSelectedRange={onUpdateSelectedRange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filters.includes("time") && (
|
<SearchFilterDialog
|
||||||
<TimeRangeFilterButton
|
config={config}
|
||||||
config={config}
|
filter={filter}
|
||||||
timeRange={filter?.time_range}
|
filterValues={filterValues}
|
||||||
updateTimeRange={(time_range) =>
|
onUpdateFilter={onUpdateFilter}
|
||||||
onUpdateFilter({ ...filter, time_range })
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters.includes("zone") && allZones.length > 0 && (
|
|
||||||
<ZoneFilterButton
|
|
||||||
allZones={filterValues.zones}
|
|
||||||
selectedZones={filter?.zones}
|
|
||||||
updateZoneFilter={(newZones) =>
|
|
||||||
onUpdateFilter({ ...filter, zones: newZones })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters.includes("general") && (
|
|
||||||
<GeneralFilterButton
|
|
||||||
allLabels={filterValues.labels}
|
|
||||||
selectedLabels={filter?.labels}
|
|
||||||
updateLabelFilter={(newLabels) => {
|
|
||||||
onUpdateFilter({ ...filter, labels: newLabels });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{filters.includes("sub") && (
|
|
||||||
<SubFilterButton
|
|
||||||
allSubLabels={allSubLabels}
|
|
||||||
selectedSubLabels={filter?.sub_labels}
|
|
||||||
updateSubLabelFilter={(newSubLabels) =>
|
|
||||||
onUpdateFilter({ ...filter, sub_labels: newSubLabels })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{config?.semantic_search?.enabled &&
|
|
||||||
filters.includes("source") &&
|
|
||||||
!filter?.search_type?.includes("similarity") && (
|
|
||||||
<SearchTypeButton
|
|
||||||
selectedSearchSources={
|
|
||||||
filter?.search_type ?? ["thumbnail", "description"]
|
|
||||||
}
|
|
||||||
updateSearchSourceFilter={(newSearchSource) =>
|
|
||||||
onUpdateFilter({ ...filter, search_type: newSearchSource })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -295,7 +253,11 @@ function GeneralFilterButton({
|
|||||||
<PlatformAwareDialog
|
<PlatformAwareDialog
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
content={content}
|
content={content}
|
||||||
contentClassName={isDesktop ? "" : "max-h-[75dvh] overflow-hidden p-4"}
|
contentClassName={
|
||||||
|
isDesktop
|
||||||
|
? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
|
||||||
|
: "max-h-[75dvh] overflow-hidden p-4"
|
||||||
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -326,7 +288,7 @@ export function GeneralFilterContent({
|
|||||||
}: GeneralFilterContentProps) {
|
}: GeneralFilterContentProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
<div className="overflow-x-hidden">
|
||||||
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
||||||
<Label
|
<Label
|
||||||
className="mx-2 cursor-pointer text-primary"
|
className="mx-2 cursor-pointer text-primary"
|
||||||
@ -397,681 +359,3 @@ export function GeneralFilterContent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimeRangeFilterButtonProps = {
|
|
||||||
config?: FrigateConfig;
|
|
||||||
timeRange?: string;
|
|
||||||
updateTimeRange: (range: string | undefined) => void;
|
|
||||||
};
|
|
||||||
function TimeRangeFilterButton({
|
|
||||||
config,
|
|
||||||
timeRange,
|
|
||||||
updateTimeRange,
|
|
||||||
}: TimeRangeFilterButtonProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [startOpen, setStartOpen] = useState(false);
|
|
||||||
const [endOpen, setEndOpen] = useState(false);
|
|
||||||
|
|
||||||
const [afterHour, beforeHour] = useMemo(() => {
|
|
||||||
if (!timeRange || !timeRange.includes(",")) {
|
|
||||||
return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE];
|
|
||||||
}
|
|
||||||
|
|
||||||
return timeRange.split(",");
|
|
||||||
}, [timeRange]);
|
|
||||||
|
|
||||||
const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour);
|
|
||||||
const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour);
|
|
||||||
|
|
||||||
// format based on locale
|
|
||||||
|
|
||||||
const formattedAfter = useFormattedHour(config, afterHour);
|
|
||||||
const formattedBefore = useFormattedHour(config, beforeHour);
|
|
||||||
const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour);
|
|
||||||
const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedAfterHour(afterHour);
|
|
||||||
setSelectedBeforeHour(beforeHour);
|
|
||||||
// only refresh when state changes
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [timeRange]);
|
|
||||||
|
|
||||||
const trigger = (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={timeRange ? "select" : "default"}
|
|
||||||
className="flex items-center gap-2 capitalize"
|
|
||||||
>
|
|
||||||
<FaClock
|
|
||||||
className={`${timeRange ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`${timeRange ? "text-selected-foreground" : "text-primary"}`}
|
|
||||||
>
|
|
||||||
{timeRange ? `${formattedAfter} - ${formattedBefore}` : "All Times"}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
const content = (
|
|
||||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
|
||||||
<div className="my-5 flex flex-row items-center justify-center gap-2">
|
|
||||||
<Popover
|
|
||||||
open={startOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setStartOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className={`text-primary ${isDesktop ? "" : "text-xs"} `}
|
|
||||||
variant={startOpen ? "select" : "default"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setStartOpen(true);
|
|
||||||
setEndOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formattedSelectedAfter}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="flex flex-row items-center justify-center">
|
|
||||||
<input
|
|
||||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
|
||||||
id="startTime"
|
|
||||||
type="time"
|
|
||||||
value={selectedAfterHour}
|
|
||||||
step="60"
|
|
||||||
onChange={(e) => {
|
|
||||||
const clock = e.target.value;
|
|
||||||
const [hour, minute, _] = clock.split(":");
|
|
||||||
setSelectedAfterHour(`${hour}:${minute}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FaArrowRight className="size-4 text-primary" />
|
|
||||||
<Popover
|
|
||||||
open={endOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setEndOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
|
||||||
variant={endOpen ? "select" : "default"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setEndOpen(true);
|
|
||||||
setStartOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formattedSelectedBefore}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="flex flex-col items-center">
|
|
||||||
<input
|
|
||||||
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
|
||||||
id="startTime"
|
|
||||||
type="time"
|
|
||||||
value={
|
|
||||||
selectedBeforeHour == "24:00" ? "23:59" : selectedBeforeHour
|
|
||||||
}
|
|
||||||
step="60"
|
|
||||||
onChange={(e) => {
|
|
||||||
const clock = e.target.value;
|
|
||||||
const [hour, minute, _] = clock.split(":");
|
|
||||||
setSelectedBeforeHour(`${hour}:${minute}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="flex items-center justify-evenly p-2">
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
selectedAfterHour == DEFAULT_TIME_RANGE_AFTER &&
|
|
||||||
selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE
|
|
||||||
) {
|
|
||||||
updateTimeRange(undefined);
|
|
||||||
} else {
|
|
||||||
updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedAfterHour(DEFAULT_TIME_RANGE_AFTER);
|
|
||||||
setSelectedBeforeHour(DEFAULT_TIME_RANGE_BEFORE);
|
|
||||||
updateTimeRange(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformAwareDialog
|
|
||||||
trigger={trigger}
|
|
||||||
content={content}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ZoneFilterButtonProps = {
|
|
||||||
allZones: string[];
|
|
||||||
selectedZones?: string[];
|
|
||||||
updateZoneFilter: (zones: string[] | undefined) => void;
|
|
||||||
};
|
|
||||||
function ZoneFilterButton({
|
|
||||||
allZones,
|
|
||||||
selectedZones,
|
|
||||||
updateZoneFilter,
|
|
||||||
}: ZoneFilterButtonProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const [currentZones, setCurrentZones] = useState<string[] | undefined>(
|
|
||||||
selectedZones,
|
|
||||||
);
|
|
||||||
|
|
||||||
const buttonText = useMemo(() => {
|
|
||||||
if (isMobile) {
|
|
||||||
return "Zones";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedZones || selectedZones.length == 0) {
|
|
||||||
return "All Zones";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedZones.length == 1) {
|
|
||||||
return selectedZones[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${selectedZones.length} Zones`;
|
|
||||||
}, [selectedZones]);
|
|
||||||
|
|
||||||
// ui
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentZones(selectedZones);
|
|
||||||
// only refresh when state changes
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedZones]);
|
|
||||||
|
|
||||||
const trigger = (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedZones?.length ? "select" : "default"}
|
|
||||||
className="flex items-center gap-2 capitalize"
|
|
||||||
>
|
|
||||||
<FaLocationDot
|
|
||||||
className={`${selectedZones?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`${selectedZones?.length ? "text-selected-foreground" : "text-primary"}`}
|
|
||||||
>
|
|
||||||
{buttonText}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
const content = (
|
|
||||||
<ZoneFilterContent
|
|
||||||
allZones={allZones}
|
|
||||||
selectedZones={selectedZones}
|
|
||||||
currentZones={currentZones}
|
|
||||||
setCurrentZones={setCurrentZones}
|
|
||||||
updateZoneFilter={updateZoneFilter}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformAwareDialog
|
|
||||||
trigger={trigger}
|
|
||||||
content={content}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setCurrentZones(selectedZones);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ZoneFilterContentProps = {
|
|
||||||
allZones?: string[];
|
|
||||||
selectedZones?: string[];
|
|
||||||
currentZones?: string[];
|
|
||||||
updateZoneFilter?: (zones: string[] | undefined) => void;
|
|
||||||
setCurrentZones?: (zones: string[] | undefined) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
export function ZoneFilterContent({
|
|
||||||
allZones,
|
|
||||||
selectedZones,
|
|
||||||
currentZones,
|
|
||||||
updateZoneFilter,
|
|
||||||
setCurrentZones,
|
|
||||||
onClose,
|
|
||||||
}: ZoneFilterContentProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
|
||||||
{allZones && setCurrentZones && (
|
|
||||||
<>
|
|
||||||
{isDesktop && <DropdownMenuSeparator />}
|
|
||||||
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
|
||||||
<Label
|
|
||||||
className="mx-2 cursor-pointer text-primary"
|
|
||||||
htmlFor="allZones"
|
|
||||||
>
|
|
||||||
All Zones
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
className="ml-1"
|
|
||||||
id="allZones"
|
|
||||||
checked={currentZones == undefined}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
setCurrentZones(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="my-2.5 flex flex-col gap-2.5">
|
|
||||||
{allZones.map((item) => (
|
|
||||||
<FilterSwitch
|
|
||||||
key={item}
|
|
||||||
label={item.replaceAll("_", " ")}
|
|
||||||
isChecked={currentZones?.includes(item) ?? false}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
const updatedZones = currentZones
|
|
||||||
? [...currentZones]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
updatedZones.push(item);
|
|
||||||
setCurrentZones(updatedZones);
|
|
||||||
} else {
|
|
||||||
const updatedZones = currentZones
|
|
||||||
? [...currentZones]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// can not deselect the last item
|
|
||||||
if (updatedZones.length > 1) {
|
|
||||||
updatedZones.splice(updatedZones.indexOf(item), 1);
|
|
||||||
setCurrentZones(updatedZones);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isDesktop && <DropdownMenuSeparator />}
|
|
||||||
<div className="flex items-center justify-evenly p-2">
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
onClick={() => {
|
|
||||||
if (updateZoneFilter && selectedZones != currentZones) {
|
|
||||||
updateZoneFilter(currentZones);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setCurrentZones?.(undefined);
|
|
||||||
updateZoneFilter?.(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubFilterButtonProps = {
|
|
||||||
allSubLabels: string[];
|
|
||||||
selectedSubLabels: string[] | undefined;
|
|
||||||
updateSubLabelFilter: (labels: string[] | undefined) => void;
|
|
||||||
};
|
|
||||||
function SubFilterButton({
|
|
||||||
allSubLabels,
|
|
||||||
selectedSubLabels,
|
|
||||||
updateSubLabelFilter,
|
|
||||||
}: SubFilterButtonProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [currentSubLabels, setCurrentSubLabels] = useState<
|
|
||||||
string[] | undefined
|
|
||||||
>(selectedSubLabels);
|
|
||||||
|
|
||||||
const buttonText = useMemo(() => {
|
|
||||||
if (isMobile) {
|
|
||||||
return "Sub Labels";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedSubLabels || selectedSubLabels.length == 0) {
|
|
||||||
return "All Sub Labels";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedSubLabels.length == 1) {
|
|
||||||
return selectedSubLabels[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${selectedSubLabels.length} Sub Labels`;
|
|
||||||
}, [selectedSubLabels]);
|
|
||||||
|
|
||||||
const trigger = (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedSubLabels?.length ? "select" : "default"}
|
|
||||||
className="flex items-center gap-2 capitalize"
|
|
||||||
>
|
|
||||||
<SubFilterIcon
|
|
||||||
className={`${selectedSubLabels?.length || selectedSubLabels?.length ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`${selectedSubLabels?.length ? "text-selected-foreground" : "text-primary"}`}
|
|
||||||
>
|
|
||||||
{buttonText}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
const content = (
|
|
||||||
<SubFilterContent
|
|
||||||
allSubLabels={allSubLabels}
|
|
||||||
selectedSubLabels={selectedSubLabels}
|
|
||||||
currentSubLabels={currentSubLabels}
|
|
||||||
setCurrentSubLabels={setCurrentSubLabels}
|
|
||||||
updateSubLabelFilter={updateSubLabelFilter}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformAwareDialog
|
|
||||||
trigger={trigger}
|
|
||||||
content={content}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setCurrentSubLabels(selectedSubLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(open);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubFilterContentProps = {
|
|
||||||
allSubLabels: string[];
|
|
||||||
selectedSubLabels: string[] | undefined;
|
|
||||||
currentSubLabels: string[] | undefined;
|
|
||||||
updateSubLabelFilter: (labels: string[] | undefined) => void;
|
|
||||||
setCurrentSubLabels: (labels: string[] | undefined) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
export function SubFilterContent({
|
|
||||||
allSubLabels,
|
|
||||||
selectedSubLabels,
|
|
||||||
currentSubLabels,
|
|
||||||
updateSubLabelFilter,
|
|
||||||
setCurrentSubLabels,
|
|
||||||
onClose,
|
|
||||||
}: SubFilterContentProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
|
||||||
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
|
||||||
<Label
|
|
||||||
className="mx-2 cursor-pointer text-primary"
|
|
||||||
htmlFor="allLabels"
|
|
||||||
>
|
|
||||||
All Sub Labels
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
className="ml-1"
|
|
||||||
id="allLabels"
|
|
||||||
checked={currentSubLabels == undefined}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
setCurrentSubLabels(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="my-2.5 flex flex-col gap-2.5">
|
|
||||||
{allSubLabels.map((item) => (
|
|
||||||
<FilterSwitch
|
|
||||||
key={item}
|
|
||||||
label={item.replaceAll("_", " ")}
|
|
||||||
isChecked={currentSubLabels?.includes(item) ?? false}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
const updatedLabels = currentSubLabels
|
|
||||||
? [...currentSubLabels]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
updatedLabels.push(item);
|
|
||||||
setCurrentSubLabels(updatedLabels);
|
|
||||||
} else {
|
|
||||||
const updatedLabels = currentSubLabels
|
|
||||||
? [...currentSubLabels]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// can not deselect the last item
|
|
||||||
if (updatedLabels.length > 1) {
|
|
||||||
updatedLabels.splice(updatedLabels.indexOf(item), 1);
|
|
||||||
setCurrentSubLabels(updatedLabels);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isDesktop && <DropdownMenuSeparator />}
|
|
||||||
<div className="flex items-center justify-evenly p-2">
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedSubLabels != currentSubLabels) {
|
|
||||||
updateSubLabelFilter(currentSubLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
updateSubLabelFilter(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchTypeButtonProps = {
|
|
||||||
selectedSearchSources: SearchSource[] | undefined;
|
|
||||||
updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
|
|
||||||
};
|
|
||||||
function SearchTypeButton({
|
|
||||||
selectedSearchSources,
|
|
||||||
updateSearchSourceFilter,
|
|
||||||
}: SearchTypeButtonProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const buttonText = useMemo(() => {
|
|
||||||
if (isMobile) {
|
|
||||||
return "Sources";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!selectedSearchSources ||
|
|
||||||
selectedSearchSources.length == 0 ||
|
|
||||||
selectedSearchSources.length == 2
|
|
||||||
) {
|
|
||||||
return "All Search Sources";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedSearchSources.length == 1) {
|
|
||||||
return selectedSearchSources[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${selectedSearchSources.length} Search Sources`;
|
|
||||||
}, [selectedSearchSources]);
|
|
||||||
|
|
||||||
const trigger = (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={selectedSearchSources?.length != 2 ? "select" : "default"}
|
|
||||||
className="flex items-center gap-2 capitalize"
|
|
||||||
>
|
|
||||||
<SearchSourceIcon
|
|
||||||
className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-secondary-foreground"}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`${selectedSearchSources?.length != 2 ? "text-selected-foreground" : "text-primary"}`}
|
|
||||||
>
|
|
||||||
{buttonText}
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
const content = (
|
|
||||||
<SearchTypeContent
|
|
||||||
selectedSearchSources={selectedSearchSources}
|
|
||||||
updateSearchSourceFilter={updateSearchSourceFilter}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformAwareDialog
|
|
||||||
trigger={trigger}
|
|
||||||
content={content}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchTypeContentProps = {
|
|
||||||
selectedSearchSources: SearchSource[] | undefined;
|
|
||||||
updateSearchSourceFilter: (sources: SearchSource[] | undefined) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
export function SearchTypeContent({
|
|
||||||
selectedSearchSources,
|
|
||||||
updateSearchSourceFilter,
|
|
||||||
onClose,
|
|
||||||
}: SearchTypeContentProps) {
|
|
||||||
const [currentSearchSources, setCurrentSearchSources] = useState<
|
|
||||||
SearchSource[] | undefined
|
|
||||||
>(selectedSearchSources);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
|
||||||
<div className="my-2.5 flex flex-col gap-2.5">
|
|
||||||
<FilterSwitch
|
|
||||||
label="Thumbnail Image"
|
|
||||||
isChecked={currentSearchSources?.includes("thumbnail") ?? false}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
const updatedSources = currentSearchSources
|
|
||||||
? [...currentSearchSources]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (isChecked) {
|
|
||||||
updatedSources.push("thumbnail");
|
|
||||||
setCurrentSearchSources(updatedSources);
|
|
||||||
} else {
|
|
||||||
if (updatedSources.length > 1) {
|
|
||||||
const index = updatedSources.indexOf("thumbnail");
|
|
||||||
if (index !== -1) updatedSources.splice(index, 1);
|
|
||||||
setCurrentSearchSources(updatedSources);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FilterSwitch
|
|
||||||
label="Description"
|
|
||||||
isChecked={currentSearchSources?.includes("description") ?? false}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
const updatedSources = currentSearchSources
|
|
||||||
? [...currentSearchSources]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (isChecked) {
|
|
||||||
updatedSources.push("description");
|
|
||||||
setCurrentSearchSources(updatedSources);
|
|
||||||
} else {
|
|
||||||
if (updatedSources.length > 1) {
|
|
||||||
const index = updatedSources.indexOf("description");
|
|
||||||
if (index !== -1) updatedSources.splice(index, 1);
|
|
||||||
setCurrentSearchSources(updatedSources);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isDesktop && <DropdownMenuSeparator />}
|
|
||||||
<div className="flex items-center justify-evenly p-2">
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedSearchSources != currentSearchSources) {
|
|
||||||
updateSearchSourceFilter(currentSearchSources);
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
updateSearchSourceFilter(undefined);
|
|
||||||
setCurrentSearchSources(["thumbnail", "description"]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
|
|||||||
import {
|
import {
|
||||||
LuX,
|
LuX,
|
||||||
LuFilter,
|
LuFilter,
|
||||||
LuImage,
|
|
||||||
LuChevronDown,
|
LuChevronDown,
|
||||||
LuChevronUp,
|
LuChevronUp,
|
||||||
LuTrash2,
|
LuTrash2,
|
||||||
@ -44,6 +43,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { MdImageSearch } from "react-icons/md";
|
||||||
|
|
||||||
type InputWithTagsProps = {
|
type InputWithTagsProps = {
|
||||||
inputFocused: boolean;
|
inputFocused: boolean;
|
||||||
@ -514,7 +514,7 @@ export default function InputWithTags({
|
|||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
className="text-md h-9 pr-24"
|
className="text-md h-9 pr-32"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
|
<div className="absolute right-3 top-0 flex h-full flex-row items-center justify-center gap-5">
|
||||||
@ -549,7 +549,7 @@ export default function InputWithTags({
|
|||||||
{isSimilaritySearch && (
|
{isSimilaritySearch && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="cursor-default">
|
<TooltipTrigger className="cursor-default">
|
||||||
<LuImage
|
<MdImageSearch
|
||||||
aria-label="Similarity search active"
|
aria-label="Similarity search active"
|
||||||
className="size-4 text-selected"
|
className="size-4 text-selected"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import {
|
|||||||
MobilePageTitle,
|
MobilePageTitle,
|
||||||
} from "@/components/mobile/MobilePage";
|
} from "@/components/mobile/MobilePage";
|
||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
|
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
type ReviewDetailDialogProps = {
|
type ReviewDetailDialogProps = {
|
||||||
review?: ReviewSegment;
|
review?: ReviewSegment;
|
||||||
@ -143,7 +145,7 @@ export default function ReviewDetailDialog({
|
|||||||
<Description className="sr-only">Review item details</Description>
|
<Description className="sr-only">Review item details</Description>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute",
|
"absolute flex gap-2 lg:flex-col",
|
||||||
isDesktop && "right-1 top-8",
|
isDesktop && "right-1 top-8",
|
||||||
isMobile && "right-0 top-3",
|
isMobile && "right-0 top-3",
|
||||||
)}
|
)}
|
||||||
@ -159,7 +161,21 @@ export default function ReviewDetailDialog({
|
|||||||
<FaShareAlt className="size-4 text-secondary-foreground" />
|
<FaShareAlt className="size-4 text-secondary-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Share this review item</TooltipContent>
|
<TooltipPortal>
|
||||||
|
<TooltipContent>Share this review item</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<DownloadVideoButton
|
||||||
|
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
|
||||||
|
camera={review.camera}
|
||||||
|
startTime={review.start_time}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>Download</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
@ -180,7 +196,7 @@ export default function ReviewDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
<div className="flex w-full flex-col gap-1.5">
|
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
|
||||||
<div className="text-sm text-primary/40">Objects</div>
|
<div className="text-sm text-primary/40">Objects</div>
|
||||||
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
|
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
|
||||||
{events?.map((event) => {
|
{events?.map((event) => {
|
||||||
|
|||||||
@ -69,16 +69,20 @@ const SEARCH_TABS = [
|
|||||||
"video",
|
"video",
|
||||||
"object lifecycle",
|
"object lifecycle",
|
||||||
] as const;
|
] as const;
|
||||||
type SearchTab = (typeof SEARCH_TABS)[number];
|
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||||
|
|
||||||
type SearchDetailDialogProps = {
|
type SearchDetailDialogProps = {
|
||||||
search?: SearchResult;
|
search?: SearchResult;
|
||||||
|
page: SearchTab;
|
||||||
setSearch: (search: SearchResult | undefined) => void;
|
setSearch: (search: SearchResult | undefined) => void;
|
||||||
|
setSearchPage: (page: SearchTab) => void;
|
||||||
setSimilarity?: () => void;
|
setSimilarity?: () => void;
|
||||||
};
|
};
|
||||||
export default function SearchDetailDialog({
|
export default function SearchDetailDialog({
|
||||||
search,
|
search,
|
||||||
|
page,
|
||||||
setSearch,
|
setSearch,
|
||||||
|
setSearchPage,
|
||||||
setSimilarity,
|
setSimilarity,
|
||||||
}: SearchDetailDialogProps) {
|
}: SearchDetailDialogProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@ -87,15 +91,20 @@ export default function SearchDetailDialog({
|
|||||||
|
|
||||||
// tabs
|
// tabs
|
||||||
|
|
||||||
const [page, setPage] = useState<SearchTab>("details");
|
const [pageToggle, setPageToggle] = useOptimisticState(
|
||||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
page,
|
||||||
|
setSearchPage,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
// dialog and mobile page
|
// dialog and mobile page
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(search != undefined);
|
const [isOpen, setIsOpen] = useState(search != undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOpen(search != undefined);
|
if (search) {
|
||||||
|
setIsOpen(search != undefined);
|
||||||
|
}
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
const searchTabs = useMemo(() => {
|
const searchTabs = useMemo(() => {
|
||||||
@ -115,12 +124,6 @@ export default function SearchDetailDialog({
|
|||||||
views.splice(index, 1);
|
views.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO implement
|
|
||||||
//if (!config.semantic_search.enabled) {
|
|
||||||
// const index = views.indexOf("similar-calendar");
|
|
||||||
// views.splice(index, 1);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return views;
|
return views;
|
||||||
}, [config, search]);
|
}, [config, search]);
|
||||||
|
|
||||||
@ -130,9 +133,9 @@ export default function SearchDetailDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchTabs.includes(pageToggle)) {
|
if (!searchTabs.includes(pageToggle)) {
|
||||||
setPage("details");
|
setSearchPage("details");
|
||||||
}
|
}
|
||||||
}, [pageToggle, searchTabs]);
|
}, [pageToggle, searchTabs, setSearchPage]);
|
||||||
|
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return;
|
return;
|
||||||
@ -147,14 +150,7 @@ export default function SearchDetailDialog({
|
|||||||
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
const Description = isDesktop ? DialogDescription : MobilePageDescription;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay
|
<Overlay open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setSearch(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Content
|
<Content
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container overflow-y-auto",
|
"scrollbar-container overflow-y-auto",
|
||||||
|
|||||||
@ -1,9 +1,23 @@
|
|||||||
|
import {
|
||||||
|
MobilePage,
|
||||||
|
MobilePageContent,
|
||||||
|
MobilePageHeader,
|
||||||
|
MobilePageTitle,
|
||||||
|
} from "@/components/mobile/MobilePage";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
|
||||||
type PlatformAwareDialogProps = {
|
type PlatformAwareDialogProps = {
|
||||||
@ -42,3 +56,60 @@ export default function PlatformAwareDialog({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlatformAwareSheetProps = {
|
||||||
|
trigger: JSX.Element;
|
||||||
|
title?: string | JSX.Element;
|
||||||
|
content: JSX.Element;
|
||||||
|
triggerClassName?: string;
|
||||||
|
titleClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
export function PlatformAwareSheet({
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
triggerClassName = "",
|
||||||
|
titleClassName = "",
|
||||||
|
contentClassName = "",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: PlatformAwareSheetProps) {
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div onClick={() => onOpenChange(true)}>{trigger}</div>
|
||||||
|
<MobilePage open={open} onOpenChange={onOpenChange}>
|
||||||
|
<MobilePageContent className="h-full overflow-hidden">
|
||||||
|
<MobilePageHeader
|
||||||
|
className="mx-2"
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<MobilePageTitle>More Filters</MobilePageTitle>
|
||||||
|
</MobilePageHeader>
|
||||||
|
<div className={contentClassName}>{content}</div>
|
||||||
|
</MobilePageContent>
|
||||||
|
</MobilePage>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
||||||
|
<SheetTrigger asChild className={triggerClassName}>
|
||||||
|
{trigger}
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className={contentClassName}>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className={title ? titleClassName : "sr-only"}>
|
||||||
|
{title ?? ""}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="sr-only">Information</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
{content}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
477
web/src/components/overlay/dialog/SearchFilterDialog.tsx
Normal file
477
web/src/components/overlay/dialog/SearchFilterDialog.tsx
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
import { FaArrowRight, FaCog } from "react-icons/fa";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { PlatformAwareSheet } from "./PlatformAwareDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
DEFAULT_TIME_RANGE_AFTER,
|
||||||
|
DEFAULT_TIME_RANGE_BEFORE,
|
||||||
|
SearchFilter,
|
||||||
|
SearchSource,
|
||||||
|
} from "@/types/search";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { isDesktop, isMobileOnly } from "react-device-detect";
|
||||||
|
import { useFormattedHour } from "@/hooks/use-date-utils";
|
||||||
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SearchFilterDialogProps = {
|
||||||
|
config?: FrigateConfig;
|
||||||
|
filter?: SearchFilter;
|
||||||
|
filterValues: {
|
||||||
|
cameras: string[];
|
||||||
|
labels: string[];
|
||||||
|
zones: string[];
|
||||||
|
search_type: SearchSource[];
|
||||||
|
};
|
||||||
|
onUpdateFilter: (filter: SearchFilter) => void;
|
||||||
|
};
|
||||||
|
export default function SearchFilterDialog({
|
||||||
|
config,
|
||||||
|
filter,
|
||||||
|
filterValues,
|
||||||
|
onUpdateFilter,
|
||||||
|
}: SearchFilterDialogProps) {
|
||||||
|
// data
|
||||||
|
|
||||||
|
const [currentFilter, setCurrentFilter] = useState(filter ?? {});
|
||||||
|
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
|
||||||
|
|
||||||
|
// state
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const moreFiltersSelected = useMemo(
|
||||||
|
() =>
|
||||||
|
currentFilter &&
|
||||||
|
(currentFilter.time_range ||
|
||||||
|
(currentFilter.zones?.length ?? 0) > 0 ||
|
||||||
|
(currentFilter.sub_labels?.length ?? 0) > 0 ||
|
||||||
|
(currentFilter.search_type?.length ?? 2) !== 2),
|
||||||
|
[currentFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
size="sm"
|
||||||
|
variant={moreFiltersSelected ? "select" : "default"}
|
||||||
|
>
|
||||||
|
<FaCog
|
||||||
|
className={cn(
|
||||||
|
moreFiltersSelected ? "text-white" : "text-secondary-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
More Filters
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
const content = (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<TimeRangeFilterContent
|
||||||
|
config={config}
|
||||||
|
timeRange={currentFilter.time_range}
|
||||||
|
updateTimeRange={(newRange) =>
|
||||||
|
setCurrentFilter({ ...currentFilter, time_range: newRange })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ZoneFilterContent
|
||||||
|
allZones={filterValues.zones}
|
||||||
|
zones={currentFilter.zones}
|
||||||
|
updateZones={(newZones) =>
|
||||||
|
setCurrentFilter({ ...currentFilter, zones: newZones })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SubFilterContent
|
||||||
|
allSubLabels={allSubLabels}
|
||||||
|
subLabels={currentFilter.sub_labels}
|
||||||
|
setSubLabels={(newSubLabels) =>
|
||||||
|
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{config?.semantic_search?.enabled &&
|
||||||
|
!currentFilter?.search_type?.includes("similarity") && (
|
||||||
|
<SearchTypeContent
|
||||||
|
searchSources={
|
||||||
|
currentFilter?.search_type ?? ["thumbnail", "description"]
|
||||||
|
}
|
||||||
|
setSearchSources={(newSearchSource) =>
|
||||||
|
setCurrentFilter({
|
||||||
|
...currentFilter,
|
||||||
|
search_type: newSearchSource,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isDesktop && <DropdownMenuSeparator />}
|
||||||
|
<div className="flex items-center justify-evenly p-2">
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentFilter != filter) {
|
||||||
|
onUpdateFilter(currentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentFilter((prevFilter) => ({
|
||||||
|
...prevFilter,
|
||||||
|
time_range: undefined,
|
||||||
|
zones: undefined,
|
||||||
|
sub_labels: undefined,
|
||||||
|
search_type: ["thumbnail", "description"],
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformAwareSheet
|
||||||
|
trigger={trigger}
|
||||||
|
content={content}
|
||||||
|
contentClassName={cn(
|
||||||
|
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
|
||||||
|
isMobileOnly && "pb-20",
|
||||||
|
)}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCurrentFilter(filter ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeRangeFilterContentProps = {
|
||||||
|
config?: FrigateConfig;
|
||||||
|
timeRange?: string;
|
||||||
|
updateTimeRange: (range: string | undefined) => void;
|
||||||
|
};
|
||||||
|
function TimeRangeFilterContent({
|
||||||
|
config,
|
||||||
|
timeRange,
|
||||||
|
updateTimeRange,
|
||||||
|
}: TimeRangeFilterContentProps) {
|
||||||
|
const [startOpen, setStartOpen] = useState(false);
|
||||||
|
const [endOpen, setEndOpen] = useState(false);
|
||||||
|
|
||||||
|
const [afterHour, beforeHour] = useMemo(() => {
|
||||||
|
if (!timeRange || !timeRange.includes(",")) {
|
||||||
|
return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE];
|
||||||
|
}
|
||||||
|
|
||||||
|
return timeRange.split(",");
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour);
|
||||||
|
const [selectedBeforeHour, setSelectedBeforeHour] = useState(beforeHour);
|
||||||
|
|
||||||
|
// format based on locale
|
||||||
|
|
||||||
|
const formattedSelectedAfter = useFormattedHour(config, selectedAfterHour);
|
||||||
|
const formattedSelectedBefore = useFormattedHour(config, selectedBeforeHour);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedAfterHour(afterHour);
|
||||||
|
setSelectedBeforeHour(beforeHour);
|
||||||
|
// only refresh when state changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedAfterHour == DEFAULT_TIME_RANGE_AFTER &&
|
||||||
|
selectedBeforeHour == DEFAULT_TIME_RANGE_BEFORE
|
||||||
|
) {
|
||||||
|
updateTimeRange(undefined);
|
||||||
|
} else {
|
||||||
|
updateTimeRange(`${selectedAfterHour},${selectedBeforeHour}`);
|
||||||
|
}
|
||||||
|
// only refresh when state changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedAfterHour, selectedBeforeHour]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden">
|
||||||
|
<div className="text-lg">Time Range</div>
|
||||||
|
<div className="mt-3 flex flex-row items-center justify-center gap-2">
|
||||||
|
<Popover
|
||||||
|
open={startOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setStartOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={`text-primary ${isDesktop ? "" : "text-xs"} `}
|
||||||
|
variant={startOpen ? "select" : "default"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStartOpen(true);
|
||||||
|
setEndOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedSelectedAfter}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="flex flex-row items-center justify-center">
|
||||||
|
<input
|
||||||
|
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
|
id="startTime"
|
||||||
|
type="time"
|
||||||
|
value={selectedAfterHour}
|
||||||
|
step="60"
|
||||||
|
onChange={(e) => {
|
||||||
|
const clock = e.target.value;
|
||||||
|
const [hour, minute, _] = clock.split(":");
|
||||||
|
setSelectedAfterHour(`${hour}:${minute}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FaArrowRight className="size-4 text-primary" />
|
||||||
|
<Popover
|
||||||
|
open={endOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEndOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
|
||||||
|
variant={endOpen ? "select" : "default"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEndOpen(true);
|
||||||
|
setStartOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedSelectedBefore}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="flex flex-col items-center">
|
||||||
|
<input
|
||||||
|
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
|
id="startTime"
|
||||||
|
type="time"
|
||||||
|
value={
|
||||||
|
selectedBeforeHour == "24:00" ? "23:59" : selectedBeforeHour
|
||||||
|
}
|
||||||
|
step="60"
|
||||||
|
onChange={(e) => {
|
||||||
|
const clock = e.target.value;
|
||||||
|
const [hour, minute, _] = clock.split(":");
|
||||||
|
setSelectedBeforeHour(`${hour}:${minute}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZoneFilterContentProps = {
|
||||||
|
allZones?: string[];
|
||||||
|
zones?: string[];
|
||||||
|
updateZones: (zones: string[] | undefined) => void;
|
||||||
|
};
|
||||||
|
export function ZoneFilterContent({
|
||||||
|
allZones,
|
||||||
|
zones,
|
||||||
|
updateZones,
|
||||||
|
}: ZoneFilterContentProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-hidden">
|
||||||
|
<DropdownMenuSeparator className="mb-3" />
|
||||||
|
<div className="text-lg">Zones</div>
|
||||||
|
{allZones && (
|
||||||
|
<>
|
||||||
|
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
className="mx-2 cursor-pointer text-primary"
|
||||||
|
htmlFor="allZones"
|
||||||
|
>
|
||||||
|
All Zones
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
className="ml-1"
|
||||||
|
id="allZones"
|
||||||
|
checked={zones == undefined}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
updateZones(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5 flex flex-col gap-2.5">
|
||||||
|
{allZones.map((item) => (
|
||||||
|
<FilterSwitch
|
||||||
|
key={item}
|
||||||
|
label={item.replaceAll("_", " ")}
|
||||||
|
isChecked={zones?.includes(item) ?? false}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
const updatedZones = zones ? [...zones] : [];
|
||||||
|
|
||||||
|
updatedZones.push(item);
|
||||||
|
updateZones(updatedZones);
|
||||||
|
} else {
|
||||||
|
const updatedZones = zones ? [...zones] : [];
|
||||||
|
|
||||||
|
// can not deselect the last item
|
||||||
|
if (updatedZones.length > 1) {
|
||||||
|
updatedZones.splice(updatedZones.indexOf(item), 1);
|
||||||
|
updateZones(updatedZones);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubFilterContentProps = {
|
||||||
|
allSubLabels: string[];
|
||||||
|
subLabels: string[] | undefined;
|
||||||
|
setSubLabels: (labels: string[] | undefined) => void;
|
||||||
|
};
|
||||||
|
export function SubFilterContent({
|
||||||
|
allSubLabels,
|
||||||
|
subLabels,
|
||||||
|
setSubLabels,
|
||||||
|
}: SubFilterContentProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-hidden">
|
||||||
|
<DropdownMenuSeparator className="mb-3" />
|
||||||
|
<div className="text-lg">Sub Labels</div>
|
||||||
|
<div className="mb-5 mt-2.5 flex items-center justify-between">
|
||||||
|
<Label className="mx-2 cursor-pointer text-primary" htmlFor="allLabels">
|
||||||
|
All Sub Labels
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
className="ml-1"
|
||||||
|
id="allLabels"
|
||||||
|
checked={subLabels == undefined}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
setSubLabels(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5 flex flex-col gap-2.5">
|
||||||
|
{allSubLabels.map((item) => (
|
||||||
|
<FilterSwitch
|
||||||
|
key={item}
|
||||||
|
label={item.replaceAll("_", " ")}
|
||||||
|
isChecked={subLabels?.includes(item) ?? false}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
const updatedLabels = subLabels ? [...subLabels] : [];
|
||||||
|
|
||||||
|
updatedLabels.push(item);
|
||||||
|
setSubLabels(updatedLabels);
|
||||||
|
} else {
|
||||||
|
const updatedLabels = subLabels ? [...subLabels] : [];
|
||||||
|
|
||||||
|
// can not deselect the last item
|
||||||
|
if (updatedLabels.length > 1) {
|
||||||
|
updatedLabels.splice(updatedLabels.indexOf(item), 1);
|
||||||
|
setSubLabels(updatedLabels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchTypeContentProps = {
|
||||||
|
searchSources: SearchSource[] | undefined;
|
||||||
|
setSearchSources: (sources: SearchSource[] | undefined) => void;
|
||||||
|
};
|
||||||
|
export function SearchTypeContent({
|
||||||
|
searchSources,
|
||||||
|
setSearchSources,
|
||||||
|
}: SearchTypeContentProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-hidden">
|
||||||
|
<DropdownMenuSeparator className="mb-3" />
|
||||||
|
<div className="text-lg">Search Sources</div>
|
||||||
|
<div className="mt-2.5 flex flex-col gap-2.5">
|
||||||
|
<FilterSwitch
|
||||||
|
label="Thumbnail Image"
|
||||||
|
isChecked={searchSources?.includes("thumbnail") ?? false}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
const updatedSources = searchSources ? [...searchSources] : [];
|
||||||
|
|
||||||
|
if (isChecked) {
|
||||||
|
updatedSources.push("thumbnail");
|
||||||
|
setSearchSources(updatedSources);
|
||||||
|
} else {
|
||||||
|
if (updatedSources.length > 1) {
|
||||||
|
const index = updatedSources.indexOf("thumbnail");
|
||||||
|
if (index !== -1) updatedSources.splice(index, 1);
|
||||||
|
setSearchSources(updatedSources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FilterSwitch
|
||||||
|
label="Description"
|
||||||
|
isChecked={searchSources?.includes("description") ?? false}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
const updatedSources = searchSources ? [...searchSources] : [];
|
||||||
|
|
||||||
|
if (isChecked) {
|
||||||
|
updatedSources.push("description");
|
||||||
|
setSearchSources(updatedSources);
|
||||||
|
} else {
|
||||||
|
if (updatedSources.length > 1) {
|
||||||
|
const index = updatedSources.indexOf("description");
|
||||||
|
if (index !== -1) updatedSources.splice(index, 1);
|
||||||
|
setSearchSources(updatedSources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -275,7 +275,7 @@ export default function Explore() {
|
|||||||
<TbExclamationCircle className="mb-3 size-10" />
|
<TbExclamationCircle className="mb-3 size-10" />
|
||||||
<div>Search Unavailable</div>
|
<div>Search Unavailable</div>
|
||||||
</div>
|
</div>
|
||||||
{embeddingsReindexing && (
|
{embeddingsReindexing && allModelsLoaded && (
|
||||||
<>
|
<>
|
||||||
<div className="text-center text-primary-variant">
|
<div className="text-center text-primary-variant">
|
||||||
Search can be used after tracked object embeddings have
|
Search can be used after tracked object embeddings have
|
||||||
@ -384,6 +384,7 @@ export default function Explore() {
|
|||||||
searchFilter={searchFilter}
|
searchFilter={searchFilter}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
|
isLoading={(isLoadingInitialData || isLoadingMore) ?? true}
|
||||||
|
hasMore={!isReachingEnd}
|
||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
setSimilaritySearch={(search) => {
|
setSimilaritySearch={(search) => {
|
||||||
setSearchFilter({
|
setSearchFilter({
|
||||||
@ -395,7 +396,7 @@ export default function Explore() {
|
|||||||
setSearchFilter={setSearchFilter}
|
setSearchFilter={setSearchFilter}
|
||||||
onUpdateFilter={setSearchFilter}
|
onUpdateFilter={setSearchFilter}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
hasMore={!isReachingEnd}
|
refresh={mutate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -29,16 +29,18 @@ import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
|||||||
import { PolygonType } from "@/types/canvas";
|
import { PolygonType } from "@/types/canvas";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
import GeneralSettingsView from "@/views/settings/GeneralSettingsView";
|
|
||||||
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
import CameraSettingsView from "@/views/settings/CameraSettingsView";
|
||||||
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
|
||||||
import MotionTunerView from "@/views/settings/MotionTunerView";
|
import MotionTunerView from "@/views/settings/MotionTunerView";
|
||||||
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
|
||||||
import AuthenticationView from "@/views/settings/AuthenticationView";
|
import AuthenticationView from "@/views/settings/AuthenticationView";
|
||||||
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
import NotificationView from "@/views/settings/NotificationsSettingsView";
|
||||||
|
import SearchSettingsView from "@/views/settings/SearchSettingsView";
|
||||||
|
import UiSettingsView from "@/views/settings/UiSettingsView";
|
||||||
|
|
||||||
const allSettingsViews = [
|
const allSettingsViews = [
|
||||||
"general",
|
"UI settings",
|
||||||
|
"search settings",
|
||||||
"camera settings",
|
"camera settings",
|
||||||
"masks / zones",
|
"masks / zones",
|
||||||
"motion tuner",
|
"motion tuner",
|
||||||
@ -49,7 +51,7 @@ const allSettingsViews = [
|
|||||||
type SettingsType = (typeof allSettingsViews)[number];
|
type SettingsType = (typeof allSettingsViews)[number];
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [page, setPage] = useState<SettingsType>("general");
|
const [page, setPage] = useState<SettingsType>("UI settings");
|
||||||
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100);
|
||||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@ -140,7 +142,7 @@ export default function Settings() {
|
|||||||
{Object.values(settingsViews).map((item) => (
|
{Object.values(settingsViews).map((item) => (
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
key={item}
|
key={item}
|
||||||
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "general" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "UI settings" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||||
value={item}
|
value={item}
|
||||||
data-nav-item={item}
|
data-nav-item={item}
|
||||||
aria-label={`Select ${item}`}
|
aria-label={`Select ${item}`}
|
||||||
@ -172,7 +174,10 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
|
||||||
{page == "general" && <GeneralSettingsView />}
|
{page == "UI settings" && <UiSettingsView />}
|
||||||
|
{page == "search settings" && (
|
||||||
|
<SearchSettingsView setUnsavedChanges={setUnsavedChanges} />
|
||||||
|
)}
|
||||||
{page == "debug" && (
|
{page == "debug" && (
|
||||||
<ObjectSettingsView selectedCamera={selectedCamera} />
|
<ObjectSettingsView selectedCamera={selectedCamera} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export const ATTRIBUTE_LABELS = [
|
|||||||
"ups",
|
"ups",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type SearchModelSize = "small" | "large";
|
||||||
|
|
||||||
export interface CameraConfig {
|
export interface CameraConfig {
|
||||||
audio: {
|
audio: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -340,6 +342,7 @@ export interface FrigateConfig {
|
|||||||
path: string | null;
|
path: string | null;
|
||||||
width: number;
|
width: number;
|
||||||
colormap: { [key: string]: [number, number, number] };
|
colormap: { [key: string]: [number, number, number] };
|
||||||
|
attributes_map: { [key: string]: [string] };
|
||||||
};
|
};
|
||||||
|
|
||||||
motion: Record<string, unknown> | null;
|
motion: Record<string, unknown> | null;
|
||||||
@ -417,7 +420,8 @@ export interface FrigateConfig {
|
|||||||
|
|
||||||
semantic_search: {
|
semantic_search: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
model_size: string;
|
reindex: boolean;
|
||||||
|
model_size: SearchModelSize;
|
||||||
};
|
};
|
||||||
|
|
||||||
snapshots: {
|
snapshots: {
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import SearchThumbnail from "@/components/card/SearchThumbnail";
|
import SearchThumbnail from "@/components/card/SearchThumbnail";
|
||||||
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
|
import SearchFilterGroup from "@/components/filter/SearchFilterGroup";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import Chip from "@/components/indicators/Chip";
|
import SearchDetailDialog, {
|
||||||
import SearchDetailDialog from "@/components/overlay/detail/SearchDetailDialog";
|
SearchTab,
|
||||||
|
} from "@/components/overlay/detail/SearchDetailDialog";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -14,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
|
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { isDesktop, isMobileOnly } from "react-device-detect";
|
import { isDesktop, isMobileOnly } from "react-device-detect";
|
||||||
import { LuColumns, LuImage, LuSearchX, LuText } from "react-icons/lu";
|
import { LuColumns, LuSearchX } from "react-icons/lu";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import ExploreView from "../explore/ExploreView";
|
import ExploreView from "../explore/ExploreView";
|
||||||
import useKeyboardListener, {
|
import useKeyboardListener, {
|
||||||
@ -25,7 +26,6 @@ import InputWithTags from "@/components/input/InputWithTags";
|
|||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
import { isEqual } from "lodash";
|
import { isEqual } from "lodash";
|
||||||
import { formatDateToLocaleString } from "@/utils/dateUtil";
|
import { formatDateToLocaleString } from "@/utils/dateUtil";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@ -33,6 +33,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
|
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
|
||||||
|
|
||||||
type SearchViewProps = {
|
type SearchViewProps = {
|
||||||
search: string;
|
search: string;
|
||||||
@ -40,12 +41,13 @@ type SearchViewProps = {
|
|||||||
searchFilter?: SearchFilter;
|
searchFilter?: SearchFilter;
|
||||||
searchResults?: SearchResult[];
|
searchResults?: SearchResult[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
setSearch: (search: string) => void;
|
setSearch: (search: string) => void;
|
||||||
setSimilaritySearch: (search: SearchResult) => void;
|
setSimilaritySearch: (search: SearchResult) => void;
|
||||||
setSearchFilter: (filter: SearchFilter) => void;
|
setSearchFilter: (filter: SearchFilter) => void;
|
||||||
onUpdateFilter: (filter: SearchFilter) => void;
|
onUpdateFilter: (filter: SearchFilter) => void;
|
||||||
loadMore: () => void;
|
loadMore: () => void;
|
||||||
hasMore: boolean;
|
refresh: () => void;
|
||||||
};
|
};
|
||||||
export default function SearchView({
|
export default function SearchView({
|
||||||
search,
|
search,
|
||||||
@ -53,12 +55,13 @@ export default function SearchView({
|
|||||||
searchFilter,
|
searchFilter,
|
||||||
searchResults,
|
searchResults,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
hasMore,
|
||||||
setSearch,
|
setSearch,
|
||||||
setSimilaritySearch,
|
setSimilaritySearch,
|
||||||
setSearchFilter,
|
setSearchFilter,
|
||||||
onUpdateFilter,
|
onUpdateFilter,
|
||||||
loadMore,
|
loadMore,
|
||||||
hasMore,
|
refresh,
|
||||||
}: SearchViewProps) {
|
}: SearchViewProps) {
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
@ -70,15 +73,17 @@ export default function SearchView({
|
|||||||
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
||||||
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
|
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
|
||||||
|
|
||||||
const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", {
|
const gridClassName = cn(
|
||||||
"sm:grid-cols-2": effectiveColumnCount <= 2,
|
"grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2",
|
||||||
"sm:grid-cols-3": effectiveColumnCount === 3,
|
isMobileOnly && "grid-cols-2",
|
||||||
"sm:grid-cols-4": effectiveColumnCount === 4,
|
{
|
||||||
"sm:grid-cols-5": effectiveColumnCount === 5,
|
"sm:grid-cols-2": effectiveColumnCount <= 2,
|
||||||
"sm:grid-cols-6": effectiveColumnCount === 6,
|
"sm:grid-cols-3": effectiveColumnCount === 3,
|
||||||
"sm:grid-cols-7": effectiveColumnCount === 7,
|
"sm:grid-cols-4": effectiveColumnCount === 4,
|
||||||
"sm:grid-cols-8": effectiveColumnCount >= 8,
|
"sm:grid-cols-5": effectiveColumnCount === 5,
|
||||||
});
|
"sm:grid-cols-6": effectiveColumnCount === 6,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// suggestions values
|
// suggestions values
|
||||||
|
|
||||||
@ -161,16 +166,25 @@ export default function SearchView({
|
|||||||
// detail
|
// detail
|
||||||
|
|
||||||
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
||||||
|
const [page, setPage] = useState<SearchTab>("details");
|
||||||
|
|
||||||
// search interaction
|
// search interaction
|
||||||
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
const onSelectSearch = useCallback((item: SearchResult, index: number) => {
|
const onSelectSearch = useCallback(
|
||||||
setSearchDetail(item);
|
(item: SearchResult, index: number, page: SearchTab = "details") => {
|
||||||
setSelectedIndex(index);
|
setPage(page);
|
||||||
}, []);
|
setSearchDetail(item);
|
||||||
|
setSelectedIndex(index);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [searchTerm, searchFilter]);
|
||||||
|
|
||||||
// update search detail when results change
|
// update search detail when results change
|
||||||
|
|
||||||
@ -187,21 +201,6 @@ export default function SearchView({
|
|||||||
}
|
}
|
||||||
}, [searchResults, searchDetail]);
|
}, [searchResults, searchDetail]);
|
||||||
|
|
||||||
// confidence score
|
|
||||||
|
|
||||||
const zScoreToConfidence = (score: number) => {
|
|
||||||
// Normalizing is not needed for similarity searches
|
|
||||||
// Sigmoid function for normalized: 1 / (1 + e^x)
|
|
||||||
// Cosine for similarity
|
|
||||||
if (searchFilter) {
|
|
||||||
const notNormalized = searchFilter?.search_type?.includes("similarity");
|
|
||||||
|
|
||||||
const confidence = notNormalized ? 1 - score : 1 / (1 + Math.exp(score));
|
|
||||||
|
|
||||||
return Math.round(confidence * 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasExistingSearch = useMemo(
|
const hasExistingSearch = useMemo(
|
||||||
() => searchResults != undefined || searchFilter != undefined,
|
() => searchResults != undefined || searchFilter != undefined,
|
||||||
[searchResults, searchFilter],
|
[searchResults, searchFilter],
|
||||||
@ -310,7 +309,9 @@ export default function SearchView({
|
|||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
<SearchDetailDialog
|
<SearchDetailDialog
|
||||||
search={searchDetail}
|
search={searchDetail}
|
||||||
|
page={page}
|
||||||
setSearch={setSearchDetail}
|
setSearch={setSearchDetail}
|
||||||
|
setSearchPage={setPage}
|
||||||
setSimilarity={
|
setSimilarity={
|
||||||
searchDetail && (() => setSimilaritySearch(searchDetail))
|
searchDetail && (() => setSimilaritySearch(searchDetail))
|
||||||
}
|
}
|
||||||
@ -388,47 +389,31 @@ export default function SearchView({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"aspect-square size-full overflow-hidden rounded-lg",
|
"aspect-square w-full overflow-hidden rounded-t-lg border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SearchThumbnail
|
<SearchThumbnail
|
||||||
|
searchResult={value}
|
||||||
|
onClick={() => onSelectSearch(value, index)}
|
||||||
|
/>
|
||||||
|
</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"}`}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full items-center justify-between rounded-b-lg border border-t-0 bg-card p-3 text-card-foreground">
|
||||||
|
<SearchThumbnailFooter
|
||||||
searchResult={value}
|
searchResult={value}
|
||||||
findSimilar={() => {
|
findSimilar={() => {
|
||||||
if (config?.semantic_search.enabled) {
|
if (config?.semantic_search.enabled) {
|
||||||
setSimilaritySearch(value);
|
setSimilaritySearch(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => onSelectSearch(value, index)}
|
refreshResults={refresh}
|
||||||
|
showObjectLifecycle={() =>
|
||||||
|
onSelectSearch(value, index, "object lifecycle")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{(searchTerm ||
|
|
||||||
searchFilter?.search_type?.includes("similarity")) && (
|
|
||||||
<div className={cn("absolute right-2 top-2 z-40")}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<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`}
|
|
||||||
>
|
|
||||||
{value.search_source == "thumbnail" ? (
|
|
||||||
<LuImage className="mr-1 size-3" />
|
|
||||||
) : (
|
|
||||||
<LuText className="mr-1 size-3" />
|
|
||||||
)}
|
|
||||||
{zScoreToConfidence(value.search_distance)}%
|
|
||||||
</Chip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent>
|
|
||||||
Matched {value.search_source} at{" "}
|
|
||||||
{zScoreToConfidence(value.search_distance)}%
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</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-selected outline-selected` : "outline-transparent duration-500"}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -467,7 +452,7 @@ export default function SearchView({
|
|||||||
<Slider
|
<Slider
|
||||||
value={[effectiveColumnCount]}
|
value={[effectiveColumnCount]}
|
||||||
onValueChange={([value]) => setColumnCount(value)}
|
onValueChange={([value]) => setColumnCount(value)}
|
||||||
max={8}
|
max={6}
|
||||||
min={2}
|
min={2}
|
||||||
step={1}
|
step={1}
|
||||||
className="flex-grow"
|
className="flex-grow"
|
||||||
|
|||||||
288
web/src/views/settings/SearchSettingsView.tsx
Normal file
288
web/src/views/settings/SearchSettingsView.tsx
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import Heading from "@/components/ui/heading";
|
||||||
|
import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import axios from "axios";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
type SearchSettingsViewProps = {
|
||||||
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchSettings = {
|
||||||
|
enabled?: boolean;
|
||||||
|
reindex?: boolean;
|
||||||
|
model_size?: SearchModelSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SearchSettingsView({
|
||||||
|
setUnsavedChanges,
|
||||||
|
}: SearchSettingsViewProps) {
|
||||||
|
const { data: config, mutate: updateConfig } =
|
||||||
|
useSWR<FrigateConfig>("config");
|
||||||
|
const [changedValue, setChangedValue] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
|
||||||
|
const [searchSettings, setSearchSettings] = useState<SearchSettings>({
|
||||||
|
enabled: undefined,
|
||||||
|
reindex: undefined,
|
||||||
|
model_size: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [origSearchSettings, setOrigSearchSettings] = useState<SearchSettings>({
|
||||||
|
enabled: undefined,
|
||||||
|
reindex: undefined,
|
||||||
|
model_size: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
if (searchSettings?.enabled == undefined) {
|
||||||
|
setSearchSettings({
|
||||||
|
enabled: config.semantic_search.enabled,
|
||||||
|
reindex: config.semantic_search.reindex,
|
||||||
|
model_size: config.semantic_search.model_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrigSearchSettings({
|
||||||
|
enabled: config.semantic_search.enabled,
|
||||||
|
reindex: config.semantic_search.reindex,
|
||||||
|
model_size: config.semantic_search.model_size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const handleSearchConfigChange = (newConfig: Partial<SearchSettings>) => {
|
||||||
|
setSearchSettings((prevConfig) => ({ ...prevConfig, ...newConfig }));
|
||||||
|
setUnsavedChanges(true);
|
||||||
|
setChangedValue(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToConfig = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put(
|
||||||
|
`config/set?semantic_search.enabled=${searchSettings.enabled}&semantic_search.reindex=${searchSettings.reindex}&semantic_search.model_size=${searchSettings.model_size}`,
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 200) {
|
||||||
|
toast.success("Search settings have been saved.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
setChangedValue(false);
|
||||||
|
updateConfig();
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to save config changes: ${res.statusText}`, {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
`Failed to save config changes: ${error.response.data.message}`,
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
updateConfig,
|
||||||
|
searchSettings.enabled,
|
||||||
|
searchSettings.reindex,
|
||||||
|
searchSettings.model_size,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onCancel = useCallback(() => {
|
||||||
|
setSearchSettings(origSearchSettings);
|
||||||
|
setChangedValue(false);
|
||||||
|
removeMessage("search_settings", "search_settings");
|
||||||
|
}, [origSearchSettings, removeMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (changedValue) {
|
||||||
|
addMessage(
|
||||||
|
"search_settings",
|
||||||
|
`Unsaved search settings changes`,
|
||||||
|
undefined,
|
||||||
|
"search_settings",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
removeMessage("search_settings", "search_settings");
|
||||||
|
}
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [changedValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Search Settings - Frigate";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
|
<Toaster position="top-center" closeButton={true} />
|
||||||
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||||
|
<Heading as="h3" className="my-2">
|
||||||
|
Search Settings
|
||||||
|
</Heading>
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
Semantic Search
|
||||||
|
</Heading>
|
||||||
|
<div className="max-w-6xl">
|
||||||
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||||
|
<p>
|
||||||
|
Semantic Search in Frigate allows you to find tracked objects
|
||||||
|
within your review items using either the image itself, a
|
||||||
|
user-defined text description, or an automatically generated one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center text-primary">
|
||||||
|
<Link
|
||||||
|
to="https://docs.frigate.video/configuration/semantic_search"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
Read the Documentation
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full max-w-lg flex-col space-y-6">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="enabled"
|
||||||
|
className="mr-3"
|
||||||
|
disabled={searchSettings.enabled === undefined}
|
||||||
|
checked={searchSettings.enabled === true}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
handleSearchConfigChange({ enabled: isChecked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Switch
|
||||||
|
id="reindex"
|
||||||
|
className="mr-3"
|
||||||
|
disabled={searchSettings.reindex === undefined}
|
||||||
|
checked={searchSettings.reindex === true}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
handleSearchConfigChange({ reindex: isChecked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="reindex">Re-Index On Startup</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
|
Re-indexing will reprocess all thumbnails and descriptions (if
|
||||||
|
enabled) and apply the embeddings on each startup.{" "}
|
||||||
|
<em>Don't forget to disable the option after restarting!</em>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-col space-y-6">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-md">Model Size</div>
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
The size of the model used for semantic search embeddings.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 text-sm">
|
||||||
|
<li>
|
||||||
|
Using <em>small</em> employs a quantized version of the
|
||||||
|
model that uses less RAM and runs faster on CPU with a very
|
||||||
|
negligible difference in embedding quality.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Using <em>large</em> employs the full Jina model and will
|
||||||
|
automatically run on the GPU if applicable.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={searchSettings.model_size}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleSearchConfigChange({
|
||||||
|
model_size: value as SearchModelSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-20">
|
||||||
|
{searchSettings.model_size}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{["small", "large"].map((size) => (
|
||||||
|
<SelectItem
|
||||||
|
key={size}
|
||||||
|
className="cursor-pointer"
|
||||||
|
value={size}
|
||||||
|
>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||||
|
<Button className="flex flex-1" onClick={onCancel}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
disabled={!changedValue || isLoading}
|
||||||
|
className="flex flex-1"
|
||||||
|
onClick={saveToConfig}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<span>Saving...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ import {
|
|||||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||||
|
|
||||||
export default function GeneralSettingsView() {
|
export default function UiSettingsView() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
const clearStoredLayouts = useCallback(() => {
|
const clearStoredLayouts = useCallback(() => {
|
||||||
Loading…
Reference in New Issue
Block a user