mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Add support for review information side panel (#13063)
This commit is contained in:
parent
2cb81ef116
commit
943114c052
@ -251,6 +251,26 @@ def events():
|
|||||||
return jsonify(list(events))
|
return jsonify(list(events))
|
||||||
|
|
||||||
|
|
||||||
|
@EventBp.route("/event_ids")
|
||||||
|
def event_ids():
|
||||||
|
idString = request.args.get("ids")
|
||||||
|
ids = idString.split(",")
|
||||||
|
|
||||||
|
if not ids:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Valid list of ids must be sent"}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
events = Event.select().where(Event.id << ids).dicts().iterator()
|
||||||
|
return jsonify(list(events))
|
||||||
|
except Exception:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Events not found"}), 400
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@EventBp.route("/events/search")
|
@EventBp.route("/events/search")
|
||||||
def events_search():
|
def events_search():
|
||||||
query = request.args.get("query", type=str)
|
query = request.args.get("query", type=str)
|
||||||
|
154
web/src/components/overlay/detail/ReviewDetailDialog.tsx
Normal file
154
web/src/components/overlay/detail/ReviewDetailDialog.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { isDesktop, isIOS } from "react-device-detect";
|
||||||
|
import { Sheet, SheetContent } from "../../ui/sheet";
|
||||||
|
import { Drawer, DrawerContent } from "../../ui/drawer";
|
||||||
|
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 { ReviewSegment } from "@/types/review";
|
||||||
|
import { Event } from "@/types/event";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
type ReviewDetailDialogProps = {
|
||||||
|
review?: ReviewSegment;
|
||||||
|
setReview: (review: ReviewSegment | undefined) => void;
|
||||||
|
};
|
||||||
|
export default function ReviewDetailDialog({
|
||||||
|
review,
|
||||||
|
setReview,
|
||||||
|
}: ReviewDetailDialogProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
|
// data
|
||||||
|
|
||||||
|
const { data: events } = useSWR<Event[]>(
|
||||||
|
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasMismatch = useMemo(() => {
|
||||||
|
if (!review || !events) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.length != review?.data.detections.length;
|
||||||
|
}, [review, events]);
|
||||||
|
|
||||||
|
const formattedDate = useFormattedTimestamp(
|
||||||
|
review?.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={review != undefined}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setReview(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Content
|
||||||
|
className={
|
||||||
|
isDesktop ? "sm:max-w-xl" : "max-h-[75dvh] overflow-hidden p-2 pb-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{review && (
|
||||||
|
<div className="mt-3 flex size-full flex-col gap-5 md:mt-0">
|
||||||
|
<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">Camera</div>
|
||||||
|
<div className="text-sm capitalize">
|
||||||
|
{review.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">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Objects</div>
|
||||||
|
<div className="flex flex-col items-start gap-2 text-sm capitalize">
|
||||||
|
{events?.map((event) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="flex flex-row items-center gap-2 text-sm capitalize"
|
||||||
|
>
|
||||||
|
{getIconForLabel(event.label, "size-3 text-white")}
|
||||||
|
{event.sub_label ?? event.label} (
|
||||||
|
{Math.round(event.data.top_score * 100)}%)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{review.data.zones.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm text-primary/40">Zones</div>
|
||||||
|
<div className="flex flex-col items-start gap-2 text-sm capitalize">
|
||||||
|
{review.data.zones.map((zone) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={zone}
|
||||||
|
className="flex flex-row items-center gap-2 text-sm capitalize"
|
||||||
|
>
|
||||||
|
{zone.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasMismatch && (
|
||||||
|
<div className="p-4 text-center text-sm">
|
||||||
|
Some objects that were detected are not included in this list
|
||||||
|
because the object does not have a snapshot
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="scrollbar-container flex w-full flex-col gap-2 overflow-y-auto px-6">
|
||||||
|
{events?.map((event) => {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={event.id}
|
||||||
|
className="aspect-video select-none rounded-lg object-contain transition-opacity"
|
||||||
|
style={
|
||||||
|
isIOS
|
||||||
|
? {
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
WebkitTouchCallout: "none",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
draggable={false}
|
||||||
|
src={
|
||||||
|
event.has_snapshot
|
||||||
|
? `${apiHost}api/events/${event.id}/snapshot.jpg`
|
||||||
|
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
@ -1,17 +1,17 @@
|
|||||||
import { isDesktop, isIOS } from "react-device-detect";
|
import { isDesktop, isIOS } from "react-device-detect";
|
||||||
import { Sheet, SheetContent } from "../ui/sheet";
|
import { Sheet, SheetContent } from "../../ui/sheet";
|
||||||
import { Drawer, DrawerContent } from "../ui/drawer";
|
import { Drawer, DrawerContent } from "../../ui/drawer";
|
||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
|
||||||
import { getIconForLabel } from "@/utils/iconUtil";
|
import { getIconForLabel } from "@/utils/iconUtil";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../../ui/button";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../../ui/textarea";
|
||||||
|
|
||||||
type SearchDetailDialogProps = {
|
type SearchDetailDialogProps = {
|
||||||
search?: SearchResult;
|
search?: SearchResult;
|
@ -28,7 +28,7 @@ type PreviewPlayerProps = {
|
|||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
onTimeUpdate?: (time: number | undefined) => void;
|
onTimeUpdate?: (time: number | undefined) => void;
|
||||||
setReviewed: (review: ReviewSegment) => void;
|
setReviewed: (review: ReviewSegment) => void;
|
||||||
onClick: (review: ReviewSegment, ctrl: boolean) => void;
|
onClick: (review: ReviewSegment, ctrl: boolean, detail: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PreviewThumbnailPlayer({
|
export default function PreviewThumbnailPlayer({
|
||||||
@ -50,7 +50,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
const handleOnClick = useCallback(
|
const handleOnClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!ignoreClick) {
|
if (!ignoreClick) {
|
||||||
onClick(review, e.metaKey);
|
onClick(review, e.metaKey, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ignoreClick, review, onClick],
|
[ignoreClick, review, onClick],
|
||||||
@ -73,7 +73,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useContextMenu(imgRef, () => {
|
useContextMenu(imgRef, () => {
|
||||||
onClick(review, true);
|
onClick(review, true, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// playback
|
// playback
|
||||||
@ -237,6 +237,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
<>
|
<>
|
||||||
<Chip
|
<Chip
|
||||||
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`}
|
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`}
|
||||||
|
onClick={() => onClick(review, false, true)}
|
||||||
>
|
>
|
||||||
{review.data.objects.sort().map((object) => {
|
{review.data.objects.sort().map((object) => {
|
||||||
return getIconForLabel(object, "size-3 text-white");
|
return getIconForLabel(object, "size-3 text-white");
|
||||||
@ -265,6 +266,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
.sort()
|
.sort()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
.replaceAll("-verified", "")}
|
.replaceAll("-verified", "")}
|
||||||
|
{` Click To View Detection Details`}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,7 +17,6 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
|||||||
import { VideoPreview } from "../preview/ScrubbablePreview";
|
import { VideoPreview } from "../preview/ScrubbablePreview";
|
||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import { SearchResult } from "@/types/search";
|
import { SearchResult } from "@/types/search";
|
||||||
import { LuInfo } from "react-icons/lu";
|
|
||||||
import useContextMenu from "@/hooks/use-contextmenu";
|
import useContextMenu from "@/hooks/use-contextmenu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@ -212,6 +211,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)}
|
||||||
>
|
>
|
||||||
{getIconForLabel(
|
{getIconForLabel(
|
||||||
searchResult.label,
|
searchResult.label,
|
||||||
@ -231,34 +231,8 @@ export default function SearchThumbnailPlayer({
|
|||||||
.map((text) => capitalizeFirstLetter(text))
|
.map((text) => capitalizeFirstLetter(text))
|
||||||
.sort()
|
.sort()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
.replaceAll("-verified", "")}
|
.replaceAll("-verified", "")}{" "}
|
||||||
</TooltipContent>
|
{` Click To View Detection Details`}
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-0 top-2 z-40">
|
|
||||||
<Tooltip>
|
|
||||||
<div
|
|
||||||
className="flex"
|
|
||||||
onMouseEnter={() => setTooltipHovering(true)}
|
|
||||||
onMouseLeave={() => setTooltipHovering(false)}
|
|
||||||
>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="mx-3 pb-1 text-sm text-white">
|
|
||||||
{
|
|
||||||
<>
|
|
||||||
<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`}
|
|
||||||
onClick={() => onClick(searchResult, true)}
|
|
||||||
>
|
|
||||||
<LuInfo className="size-3" />
|
|
||||||
</Chip>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</div>
|
|
||||||
<TooltipContent className="capitalize">
|
|
||||||
View Detection Details
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,6 +52,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
|
import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
|
||||||
import { GiSoundWaves } from "react-icons/gi";
|
import { GiSoundWaves } from "react-icons/gi";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
|
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
|
||||||
|
|
||||||
type EventViewProps = {
|
type EventViewProps = {
|
||||||
reviewItems?: SegmentedReviewData;
|
reviewItems?: SegmentedReviewData;
|
||||||
@ -464,6 +465,10 @@ function DetectionReview({
|
|||||||
|
|
||||||
const segmentDuration = 60;
|
const segmentDuration = 60;
|
||||||
|
|
||||||
|
// detail
|
||||||
|
|
||||||
|
const [reviewDetail, setReviewDetail] = useState<ReviewSegment>();
|
||||||
|
|
||||||
// preview
|
// preview
|
||||||
|
|
||||||
const [previewTime, setPreviewTime] = useState<number>();
|
const [previewTime, setPreviewTime] = useState<number>();
|
||||||
@ -628,6 +633,8 @@ function DetectionReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ReviewDetailDialog review={reviewDetail} setReview={setReviewDetail} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"
|
className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"
|
||||||
@ -682,7 +689,17 @@ function DetectionReview({
|
|||||||
setReviewed={markItemAsReviewed}
|
setReviewed={markItemAsReviewed}
|
||||||
scrollLock={scrollLock}
|
scrollLock={scrollLock}
|
||||||
onTimeUpdate={onPreviewTimeUpdate}
|
onTimeUpdate={onPreviewTimeUpdate}
|
||||||
onClick={onSelectReview}
|
onClick={(
|
||||||
|
review: ReviewSegment,
|
||||||
|
ctrl: boolean,
|
||||||
|
detail: boolean,
|
||||||
|
) => {
|
||||||
|
if (detail) {
|
||||||
|
setReviewDetail(review);
|
||||||
|
} else {
|
||||||
|
onSelectReview(review, ctrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
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 SearchDetailDialog from "@/components/overlay/detail/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";
|
||||||
|
Loading…
Reference in New Issue
Block a user