mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-12 22:25:24 +03:00
Implement detail pane
This commit is contained in:
parent
8d65e56f0e
commit
765bd2c988
122
web/src/components/overlay/SearchDetailDialog.tsx
Normal file
122
web/src/components/overlay/SearchDetailDialog.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { isDesktop, isIOS } from "react-device-detect";
|
||||||
|
import { Sheet, SheetContent } from "../ui/sheet";
|
||||||
|
import { Drawer, DrawerContent } from "../ui/drawer";
|
||||||
|
import { SearchResult } from "@/types/search";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
|
import { useApiHost } from "@/api";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type SearchDetailDialogProps = {
|
||||||
|
search?: SearchResult;
|
||||||
|
setSearch: (search: SearchResult | undefined) => void;
|
||||||
|
};
|
||||||
|
export default function SearchDetailDialog({
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
}: SearchDetailDialogProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
|
// data
|
||||||
|
|
||||||
|
const [desc, setDesc] = useState(search?.description);
|
||||||
|
const formattedDate = useFormattedTimestamp(
|
||||||
|
search?.start_time ?? 0,
|
||||||
|
config?.ui.time_format == "24hour"
|
||||||
|
? "%b %-d %Y, %H:%M"
|
||||||
|
: "%b %-d %Y, %I:%M %p",
|
||||||
|
);
|
||||||
|
|
||||||
|
// content
|
||||||
|
|
||||||
|
const Overlay = isDesktop ? Sheet : Drawer;
|
||||||
|
const Content = isDesktop ? SheetContent : DrawerContent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay
|
||||||
|
open={search != undefined}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Content
|
||||||
|
className={
|
||||||
|
isDesktop ? "sm:max-w-xl" : "max-h-[75dvh] overflow-hidden p-2 pb-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{search && (
|
||||||
|
<div className="flex size-full flex-col gap-5">
|
||||||
|
<div className="flex w-full flex-row">
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Label</div>
|
||||||
|
<div className="flex flex-row items-center gap-2 text-sm capitalize">
|
||||||
|
{getIconForLabel(search.label, "size-4 text-white")}
|
||||||
|
{search.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Score</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{Math.round(search.score * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Camera</div>
|
||||||
|
<div className="text-sm capitalize">
|
||||||
|
{search.camera.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Timestamp</div>
|
||||||
|
<div className="text-sm">{formattedDate}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-2 px-6">
|
||||||
|
<img
|
||||||
|
className="aspect-video select-none transition-opacity"
|
||||||
|
style={
|
||||||
|
isIOS
|
||||||
|
? {
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
WebkitTouchCallout: "none",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
draggable={false}
|
||||||
|
src={
|
||||||
|
search.thumb_path
|
||||||
|
? `${apiHost}${search.thumb_path.replace("/media/frigate/", "")}`
|
||||||
|
: `${apiHost}api/events/${search.id}/thumbnail.jpg`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button>Find Similar</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Description</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Description of the event"
|
||||||
|
value={desc}
|
||||||
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex w-full flex-row justify-end">
|
||||||
|
<Button variant="select">Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -244,6 +244,7 @@ export default function SearchThumbnailPlayer({
|
|||||||
<>
|
<>
|
||||||
<Chip
|
<Chip
|
||||||
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} "bg-gray-500 z-0 bg-gradient-to-br from-gray-400 to-gray-500`}
|
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} "bg-gray-500 z-0 bg-gradient-to-br from-gray-400 to-gray-500`}
|
||||||
|
onClick={() => onClick(searchResult, true)}
|
||||||
>
|
>
|
||||||
<LuInfo className="size-3" />
|
<LuInfo className="size-3" />
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|||||||
@ -89,17 +89,13 @@ export default function Search() {
|
|||||||
|
|
||||||
// selection
|
// selection
|
||||||
|
|
||||||
const onSelectSearch = useCallback(
|
const onOpenSearch = useCallback(
|
||||||
(item: SearchResult, detail: boolean) => {
|
(item: SearchResult) => {
|
||||||
if (detail) {
|
setRecording({
|
||||||
// TODO open detail
|
camera: item.camera,
|
||||||
} else {
|
startTime: item.start_time,
|
||||||
setRecording({
|
severity: "alert",
|
||||||
camera: item.camera,
|
});
|
||||||
startTime: item.start_time,
|
|
||||||
severity: "alert",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[setRecording],
|
[setRecording],
|
||||||
);
|
);
|
||||||
@ -169,7 +165,7 @@ export default function Search() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
onUpdateFilter={onUpdateFilter}
|
onUpdateFilter={onUpdateFilter}
|
||||||
onSelectItem={onSelectSearch}
|
onOpenSearch={onOpenSearch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
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 SearchDetailDialog from "@/components/overlay/SearchDetailDialog";
|
||||||
import SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer";
|
import SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import { SearchFilter, SearchResult } from "@/types/search";
|
import { SearchFilter, SearchResult } from "@/types/search";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import { LuSearchCheck, LuSearchX } from "react-icons/lu";
|
import { LuSearchCheck, LuSearchX } from "react-icons/lu";
|
||||||
|
|
||||||
type SearchViewProps = {
|
type SearchViewProps = {
|
||||||
@ -17,7 +19,7 @@ type SearchViewProps = {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
setSearch: (search: string) => void;
|
setSearch: (search: string) => void;
|
||||||
onUpdateFilter: (filter: SearchFilter) => void;
|
onUpdateFilter: (filter: SearchFilter) => void;
|
||||||
onSelectItem: (item: SearchResult, detail: boolean) => void;
|
onOpenSearch: (item: SearchResult) => void;
|
||||||
};
|
};
|
||||||
export default function SearchView({
|
export default function SearchView({
|
||||||
search,
|
search,
|
||||||
@ -28,11 +30,29 @@ export default function SearchView({
|
|||||||
isLoading,
|
isLoading,
|
||||||
setSearch,
|
setSearch,
|
||||||
onUpdateFilter,
|
onUpdateFilter,
|
||||||
onSelectItem,
|
onOpenSearch,
|
||||||
}: SearchViewProps) {
|
}: SearchViewProps) {
|
||||||
|
// detail
|
||||||
|
|
||||||
|
const [searchDetail, setSearchDetail] = useState<SearchResult>();
|
||||||
|
|
||||||
|
// search interaction
|
||||||
|
|
||||||
|
const onSelectSearch = useCallback(
|
||||||
|
(item: SearchResult, detail: boolean) => {
|
||||||
|
if (detail) {
|
||||||
|
setSearchDetail(item);
|
||||||
|
} else {
|
||||||
|
onOpenSearch(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onOpenSearch],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col pt-2 md:py-2">
|
<div className="flex size-full flex-col pt-2 md:py-2">
|
||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
|
<SearchDetailDialog search={searchDetail} setSearch={setSearchDetail} />
|
||||||
|
|
||||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
|
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:pl-3">
|
||||||
<Input
|
<Input
|
||||||
@ -83,7 +103,7 @@ export default function SearchView({
|
|||||||
searchResult={value}
|
searchResult={value}
|
||||||
allPreviews={allPreviews}
|
allPreviews={allPreviews}
|
||||||
scrollLock={false}
|
scrollLock={false}
|
||||||
onClick={(item, detail) => onSelectItem(item, detail)}
|
onClick={onSelectSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user