2023-12-14 06:15:28 +03:00
|
|
|
import { useCallback, useMemo, useRef, useState } from "react";
|
2023-12-13 05:48:52 +03:00
|
|
|
import useSWR from "swr";
|
2023-12-14 06:15:28 +03:00
|
|
|
import useSWRInfinite from "swr/infinite";
|
2023-12-13 05:48:52 +03:00
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
2023-12-08 16:33:22 +03:00
|
|
|
import Heading from "@/components/ui/heading";
|
2023-12-13 05:48:52 +03:00
|
|
|
import ActivityIndicator from "@/components/ui/activity-indicator";
|
|
|
|
|
import HistoryCard from "@/components/card/HistoryCard";
|
|
|
|
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
2023-12-14 06:15:28 +03:00
|
|
|
import axios from "axios";
|
2023-12-20 17:33:57 +03:00
|
|
|
import TimelinePlayerCard from "@/components/card/TimelinePlayerCard";
|
|
|
|
|
import { getHourlyTimelineData } from "@/utils/historyUtil";
|
2023-12-21 03:38:06 +03:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from "@/components/ui/alert-dialog";
|
2023-12-14 06:15:28 +03:00
|
|
|
|
2023-12-20 17:33:57 +03:00
|
|
|
const API_LIMIT = 200;
|
2023-12-08 16:33:22 +03:00
|
|
|
|
|
|
|
|
function History() {
|
2023-12-13 05:48:52 +03:00
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
const timezone = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
|
|
|
[config]
|
|
|
|
|
);
|
2023-12-14 06:15:28 +03:00
|
|
|
const timelineFetcher = useCallback((key: any) => {
|
|
|
|
|
const [path, params] = Array.isArray(key) ? key : [key, undefined];
|
|
|
|
|
return axios.get(path, { params }).then((res) => res.data);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const getKey = useCallback((index: number, prevData: HourlyTimeline) => {
|
|
|
|
|
if (index > 0) {
|
|
|
|
|
const lastDate = prevData.end;
|
|
|
|
|
const pagedParams = { before: lastDate, timezone, limit: API_LIMIT };
|
|
|
|
|
return ["timeline/hourly", pagedParams];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ["timeline/hourly", { timezone, limit: API_LIMIT }];
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
data: timelinePages,
|
2023-12-21 03:38:06 +03:00
|
|
|
mutate: updateHistory,
|
2023-12-14 06:15:28 +03:00
|
|
|
size,
|
|
|
|
|
setSize,
|
|
|
|
|
isValidating,
|
|
|
|
|
} = useSWRInfinite<HourlyTimeline>(getKey, timelineFetcher);
|
2023-12-13 05:48:52 +03:00
|
|
|
const { data: allPreviews } = useSWR<Preview[]>(
|
2023-12-16 14:06:02 +03:00
|
|
|
timelinePages
|
|
|
|
|
? `preview/all/start/${timelinePages?.at(0)
|
|
|
|
|
?.start}/end/${timelinePages?.at(-1)?.end}`
|
|
|
|
|
: null,
|
2023-12-13 05:48:52 +03:00
|
|
|
{ revalidateOnFocus: false }
|
|
|
|
|
);
|
|
|
|
|
|
2023-12-16 14:06:02 +03:00
|
|
|
const [detailLevel, _] = useState<"normal" | "extra" | "full">("normal");
|
|
|
|
|
const [playback, setPlayback] = useState<Card | undefined>();
|
|
|
|
|
|
|
|
|
|
const shouldAutoPlay = useMemo(() => {
|
|
|
|
|
return playback == undefined && window.innerWidth < 480;
|
|
|
|
|
}, [playback]);
|
2023-12-13 05:48:52 +03:00
|
|
|
|
|
|
|
|
const timelineCards: CardsData | never[] = useMemo(() => {
|
2023-12-14 06:15:28 +03:00
|
|
|
if (!timelinePages) {
|
2023-12-13 05:48:52 +03:00
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-20 17:33:57 +03:00
|
|
|
return getHourlyTimelineData(timelinePages, detailLevel);
|
2023-12-14 06:15:28 +03:00
|
|
|
}, [detailLevel, timelinePages]);
|
|
|
|
|
|
|
|
|
|
const isDone =
|
|
|
|
|
(timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT;
|
|
|
|
|
|
|
|
|
|
// hooks for infinite scroll
|
|
|
|
|
const observer = useRef<IntersectionObserver | null>();
|
|
|
|
|
const lastTimelineRef = useCallback(
|
|
|
|
|
(node: HTMLElement | null) => {
|
|
|
|
|
if (isValidating) return;
|
|
|
|
|
if (observer.current) observer.current.disconnect();
|
|
|
|
|
try {
|
|
|
|
|
observer.current = new IntersectionObserver((entries) => {
|
|
|
|
|
if (entries[0].isIntersecting && !isDone) {
|
|
|
|
|
setSize(size + 1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (node) observer.current.observe(node);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// no op
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[size, setSize, isValidating, isDone]
|
|
|
|
|
);
|
2023-12-13 05:48:52 +03:00
|
|
|
|
2023-12-21 03:38:06 +03:00
|
|
|
const [itemsToDelete, setItemsToDelete] = useState<string[] | null>(null);
|
|
|
|
|
const onDelete = useCallback(
|
|
|
|
|
async (timeline: Card) => {
|
|
|
|
|
if (timeline.entries.length > 1) {
|
|
|
|
|
const uniqueEvents = new Set(
|
|
|
|
|
timeline.entries.map((entry) => entry.source_id)
|
|
|
|
|
);
|
|
|
|
|
setItemsToDelete(new Array(...uniqueEvents));
|
|
|
|
|
} else {
|
|
|
|
|
const response = await axios.delete(
|
|
|
|
|
`events/${timeline.entries[0].source_id}`
|
|
|
|
|
);
|
|
|
|
|
if (response.status === 200) {
|
|
|
|
|
updateHistory();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[updateHistory]
|
|
|
|
|
);
|
|
|
|
|
const onDeleteMulti = useCallback(async () => {
|
|
|
|
|
if (!itemsToDelete) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const responses = itemsToDelete.map(async (id) => {
|
|
|
|
|
return axios.delete(`events/${id}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if ((await responses[0]).status == 200) {
|
|
|
|
|
updateHistory();
|
|
|
|
|
setItemsToDelete(null);
|
|
|
|
|
}
|
|
|
|
|
}, [itemsToDelete, updateHistory]);
|
|
|
|
|
|
2023-12-16 14:06:02 +03:00
|
|
|
if (!config || !timelineCards || timelineCards.length == 0) {
|
2023-12-13 05:48:52 +03:00
|
|
|
return <ActivityIndicator />;
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-08 16:33:22 +03:00
|
|
|
return (
|
|
|
|
|
<>
|
2023-12-13 05:48:52 +03:00
|
|
|
<Heading as="h2">Review</Heading>
|
|
|
|
|
|
2023-12-21 03:38:06 +03:00
|
|
|
<AlertDialog
|
|
|
|
|
open={itemsToDelete != null}
|
|
|
|
|
onOpenChange={(_) => setItemsToDelete(null)}
|
|
|
|
|
>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>{`Delete ${itemsToDelete?.length} events?`}</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
This will delete all events associated with these objects.
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel onClick={() => setItemsToDelete(null)}>
|
|
|
|
|
Cancel
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
className="bg-red-500"
|
|
|
|
|
onClick={() => onDeleteMulti()}
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
2023-12-16 14:06:02 +03:00
|
|
|
<TimelinePlayerCard
|
|
|
|
|
timeline={playback}
|
|
|
|
|
onDismiss={() => setPlayback(undefined)}
|
|
|
|
|
/>
|
|
|
|
|
|
2023-12-13 05:48:52 +03:00
|
|
|
<div>
|
|
|
|
|
{Object.entries(timelineCards)
|
|
|
|
|
.reverse()
|
2023-12-14 06:15:28 +03:00
|
|
|
.map(([day, timelineDay], dayIdx) => {
|
2023-12-13 05:48:52 +03:00
|
|
|
return (
|
|
|
|
|
<div key={day}>
|
2023-12-20 17:33:57 +03:00
|
|
|
<Heading
|
|
|
|
|
className="sticky py-2 -top-4 left-0 bg-background w-full z-10"
|
|
|
|
|
as="h3"
|
|
|
|
|
>
|
2023-12-13 05:48:52 +03:00
|
|
|
{formatUnixTimestampToDateTime(parseInt(day), {
|
|
|
|
|
strftime_fmt: "%A %b %d",
|
2023-12-16 19:20:59 +03:00
|
|
|
time_style: "medium",
|
|
|
|
|
date_style: "medium",
|
2023-12-13 05:48:52 +03:00
|
|
|
})}
|
|
|
|
|
</Heading>
|
2023-12-14 06:15:28 +03:00
|
|
|
{Object.entries(timelineDay).map(
|
|
|
|
|
([hour, timelineHour], hourIdx) => {
|
|
|
|
|
if (Object.values(timelineHour).length == 0) {
|
|
|
|
|
return <div key={hour}></div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lastRow =
|
|
|
|
|
dayIdx == Object.values(timelineCards).length - 1 &&
|
|
|
|
|
hourIdx == Object.values(timelineDay).length - 1;
|
|
|
|
|
const previewMap: { [key: string]: Preview | undefined } =
|
|
|
|
|
{};
|
2023-12-13 05:48:52 +03:00
|
|
|
|
2023-12-14 06:15:28 +03:00
|
|
|
return (
|
|
|
|
|
<div key={hour} ref={lastRow ? lastTimelineRef : null}>
|
|
|
|
|
<Heading as="h4">
|
|
|
|
|
{formatUnixTimestampToDateTime(parseInt(hour), {
|
2023-12-20 17:33:57 +03:00
|
|
|
strftime_fmt:
|
|
|
|
|
config.ui.time_format == "24hour"
|
|
|
|
|
? "%H:00"
|
|
|
|
|
: "%I:00 %p",
|
2023-12-16 19:20:59 +03:00
|
|
|
time_style: "medium",
|
|
|
|
|
date_style: "medium",
|
2023-12-14 06:15:28 +03:00
|
|
|
})}
|
|
|
|
|
</Heading>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap">
|
2023-12-16 19:20:59 +03:00
|
|
|
{Object.entries(timelineHour)
|
|
|
|
|
.reverse()
|
|
|
|
|
.map(([key, timeline]) => {
|
2023-12-14 06:15:28 +03:00
|
|
|
const startTs = Object.values(timeline.entries)[0]
|
|
|
|
|
.timestamp;
|
|
|
|
|
let relevantPreview = previewMap[timeline.camera];
|
|
|
|
|
|
|
|
|
|
if (relevantPreview == undefined) {
|
|
|
|
|
relevantPreview = previewMap[timeline.camera] =
|
|
|
|
|
Object.values(allPreviews || []).find(
|
|
|
|
|
(preview) =>
|
|
|
|
|
preview.camera == timeline.camera &&
|
|
|
|
|
preview.start < startTs &&
|
|
|
|
|
preview.end > startTs
|
|
|
|
|
);
|
|
|
|
|
}
|
2023-12-20 17:33:57 +03:00
|
|
|
|
2023-12-13 05:48:52 +03:00
|
|
|
return (
|
|
|
|
|
<HistoryCard
|
|
|
|
|
key={key}
|
|
|
|
|
timeline={timeline}
|
2023-12-14 06:15:28 +03:00
|
|
|
shouldAutoPlay={shouldAutoPlay}
|
|
|
|
|
relevantPreview={relevantPreview}
|
2023-12-16 14:06:02 +03:00
|
|
|
onClick={() => {
|
|
|
|
|
setPlayback(timeline);
|
|
|
|
|
}}
|
2023-12-21 03:38:06 +03:00
|
|
|
onDelete={() => onDelete(timeline)}
|
2023-12-13 05:48:52 +03:00
|
|
|
/>
|
|
|
|
|
);
|
2023-12-16 19:20:59 +03:00
|
|
|
})}
|
2023-12-13 05:48:52 +03:00
|
|
|
</div>
|
2023-12-14 06:15:28 +03:00
|
|
|
{lastRow && <ActivityIndicator />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
)}
|
2023-12-13 05:48:52 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2023-12-08 16:33:22 +03:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default History;
|