This commit is contained in:
Nicolas Mowen 2024-08-13 09:44:00 -06:00
parent cd1ba66d8b
commit 8b8d6f8e7e
7 changed files with 154 additions and 14 deletions

View File

@ -0,0 +1,122 @@
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";
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 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">Labels</div>
<div className="flex flex-col items-start gap-2 text-sm capitalize">
{[
...new Set([
...(review.data.objects || []),
...(review.data.sub_labels || []),
...(review.data.audio || []),
]),
]
.filter(
(item) =>
item !== undefined && !item.includes("-verified"),
)
.sort()
.map((obj) => {
return (
<div
key={obj}
className="flex flex-row items-center gap-2 text-sm capitalize"
>
{getIconForLabel(obj, "size-3 text-white")}
{obj}
</div>
);
})}
</div>
</div>
<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">
{review.data.detections.map((eventId) => {
return (
<img
key={eventId}
className="aspect-video select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
/>
);
})}
</div>
</div>
</div>
)}
</Content>
</Overlay>
);
}

View File

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

View File

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

View File

@ -212,6 +212,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,7 +232,8 @@ export default function SearchThumbnailPlayer({
.map((text) => capitalizeFirstLetter(text)) .map((text) => capitalizeFirstLetter(text))
.sort() .sort()
.join(", ") .join(", ")
.replaceAll("-verified", "")} .replaceAll("-verified", "")}{" "}
Click To View Detection Details
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@ -257,9 +259,7 @@ export default function SearchThumbnailPlayer({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipContent className="capitalize"> <TooltipContent className="capitalize"></TooltipContent>
View Detection Details
</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{!playingBack && ( {!playingBack && (

View File

@ -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;
@ -461,6 +462,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>();
@ -615,6 +620,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"
@ -669,7 +676,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

View File

@ -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";

View File

@ -4,7 +4,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import monacoEditorPlugin from "vite-plugin-monaco-editor"; import monacoEditorPlugin from "vite-plugin-monaco-editor";
const proxyHost = process.env.PROXY_HOST || "localhost:5000"; const proxyHost = process.env.PROXY_HOST || "192.168.50.106:5002";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({