Redesign Recordings View (#10690)

* Use full width top bar

* Make each item in review filter group optional

* Remove export creation from export page

* Consolidate packages and fix opening recording from event

* Use common type for time range

* Move timeline to separate component

* Add events list view to recordings view

* Fix loading of images

* Fix incorrect labels

* use overlay state for selected timeline type

* Fix up for mobile view for now

* replace overlay state

* fix comparison

* remove unused
This commit is contained in:
Nicolas Mowen
2024-03-26 15:03:58 -06:00
committed by GitHub
parent 1cd374d3ad
commit 1377d33e25
16 changed files with 378 additions and 363 deletions

View File

@@ -0,0 +1,90 @@
import { baseUrl } from "@/api/baseUrl";
import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCallback, useMemo, useState } from "react";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom";
import { Skeleton } from "../ui/skeleton";
import { RecordingStartingPoint } from "@/types/record";
import axios from "axios";
type AnimatedEventCardProps = {
event: ReviewSegment;
};
export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
// interaction
const navigate = useNavigate();
const onOpenReview = useCallback(() => {
navigate("events", {
state: {
severity: event.severity,
recording: {
camera: event.camera,
startTime: event.start_time,
severity: event.severity,
} as RecordingStartingPoint,
},
});
axios.post(`reviews/viewed`, { ids: [event.id] });
}, [navigate, event]);
// image behavior
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(0);
const imageUrl = useMemo(() => {
if (error > 0) {
return `${baseUrl}api/review/${event.id}/preview.gif?key=${error}`;
}
return `${baseUrl}api/review/${event.id}/preview.gif`;
}, [error, event]);
const aspectRatio = useMemo(() => {
if (!config) {
return 1;
}
const detect = config.cameras[event.camera].detect;
return detect.width / detect.height;
}, [config, event]);
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="h-24 relative"
style={{
aspectRatio: aspectRatio,
}}
>
<img
className="size-full rounded object-cover object-center cursor-pointer"
src={imageUrl}
onClick={onOpenReview}
onLoad={() => setLoaded(true)}
onError={() => {
if (error < 2) {
setError(error + 1);
}
}}
/>
{!loaded && <Skeleton className="absolute inset-0" />}
<div className="absolute bottom-0 inset-x-0 h-6 bg-gradient-to-t from-slate-900/50 to-transparent rounded">
<div className="w-full absolute left-1 bottom-0 text-xs text-white">
<TimeAgo time={event.start_time * 1000} dense />
</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
{`${[...event.data.objects, ...event.data.audio, ...(event.data.sub_labels || [])].join(", ")} detected`}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,73 @@
import { baseUrl } from "@/api/baseUrl";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
import { isSafari } from "react-device-detect";
import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo";
import { useMemo } from "react";
import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
type ReviewCardProps = {
event: ReviewSegment;
currentTime: number;
onClick?: () => void;
};
export default function ReviewCard({
event,
currentTime,
onClick,
}: ReviewCardProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const formattedDate = useFormattedTimestamp(
event.start_time,
config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p",
);
const isSelected = useMemo(
() => event.start_time <= currentTime && event.end_time >= currentTime,
[event, currentTime],
);
return (
<div
className="w-full flex flex-col gap-1.5 cursor-pointer"
onClick={onClick}
>
<ImageLoadingIndicator
className="size-full aspect-video"
imgLoaded={imgLoaded}
/>
<img
ref={imgRef}
className={`size-full rounded-lg ${isSelected ? "outline outline-3 outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"}
onLoad={() => {
onImgLoad();
}}
/>
<div className="flex justify-between items-center">
<div className="flex justify-evenly items-center gap-1">
{event.data.objects.map((object) => {
return getIconForLabel(object, "size-3 text-white");
})}
{event.data.audio.map((audio) => {
return getIconForLabel(audio, "size-3 text-white");
})}
{event.data.sub_labels?.map((sub) => {
return getIconForSubLabel(sub, "size-3 text-white");
})}
<div className="font-extra-light text-xs">{formattedDate}</div>
</div>
<TimeAgo
className="text-xs text-muted-foreground"
time={event.start_time * 1000}
dense
/>
</div>
</div>
);
}