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:
Nicolas Mowen 2024-02-25 12:04:44 -07:00 committed by GitHub
parent 4a7c159a44
commit 9801534f11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 473 additions and 533 deletions

View File

@ -2395,7 +2395,9 @@ def vod_event(id):
@bp.route("/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)
severity = request.args.get("severity", None)
@ -2406,8 +2408,26 @@ def review():
clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))]
if camera != "all":
clauses.append((ReviewSegment.camera == camera))
if cameras != "all":
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:
clauses.append((ReviewSegment.severity == severity))

View File

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

View File

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

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

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useApiHost } from "@/api";
import { formatUnixTimestampToDateTime, isCurrentHour } from "@/utils/dateUtil";
import { isCurrentHour } from "@/utils/dateUtil";
import { ReviewSegment } from "@/types/review";
import { Slider } from "../ui/slider";
import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil";
@ -18,6 +18,7 @@ import {
} from "../ui/context-menu";
import { LuCheckSquare, LuFileUp, LuTrash } from "react-icons/lu";
import axios from "axios";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
type PreviewPlayerProps = {
review: ReviewSegment;
@ -92,6 +93,13 @@ export default function PreviewThumbnailPlayer({
[hoverTimeout, review]
);
// date
const formattedDate = useFormattedTimestamp(
review.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p"
);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
@ -137,13 +145,7 @@ export default function PreviewThumbnailPlayer({
{!playingBack && (
<div className="absolute left-[6px] right-[6px] bottom-1 flex justify-between text-white">
<TimeAgo time={review.start_time * 1000} dense />
{config &&
formatUnixTimestampToDateTime(review.start_time, {
strftime_fmt:
config.ui.time_format == "24hour"
? "%b %-d, %H:%M"
: "%b %-d, %I:%M %p",
})}
{formattedDate}
</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" />

View File

@ -3,11 +3,9 @@ import { useMemo, useState } from "react";
type useApiFilterReturn<F extends FilterType> = [
filter: F | undefined,
setFilter: (filter: F) => void,
searchParams:
| {
[key: string]: any;
}
| undefined,
searchParams: {
[key: string]: any;
},
];
export default function useApiFilter<
@ -16,7 +14,7 @@ export default function useApiFilter<
const [filter, setFilter] = useState<F | undefined>(undefined);
const searchParams = useMemo(() => {
if (filter == undefined) {
return undefined;
return {};
}
const search: { [key: string]: string } = {};

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

View File

@ -1,5 +1,6 @@
import useApiFilter from "@/hooks/use-api-filter";
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 DesktopRecordingView from "@/views/events/DesktopRecordingView";
import MobileEventView from "@/views/events/MobileEventView";
@ -15,6 +16,16 @@ export default function Events() {
// recordings viewer
const [selectedReviewId, setSelectedReviewId] = useOverlayState("review");
// review filter
const [reviewFilter, setReviewFilter, reviewSearchParams] =
useApiFilter<ReviewFilter>();
const onUpdateFilter = useCallback((newFilter: ReviewFilter) => {
setSize(1);
setReviewFilter(newFilter);
}, [])
// review paging
const timeRange = useMemo(() => {
@ -26,30 +37,30 @@ export default function Events() {
return axios.get(path, { params }).then((res) => res.data);
}, []);
const reviewSearchParams = {};
const getKey = useCallback(
(index: number, prevData: ReviewSegment[]) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = reviewSearchParams
? { before: lastDate, after: timeRange.after, limit: API_LIMIT }
: {
...reviewSearchParams,
before: lastDate,
after: timeRange.after,
limit: API_LIMIT,
};
reviewSearchParams;
const pagedParams = {
cameras: reviewSearchParams["cameras"],
labels: reviewSearchParams["labels"],
reviewed: reviewSearchParams["showReviewed"] || false,
before: lastDate,
after: reviewSearchParams["after"] || timeRange.after,
limit: API_LIMIT,
};
return ["review", pagedParams];
}
const params = reviewSearchParams
? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after }
: {
...reviewSearchParams,
limit: API_LIMIT,
before: timeRange.before,
after: timeRange.after,
};
const params = {
cameras: reviewSearchParams["cameras"],
labels: reviewSearchParams["labels"],
reviewed: reviewSearchParams["showReviewed"] || false,
limit: API_LIMIT,
before: reviewSearchParams["before"] || timeRange.before,
after: reviewSearchParams["after"] || timeRange.after,
};
return ["review", params];
},
[reviewSearchParams]
@ -130,7 +141,7 @@ export default function Events() {
return newData;
},
{ revalidate: false }
{ revalidate: false, populateCache: true }
);
}
},
@ -197,10 +208,12 @@ export default function Events() {
timeRange={timeRange}
reachedEnd={isDone}
isValidating={isValidating}
filter={reviewFilter}
loadNextPage={() => setSize(size + 1)}
markItemAsReviewed={markItemAsReviewed}
onSelectReview={setSelectedReviewId}
pullLatestData={updateSegments}
updateFilter={onUpdateFilter}
/>
);
}

View File

@ -19,3 +19,11 @@ export type ReviewData = {
significant_motion_areas: number[];
zones: string[];
};
export type ReviewFilter = {
cameras?: string[];
labels?: string[];
before?: number;
after?: number;
showReviewed?: boolean;
};

View File

@ -293,6 +293,11 @@ export function endOfHourOrCurrentTime(timestamp: number) {
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) {
const now = new Date();
now.setMinutes(0, 0, 0);

View File

@ -1,20 +1,14 @@
import { useFrigateEvents } from "@/api/ws";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/ui/activity-indicator";
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 { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
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 useSWR from "swr";
@ -24,10 +18,12 @@ type DesktopEventViewProps = {
timeRange: { before: number; after: number };
reachedEnd: boolean;
isValidating: boolean;
filter?: ReviewFilter;
loadNextPage: () => void;
markItemAsReviewed: (reviewId: string) => void;
onSelectReview: (reviewId: string) => void;
pullLatestData: () => void;
updateFilter: (filter: ReviewFilter) => void;
};
export default function DesktopEventView({
reviewPages,
@ -35,10 +31,12 @@ export default function DesktopEventView({
timeRange,
reachedEnd,
isValidating,
filter,
loadNextPage,
markItemAsReviewed,
onSelectReview,
pullLatestData,
updateFilter,
}: DesktopEventViewProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const [severity, setSeverity] = useState<ReviewSeverity>("alert");
@ -234,17 +232,7 @@ export default function DesktopEventView({
Motion
</ToggleGroupItem>
</ToggleGroup>
<div>
<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>
<ReviewFilterGroup filter={filter} onUpdateFilter={updateFilter} />
</div>
<div className="flex h-full overflow-hidden">
@ -334,29 +322,3 @@ export default function DesktopEventView({
</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>
);
}