mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-14 00:17:05 +01:00
Implement Review Filters (#10031)
* Get cameras filter working * Implement label and review filters * Fix * Add time selection * Cleanup * Cleanup * cleanup * remove commented code * Fix
This commit is contained in:
parent
4a7c159a44
commit
9801534f11
@ -2395,7 +2395,9 @@ def vod_event(id):
|
|||||||
|
|
||||||
@bp.route("/review")
|
@bp.route("/review")
|
||||||
def review():
|
def review():
|
||||||
camera = request.args.get("camera", "all")
|
cameras = request.args.get("cameras", "all")
|
||||||
|
labels = request.args.get("labels", "all")
|
||||||
|
reviewed = request.args.get("reviewed", default=False)
|
||||||
limit = request.args.get("limit", 100)
|
limit = request.args.get("limit", 100)
|
||||||
severity = request.args.get("severity", None)
|
severity = request.args.get("severity", None)
|
||||||
|
|
||||||
@ -2406,8 +2408,26 @@ def review():
|
|||||||
|
|
||||||
clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))]
|
clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))]
|
||||||
|
|
||||||
if camera != "all":
|
if cameras != "all":
|
||||||
clauses.append((ReviewSegment.camera == camera))
|
camera_list = cameras.split(",")
|
||||||
|
clauses.append((ReviewSegment.camera << camera_list))
|
||||||
|
|
||||||
|
if labels != "all":
|
||||||
|
# use matching so segments with multiple labels
|
||||||
|
# still match on a search where any label matches
|
||||||
|
label_clauses = []
|
||||||
|
filtered_labels = labels.split(",")
|
||||||
|
|
||||||
|
for label in filtered_labels:
|
||||||
|
label_clauses.append(
|
||||||
|
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||||
|
)
|
||||||
|
|
||||||
|
label_clause = reduce(operator.or_, label_clauses)
|
||||||
|
clauses.append((label_clause))
|
||||||
|
|
||||||
|
if not reviewed:
|
||||||
|
clauses.append((ReviewSegment.has_been_reviewed == False))
|
||||||
|
|
||||||
if severity:
|
if severity:
|
||||||
clauses.append((ReviewSegment.severity == severity))
|
clauses.append((ReviewSegment.severity == severity))
|
||||||
|
@ -1,144 +0,0 @@
|
|||||||
import { getTimelineItemDescription } from "@/utils/timelineUtil";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import Logo from "../Logo";
|
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import VideoPlayer from "../player/VideoPlayer";
|
|
||||||
import { Card } from "../ui/card";
|
|
||||||
import { useApiHost } from "@/api";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "../ui/alert-dialog";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
type TimelineItemCardProps = {
|
|
||||||
timeline: Timeline;
|
|
||||||
relevantPreview: Preview | undefined;
|
|
||||||
onSelect: () => void;
|
|
||||||
};
|
|
||||||
export default function TimelineItemCard({
|
|
||||||
timeline,
|
|
||||||
relevantPreview,
|
|
||||||
onSelect,
|
|
||||||
}: TimelineItemCardProps) {
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
const apiHost = useApiHost();
|
|
||||||
|
|
||||||
const onSubmitToPlus = useCallback(
|
|
||||||
async (falsePositive: boolean) => {
|
|
||||||
falsePositive
|
|
||||||
? await axios.put(`events/${timeline.source_id}/false_positive`)
|
|
||||||
: await axios.post(`events/${timeline.source_id}/plus`, {
|
|
||||||
include_annotation: 1,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[timeline]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className="relative mx-2 mb-2 flex w-full h-20 xl:h-24 3xl:h-28 4xl:h-36 cursor-pointer"
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
<div className="w-32 xl:w-40 3xl:w-44 4xl:w-60 p-2">
|
|
||||||
<VideoPlayer
|
|
||||||
options={{
|
|
||||||
preload: "auto",
|
|
||||||
autoplay: true,
|
|
||||||
controls: false,
|
|
||||||
aspectRatio: "16:9",
|
|
||||||
muted: true,
|
|
||||||
loadingSpinner: false,
|
|
||||||
poster: relevantPreview
|
|
||||||
? ""
|
|
||||||
: `${apiHost}api/preview/${timeline.camera}/${timeline.timestamp}/thumbnail.jpg`,
|
|
||||||
sources: relevantPreview
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
src: `${relevantPreview.src}`,
|
|
||||||
type: "video/mp4",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
}}
|
|
||||||
seekOptions={{}}
|
|
||||||
onReady={(player) => {
|
|
||||||
if (relevantPreview) {
|
|
||||||
player.pause(); // autoplay + pause is required for iOS
|
|
||||||
player.currentTime(timeline.timestamp - relevantPreview.start);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="py-1">
|
|
||||||
<div className="capitalize font-semibold text-sm">
|
|
||||||
{getTimelineItemDescription(timeline)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
{formatUnixTimestampToDateTime(timeline.timestamp, {
|
|
||||||
strftime_fmt:
|
|
||||||
config?.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S %p",
|
|
||||||
time_style: "medium",
|
|
||||||
date_style: "medium",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{timeline.source == "tracked_object" && (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="absolute bottom-1 right-1 hidden xl:flex"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8">
|
|
||||||
<Logo />
|
|
||||||
</div>
|
|
||||||
+
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Submit To Frigate+</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Objects in locations you want to avoid are not false
|
|
||||||
positives. Submitting them as false positives will confuse the
|
|
||||||
model.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<img
|
|
||||||
className="flex-grow-0"
|
|
||||||
src={`${apiHost}api/events/${timeline.source_id}/snapshot.jpg`}
|
|
||||||
alt={`${timeline.data.label}`}
|
|
||||||
/>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-success"
|
|
||||||
onClick={() => onSubmitToPlus(false)}
|
|
||||||
>
|
|
||||||
This is a {timeline.data.label}
|
|
||||||
</AlertDialogAction>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-danger"
|
|
||||||
onClick={() => onSubmitToPlus(true)}
|
|
||||||
>
|
|
||||||
This is not a {timeline.data.label}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,307 +0,0 @@
|
|||||||
import { LuCheck, LuFilter } from "react-icons/lu";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
|
||||||
import useSWR from "swr";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "../ui/dropdown-menu";
|
|
||||||
import { Calendar } from "../ui/calendar";
|
|
||||||
|
|
||||||
type HistoryFilterPopoverProps = {
|
|
||||||
// @ts-ignore
|
|
||||||
filter: HistoryFilter | undefined;
|
|
||||||
// @ts-ignore
|
|
||||||
onUpdateFilter: (filter: HistoryFilter) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HistoryFilterPopover({
|
|
||||||
filter,
|
|
||||||
onUpdateFilter,
|
|
||||||
}: HistoryFilterPopoverProps) {
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const disabledDates = useMemo(() => {
|
|
||||||
const tomorrow = new Date();
|
|
||||||
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
|
||||||
const future = new Date();
|
|
||||||
future.setFullYear(2032);
|
|
||||||
return { from: tomorrow, to: future };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data: allLabels } = useSWR<string[]>(["labels"], {
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
});
|
|
||||||
const { data: allSubLabels } = useSWR<string[]>(
|
|
||||||
["sub_labels", { split_joined: 1 }],
|
|
||||||
{
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const filterValues = useMemo(
|
|
||||||
() => ({
|
|
||||||
cameras: Object.keys(config?.cameras || {}),
|
|
||||||
labels: Object.values(allLabels || {}),
|
|
||||||
}),
|
|
||||||
[config, allLabels, allSubLabels]
|
|
||||||
);
|
|
||||||
const [selectedFilters, setSelectedFilters] = useState({
|
|
||||||
cameras: filter == undefined ? ["all"] : filter.cameras,
|
|
||||||
labels: filter == undefined ? ["all"] : filter.labels,
|
|
||||||
before: filter?.before,
|
|
||||||
after: filter?.after,
|
|
||||||
detailLevel: filter?.detailLevel ?? "normal",
|
|
||||||
});
|
|
||||||
const dateRange = useMemo(() => {
|
|
||||||
return selectedFilters?.before == undefined ||
|
|
||||||
selectedFilters?.after == undefined
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
from: new Date(selectedFilters.after * 1000),
|
|
||||||
to: new Date(selectedFilters.before * 1000),
|
|
||||||
};
|
|
||||||
}, [selectedFilters]);
|
|
||||||
|
|
||||||
const allItems = useMemo(() => {
|
|
||||||
return {
|
|
||||||
cameras:
|
|
||||||
JSON.stringify(selectedFilters.cameras) == JSON.stringify(["all"]),
|
|
||||||
labels: JSON.stringify(selectedFilters.labels) == JSON.stringify(["all"]),
|
|
||||||
};
|
|
||||||
}, [selectedFilters]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={(open) => setOpen(open)}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<LuFilter className="mx-1" />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-screen sm:w-[340px]">
|
|
||||||
<div className="flex justify-around">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button className="capitalize" variant="outline">
|
|
||||||
{allItems.cameras
|
|
||||||
? "All Cameras"
|
|
||||||
: `${selectedFilters.cameras.length} Cameras`}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuLabel>Filter Cameras</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<FilterCheckBox
|
|
||||||
isChecked={allItems.cameras}
|
|
||||||
label="All Cameras"
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
cameras: ["all"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{filterValues.cameras.map((item) => (
|
|
||||||
<FilterCheckBox
|
|
||||||
key={item}
|
|
||||||
isChecked={selectedFilters.cameras.includes(item)}
|
|
||||||
label={item.replaceAll("_", " ")}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
const selectedCameras = allItems.cameras
|
|
||||||
? []
|
|
||||||
: [...selectedFilters.cameras];
|
|
||||||
selectedCameras.push(item);
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
cameras: selectedCameras,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const selectedCameraList = [...selectedFilters.cameras];
|
|
||||||
|
|
||||||
// can not deselect the last item
|
|
||||||
if (selectedCameraList.length > 1) {
|
|
||||||
selectedCameraList.splice(
|
|
||||||
selectedCameraList.indexOf(item),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
cameras: selectedCameraList,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button className="capitalize" variant="outline">
|
|
||||||
{allItems.labels
|
|
||||||
? "All Labels"
|
|
||||||
: `${selectedFilters.labels.length} Labels`}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuLabel>Filter Labels</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<FilterCheckBox
|
|
||||||
isChecked={allItems.labels}
|
|
||||||
label="All Labels"
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
labels: ["all"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{filterValues.labels.map((item) => (
|
|
||||||
<FilterCheckBox
|
|
||||||
key={item}
|
|
||||||
isChecked={
|
|
||||||
selectedFilters.labels.length == 0 ||
|
|
||||||
selectedFilters.labels.includes(item)
|
|
||||||
}
|
|
||||||
label={item.replaceAll("_", " ")}
|
|
||||||
onCheckedChange={(isChecked) => {
|
|
||||||
if (isChecked) {
|
|
||||||
const selectedLabels = allItems.labels
|
|
||||||
? []
|
|
||||||
: [...selectedFilters.labels];
|
|
||||||
selectedLabels.push(item);
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
labels: selectedLabels,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const selectedLabelList = [...selectedFilters.labels];
|
|
||||||
|
|
||||||
// can not deselect the last item
|
|
||||||
if (selectedLabelList.length > 1) {
|
|
||||||
selectedLabelList.splice(
|
|
||||||
selectedLabelList.indexOf(item),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
labels: selectedLabelList,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button className="capitalize" variant="outline">
|
|
||||||
{selectedFilters.detailLevel}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
Detail Level
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={selectedFilters.detailLevel}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
// @ts-ignore we know that value is one of the detailLevel
|
|
||||||
detailLevel: value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="normal">
|
|
||||||
Normal
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="extra">
|
|
||||||
Extra
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
<DropdownMenuRadioItem value="full">Full</DropdownMenuRadioItem>
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
disabled={disabledDates}
|
|
||||||
selected={dateRange}
|
|
||||||
onSelect={(range) => {
|
|
||||||
let afterTime = undefined;
|
|
||||||
if (range?.from != undefined) {
|
|
||||||
afterTime = range.from.getTime() / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
// need to make sure the day selected for before covers the entire day
|
|
||||||
let beforeTime = undefined;
|
|
||||||
if (range?.from != undefined) {
|
|
||||||
const beforeDate = range.to ?? range.from;
|
|
||||||
beforeDate.setHours(beforeDate.getHours() + 24, -1, 0, 0);
|
|
||||||
beforeTime = beforeDate.getTime() / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedFilters({
|
|
||||||
...selectedFilters,
|
|
||||||
after: afterTime,
|
|
||||||
before: beforeTime,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
onUpdateFilter(selectedFilters);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type FilterCheckBoxProps = {
|
|
||||||
label: string;
|
|
||||||
isChecked: boolean;
|
|
||||||
onCheckedChange: (isChecked: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function FilterCheckBox({
|
|
||||||
label,
|
|
||||||
isChecked,
|
|
||||||
onCheckedChange,
|
|
||||||
}: FilterCheckBoxProps) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className="capitalize flex justify-between items-center cursor-pointer w-full"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(_) => onCheckedChange(!isChecked)}
|
|
||||||
>
|
|
||||||
{isChecked ? (
|
|
||||||
<LuCheck className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<div className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
<div className="ml-1 w-full flex justify-start">{label}</div>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
371
web/src/components/filter/ReviewFilterGroup.tsx
Normal file
371
web/src/components/filter/ReviewFilterGroup.tsx
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
import { LuCalendar, LuCheck, LuFilter, LuVideo } from "react-icons/lu";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { Calendar } from "../ui/calendar";
|
||||||
|
import { ReviewFilter } from "@/types/review";
|
||||||
|
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
|
||||||
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
|
|
||||||
|
const ATTRIBUTES = ["amazon", "face", "fedex", "license_plate", "ups"];
|
||||||
|
|
||||||
|
type ReviewFilterGroupProps = {
|
||||||
|
filter?: ReviewFilter;
|
||||||
|
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReviewFilterGroup({
|
||||||
|
filter,
|
||||||
|
onUpdateFilter,
|
||||||
|
}: ReviewFilterGroupProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const allLabels = useMemo<string[]>(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = new Set<string>();
|
||||||
|
const cameras = filter?.cameras || Object.keys(config.cameras);
|
||||||
|
|
||||||
|
cameras.forEach((camera) => {
|
||||||
|
config.cameras[camera].objects.track.forEach((label) => {
|
||||||
|
if (!ATTRIBUTES.includes(label)) {
|
||||||
|
labels.add(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...labels];
|
||||||
|
}, [config, filter]);
|
||||||
|
|
||||||
|
const filterValues = useMemo(
|
||||||
|
() => ({
|
||||||
|
cameras: Object.keys(config?.cameras || {}),
|
||||||
|
labels: Object.values(allLabels || {}),
|
||||||
|
}),
|
||||||
|
[config, allLabels]
|
||||||
|
);
|
||||||
|
|
||||||
|
// handle updating filters
|
||||||
|
|
||||||
|
const onUpdateSelectedDay = useCallback(
|
||||||
|
(day?: Date) => {
|
||||||
|
onUpdateFilter({
|
||||||
|
...filter,
|
||||||
|
after: day == undefined ? undefined : day.getTime() / 1000,
|
||||||
|
before: day == undefined ? undefined : getEndOfDayTimestamp(day),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onUpdateFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mr-2">
|
||||||
|
<CamerasFilterButton
|
||||||
|
allCameras={filterValues.cameras}
|
||||||
|
selectedCameras={filter?.cameras}
|
||||||
|
updateCameraFilter={(newCameras) => {
|
||||||
|
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CalendarFilterButton
|
||||||
|
day={
|
||||||
|
filter?.after == undefined ? undefined : new Date(filter.after * 1000)
|
||||||
|
}
|
||||||
|
updateSelectedDay={onUpdateSelectedDay}
|
||||||
|
/>
|
||||||
|
<GeneralFilterButton
|
||||||
|
allLabels={filterValues.labels}
|
||||||
|
selectedLabels={filter?.labels}
|
||||||
|
updateLabelFilter={(newLabels) => {
|
||||||
|
onUpdateFilter({ ...filter, labels: newLabels });
|
||||||
|
}}
|
||||||
|
showReviewed={filter?.showReviewed || false}
|
||||||
|
setShowReviewed={(reviewed) =>
|
||||||
|
onUpdateFilter({ ...filter, showReviewed: reviewed })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CameraFilterButtonProps = {
|
||||||
|
allCameras: string[];
|
||||||
|
selectedCameras: string[] | undefined;
|
||||||
|
updateCameraFilter: (cameras: string[] | undefined) => void;
|
||||||
|
};
|
||||||
|
function CamerasFilterButton({
|
||||||
|
allCameras,
|
||||||
|
selectedCameras,
|
||||||
|
updateCameraFilter,
|
||||||
|
}: CameraFilterButtonProps) {
|
||||||
|
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
|
||||||
|
selectedCameras
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
updateCameraFilter(currentCameras);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="mx-1 capitalize" variant="secondary">
|
||||||
|
<LuVideo className=" mr-[10px]" />
|
||||||
|
{selectedCameras == undefined
|
||||||
|
? "All Cameras"
|
||||||
|
: `${selectedCameras.length} Cameras`}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>Filter Cameras</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<FilterCheckBox
|
||||||
|
isChecked={currentCameras == undefined}
|
||||||
|
label="All Cameras"
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
setCurrentCameras(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{allCameras.map((item) => (
|
||||||
|
<FilterCheckBox
|
||||||
|
key={item}
|
||||||
|
isChecked={currentCameras?.includes(item) ?? false}
|
||||||
|
label={item.replaceAll("_", " ")}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
const updatedCameras = currentCameras
|
||||||
|
? [...currentCameras]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
updatedCameras.push(item);
|
||||||
|
setCurrentCameras(updatedCameras);
|
||||||
|
} else {
|
||||||
|
const updatedCameras = currentCameras
|
||||||
|
? [...currentCameras]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// can not deselect the last item
|
||||||
|
if (updatedCameras.length > 1) {
|
||||||
|
updatedCameras.splice(updatedCameras.indexOf(item), 1);
|
||||||
|
setCurrentCameras(updatedCameras);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarFilterButtonProps = {
|
||||||
|
day?: Date;
|
||||||
|
updateSelectedDay: (day?: Date) => void;
|
||||||
|
};
|
||||||
|
function CalendarFilterButton({
|
||||||
|
day,
|
||||||
|
updateSelectedDay,
|
||||||
|
}: CalendarFilterButtonProps) {
|
||||||
|
const [selectedDay, setSelectedDay] = useState(day);
|
||||||
|
const disabledDates = useMemo(() => {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
||||||
|
const future = new Date();
|
||||||
|
future.setFullYear(tomorrow.getFullYear() + 10);
|
||||||
|
return { from: tomorrow, to: future };
|
||||||
|
}, []);
|
||||||
|
const selectedDate = useFormattedTimestamp(
|
||||||
|
day == undefined ? 0 : day?.getTime() / 1000,
|
||||||
|
"%b %-d"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
updateSelectedDay(selectedDay);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button className="mx-1" variant="secondary">
|
||||||
|
<LuCalendar className=" mr-[10px]" />
|
||||||
|
{day == undefined ? "Last 24 Hours" : selectedDate}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
disabled={disabledDates}
|
||||||
|
selected={selectedDay}
|
||||||
|
onSelect={(day) => {
|
||||||
|
setSelectedDay(day);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeneralFilterButtonProps = {
|
||||||
|
allLabels: string[];
|
||||||
|
selectedLabels: string[] | undefined;
|
||||||
|
updateLabelFilter: (labels: string[] | undefined) => void;
|
||||||
|
showReviewed: boolean;
|
||||||
|
setShowReviewed: (reviewed: boolean) => void;
|
||||||
|
};
|
||||||
|
function GeneralFilterButton({
|
||||||
|
allLabels,
|
||||||
|
selectedLabels,
|
||||||
|
updateLabelFilter,
|
||||||
|
showReviewed,
|
||||||
|
setShowReviewed,
|
||||||
|
}: GeneralFilterButtonProps) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button className="mx-1" variant="secondary">
|
||||||
|
<LuFilter className=" mr-[10px]" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="left" asChild>
|
||||||
|
<div className="w-80 flex">
|
||||||
|
<LabelsFilterButton
|
||||||
|
allLabels={allLabels}
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
updateLabelFilter={updateLabelFilter}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="capitalize flex justify-between items-center cursor-pointer w-full"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(_) => setShowReviewed(!showReviewed)}
|
||||||
|
>
|
||||||
|
{showReviewed ? (
|
||||||
|
<LuCheck className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
<div className="ml-1 w-full flex justify-start">Show Reviewed</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelFilterButtonProps = {
|
||||||
|
allLabels: string[];
|
||||||
|
selectedLabels: string[] | undefined;
|
||||||
|
updateLabelFilter: (labels: string[] | undefined) => void;
|
||||||
|
};
|
||||||
|
function LabelsFilterButton({
|
||||||
|
allLabels,
|
||||||
|
selectedLabels,
|
||||||
|
updateLabelFilter,
|
||||||
|
}: LabelFilterButtonProps) {
|
||||||
|
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
|
||||||
|
selectedLabels
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
updateLabelFilter(currentLabels);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="mx-1 capitalize" variant="secondary">
|
||||||
|
<LuVideo className=" mr-[10px]" />
|
||||||
|
{selectedLabels == undefined
|
||||||
|
? "All Labels"
|
||||||
|
: `${selectedLabels.length} Labels`}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>Filter Labels</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<FilterCheckBox
|
||||||
|
isChecked={currentLabels == undefined}
|
||||||
|
label="All Labels"
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
setCurrentLabels(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{allLabels.map((item) => (
|
||||||
|
<FilterCheckBox
|
||||||
|
key={item}
|
||||||
|
isChecked={currentLabels?.includes(item) ?? false}
|
||||||
|
label={item.replaceAll("_", " ")}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (isChecked) {
|
||||||
|
const updatedLabels = currentLabels ? [...currentLabels] : [];
|
||||||
|
|
||||||
|
updatedLabels.push(item);
|
||||||
|
setCurrentLabels(updatedLabels);
|
||||||
|
} else {
|
||||||
|
const updatedLabels = currentLabels ? [...currentLabels] : [];
|
||||||
|
|
||||||
|
// can not deselect the last item
|
||||||
|
if (updatedLabels.length > 1) {
|
||||||
|
updatedLabels.splice(updatedLabels.indexOf(item), 1);
|
||||||
|
setCurrentLabels(updatedLabels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterCheckBoxProps = {
|
||||||
|
label: string;
|
||||||
|
isChecked: boolean;
|
||||||
|
onCheckedChange: (isChecked: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilterCheckBox({
|
||||||
|
label,
|
||||||
|
isChecked,
|
||||||
|
onCheckedChange,
|
||||||
|
}: FilterCheckBoxProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="capitalize flex justify-between items-center cursor-pointer w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(_) => onCheckedChange(!isChecked)}
|
||||||
|
>
|
||||||
|
{isChecked ? (
|
||||||
|
<LuCheck className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
<div className="ml-1 w-full flex justify-start">{label}</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { formatUnixTimestampToDateTime, isCurrentHour } from "@/utils/dateUtil";
|
import { isCurrentHour } from "@/utils/dateUtil";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import { Slider } from "../ui/slider";
|
import { Slider } from "../ui/slider";
|
||||||
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
|
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
|
||||||
@ -18,6 +18,7 @@ import {
|
|||||||
} from "../ui/context-menu";
|
} from "../ui/context-menu";
|
||||||
import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu";
|
import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
|
|
||||||
type PreviewPlayerProps = {
|
type PreviewPlayerProps = {
|
||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
@ -92,6 +93,13 @@ export default function PreviewThumbnailPlayer({
|
|||||||
[hoverTimeout, review]
|
[hoverTimeout, review]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// date
|
||||||
|
|
||||||
|
const formattedDate = useFormattedTimestamp(
|
||||||
|
review.start_time,
|
||||||
|
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p"
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
@ -137,13 +145,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
{!playingBack && (
|
{!playingBack && (
|
||||||
<div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white">
|
<div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white">
|
||||||
<TimeAgo time={review.start_time * 1000} dense />
|
<TimeAgo time={review.start_time * 1000} dense />
|
||||||
{config &&
|
{formattedDate}
|
||||||
formatUnixTimestampToDateTime(review.start_time, {
|
|
||||||
strftime_fmt:
|
|
||||||
config.ui.time_format == "24hour"
|
|
||||||
? "%b %-d, %H:%M"
|
|
||||||
: "%b %-d, %I:%M %p",
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-0 left-0 right-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none" />
|
<div className="absolute top-0 left-0 right-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none" />
|
||||||
|
@ -3,11 +3,9 @@ import { useMemo, useState } from "react";
|
|||||||
type useApiFilterReturn<F extends FilterType> = [
|
type useApiFilterReturn<F extends FilterType> = [
|
||||||
filter: F | undefined,
|
filter: F | undefined,
|
||||||
setFilter: (filter: F) => void,
|
setFilter: (filter: F) => void,
|
||||||
searchParams:
|
searchParams: {
|
||||||
| {
|
[key: string]: any;
|
||||||
[key: string]: any;
|
},
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function useApiFilter<
|
export default function useApiFilter<
|
||||||
@ -16,7 +14,7 @@ export default function useApiFilter<
|
|||||||
const [filter, setFilter] = useState<F | undefined>(undefined);
|
const [filter, setFilter] = useState<F | undefined>(undefined);
|
||||||
const searchParams = useMemo(() => {
|
const searchParams = useMemo(() => {
|
||||||
if (filter == undefined) {
|
if (filter == undefined) {
|
||||||
return undefined;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const search: { [key: string]: string } = {};
|
const search: { [key: string]: string } = {};
|
||||||
|
12
web/src/hooks/use-date-utils.ts
Normal file
12
web/src/hooks/use-date-utils.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function useFormattedTimestamp(timestamp: number, format: string) {
|
||||||
|
const formattedTimestamp = useMemo(() => {
|
||||||
|
return formatUnixTimestampToDateTime(timestamp, {
|
||||||
|
strftime_fmt: format,
|
||||||
|
});
|
||||||
|
}, [format, timestamp]);
|
||||||
|
|
||||||
|
return formattedTimestamp;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
|
import useApiFilter from "@/hooks/use-api-filter";
|
||||||
import useOverlayState from "@/hooks/use-overlay-state";
|
import useOverlayState from "@/hooks/use-overlay-state";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewFilter, ReviewSegment } from "@/types/review";
|
||||||
import DesktopEventView from "@/views/events/DesktopEventView";
|
import DesktopEventView from "@/views/events/DesktopEventView";
|
||||||
import DesktopRecordingView from "@/views/events/DesktopRecordingView";
|
import DesktopRecordingView from "@/views/events/DesktopRecordingView";
|
||||||
import MobileEventView from "@/views/events/MobileEventView";
|
import MobileEventView from "@/views/events/MobileEventView";
|
||||||
@ -15,6 +16,16 @@ export default function Events() {
|
|||||||
// recordings viewer
|
// recordings viewer
|
||||||
const [selectedReviewId, setSelectedReviewId] = useOverlayState("review");
|
const [selectedReviewId, setSelectedReviewId] = useOverlayState("review");
|
||||||
|
|
||||||
|
// review filter
|
||||||
|
|
||||||
|
const [reviewFilter, setReviewFilter, reviewSearchParams] =
|
||||||
|
useApiFilter<ReviewFilter>();
|
||||||
|
|
||||||
|
const onUpdateFilter = useCallback((newFilter: ReviewFilter) => {
|
||||||
|
setSize(1);
|
||||||
|
setReviewFilter(newFilter);
|
||||||
|
}, [])
|
||||||
|
|
||||||
// review paging
|
// review paging
|
||||||
|
|
||||||
const timeRange = useMemo(() => {
|
const timeRange = useMemo(() => {
|
||||||
@ -26,30 +37,30 @@ export default function Events() {
|
|||||||
return axios.get(path, { params }).then((res) => res.data);
|
return axios.get(path, { params }).then((res) => res.data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reviewSearchParams = {};
|
|
||||||
const getKey = useCallback(
|
const getKey = useCallback(
|
||||||
(index: number, prevData: ReviewSegment[]) => {
|
(index: number, prevData: ReviewSegment[]) => {
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
const lastDate = prevData[prevData.length - 1].start_time;
|
const lastDate = prevData[prevData.length - 1].start_time;
|
||||||
const pagedParams = reviewSearchParams
|
reviewSearchParams;
|
||||||
? { before: lastDate, after: timeRange.after, limit: API_LIMIT }
|
const pagedParams = {
|
||||||
: {
|
cameras: reviewSearchParams["cameras"],
|
||||||
...reviewSearchParams,
|
labels: reviewSearchParams["labels"],
|
||||||
before: lastDate,
|
reviewed: reviewSearchParams["showReviewed"] || false,
|
||||||
after: timeRange.after,
|
before: lastDate,
|
||||||
limit: API_LIMIT,
|
after: reviewSearchParams["after"] || timeRange.after,
|
||||||
};
|
limit: API_LIMIT,
|
||||||
|
};
|
||||||
return ["review", pagedParams];
|
return ["review", pagedParams];
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = reviewSearchParams
|
const params = {
|
||||||
? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after }
|
cameras: reviewSearchParams["cameras"],
|
||||||
: {
|
labels: reviewSearchParams["labels"],
|
||||||
...reviewSearchParams,
|
reviewed: reviewSearchParams["showReviewed"] || false,
|
||||||
limit: API_LIMIT,
|
limit: API_LIMIT,
|
||||||
before: timeRange.before,
|
before: reviewSearchParams["before"] || timeRange.before,
|
||||||
after: timeRange.after,
|
after: reviewSearchParams["after"] || timeRange.after,
|
||||||
};
|
};
|
||||||
return ["review", params];
|
return ["review", params];
|
||||||
},
|
},
|
||||||
[reviewSearchParams]
|
[reviewSearchParams]
|
||||||
@ -130,7 +141,7 @@ export default function Events() {
|
|||||||
|
|
||||||
return newData;
|
return newData;
|
||||||
},
|
},
|
||||||
{ revalidate: false }
|
{ revalidate: false, populateCache: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -197,10 +208,12 @@ export default function Events() {
|
|||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
reachedEnd={isDone}
|
reachedEnd={isDone}
|
||||||
isValidating={isValidating}
|
isValidating={isValidating}
|
||||||
|
filter={reviewFilter}
|
||||||
loadNextPage={() => setSize(size + 1)}
|
loadNextPage={() => setSize(size + 1)}
|
||||||
markItemAsReviewed={markItemAsReviewed}
|
markItemAsReviewed={markItemAsReviewed}
|
||||||
onSelectReview={setSelectedReviewId}
|
onSelectReview={setSelectedReviewId}
|
||||||
pullLatestData={updateSegments}
|
pullLatestData={updateSegments}
|
||||||
|
updateFilter={onUpdateFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -19,3 +19,11 @@ export type ReviewData = {
|
|||||||
significant_motion_areas: number[];
|
significant_motion_areas: number[];
|
||||||
zones: string[];
|
zones: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReviewFilter = {
|
||||||
|
cameras?: string[];
|
||||||
|
labels?: string[];
|
||||||
|
before?: number;
|
||||||
|
after?: number;
|
||||||
|
showReviewed?: boolean;
|
||||||
|
};
|
||||||
|
@ -293,6 +293,11 @@ export function endOfHourOrCurrentTime(timestamp: number) {
|
|||||||
return Math.min(timestamp, now.getTime() / 1000);
|
return Math.min(timestamp, now.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEndOfDayTimestamp(date: Date) {
|
||||||
|
date.setHours(23, 59, 59, 999);
|
||||||
|
return date.getTime() / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
export function isCurrentHour(timestamp: number) {
|
export function isCurrentHour(timestamp: number) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(0, 0, 0);
|
now.setMinutes(0, 0, 0);
|
||||||
|
@ -1,20 +1,14 @@
|
|||||||
import { useFrigateEvents } from "@/api/ws";
|
import { useFrigateEvents } from "@/api/ws";
|
||||||
|
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||||
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
||||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||||
import ActivityIndicator from "@/components/ui/activity-indicator";
|
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { LuCalendar, LuFilter, LuRefreshCcw, LuVideo } from "react-icons/lu";
|
import { LuRefreshCcw } from "react-icons/lu";
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
@ -24,10 +18,12 @@ type DesktopEventViewProps = {
|
|||||||
timeRange: { before: number; after: number };
|
timeRange: { before: number; after: number };
|
||||||
reachedEnd: boolean;
|
reachedEnd: boolean;
|
||||||
isValidating: boolean;
|
isValidating: boolean;
|
||||||
|
filter?: ReviewFilter;
|
||||||
loadNextPage: () => void;
|
loadNextPage: () => void;
|
||||||
markItemAsReviewed: (reviewId: string) => void;
|
markItemAsReviewed: (reviewId: string) => void;
|
||||||
onSelectReview: (reviewId: string) => void;
|
onSelectReview: (reviewId: string) => void;
|
||||||
pullLatestData: () => void;
|
pullLatestData: () => void;
|
||||||
|
updateFilter: (filter: ReviewFilter) => void;
|
||||||
};
|
};
|
||||||
export default function DesktopEventView({
|
export default function DesktopEventView({
|
||||||
reviewPages,
|
reviewPages,
|
||||||
@ -35,10 +31,12 @@ export default function DesktopEventView({
|
|||||||
timeRange,
|
timeRange,
|
||||||
reachedEnd,
|
reachedEnd,
|
||||||
isValidating,
|
isValidating,
|
||||||
|
filter,
|
||||||
loadNextPage,
|
loadNextPage,
|
||||||
markItemAsReviewed,
|
markItemAsReviewed,
|
||||||
onSelectReview,
|
onSelectReview,
|
||||||
pullLatestData,
|
pullLatestData,
|
||||||
|
updateFilter,
|
||||||
}: DesktopEventViewProps) {
|
}: DesktopEventViewProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
|
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
|
||||||
@ -234,17 +232,7 @@ export default function DesktopEventView({
|
|||||||
Motion
|
Motion
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<div>
|
<ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} />
|
||||||
<Button className="mx-1" variant="secondary">
|
|
||||||
<LuVideo className=" mr-[10px]" />
|
|
||||||
All Cameras
|
|
||||||
</Button>
|
|
||||||
<ReviewCalendarButton />
|
|
||||||
<Button className="mx-1" variant="secondary">
|
|
||||||
<LuFilter className=" mr-[10px]" />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full overflow-hidden">
|
<div className="flex h-full overflow-hidden">
|
||||||
@ -334,29 +322,3 @@ export default function DesktopEventView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewCalendarButton() {
|
|
||||||
const disabledDates = useMemo(() => {
|
|
||||||
const tomorrow = new Date();
|
|
||||||
tomorrow.setHours(tomorrow.getHours() + 24, -1, 0, 0);
|
|
||||||
const future = new Date();
|
|
||||||
future.setFullYear(tomorrow.getFullYear() + 10);
|
|
||||||
return { from: tomorrow, to: future };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button className="mx-1" variant="secondary">
|
|
||||||
<LuCalendar className=" mr-[10px]" />
|
|
||||||
{formatUnixTimestampToDateTime(Date.now() / 1000, {
|
|
||||||
strftime_fmt: "%b %-d",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent>
|
|
||||||
<Calendar mode="single" disabled={disabledDates} />
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Loading…
Reference in New Issue
Block a user