Implement detail pane

This commit is contained in:
Nicolas Mowen 2024-06-22 15:10:48 -06:00
parent 8d65e56f0e
commit 765bd2c988
4 changed files with 154 additions and 15 deletions

View 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>
);
}

View File

@ -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>

View File

@ -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}
/> />
); );
} }

View File

@ -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