161 lines
5.5 KiB
TypeScript
161 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { Activity, Film, ImageIcon, MapPin } from 'lucide-react';
|
|
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
|
import { cn } from '@/lib/utils';
|
|
import type { DetectionResultLog } from '@/types';
|
|
|
|
interface DetectionLogsProps {
|
|
logs: DetectionResultLog[];
|
|
activeLogId?: string;
|
|
onSelect: (log: DetectionResultLog) => void;
|
|
}
|
|
|
|
const formatVideoTime = (seconds: number) => {
|
|
const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
|
|
const minutes = Math.floor(safeSeconds / 60);
|
|
const secs = Math.floor(safeSeconds % 60);
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
function DetectionLogThumbnail({
|
|
url,
|
|
label,
|
|
}: {
|
|
url?: string | null;
|
|
label: string;
|
|
}) {
|
|
const { objectUrl, isLoading, isError } = useProtectedMediaObjectUrl(url);
|
|
|
|
if (!url) {
|
|
return (
|
|
<div className="flex size-20 shrink-0 items-center justify-center rounded-md border border-dashed border-border/70 bg-muted/20 text-muted-foreground/60">
|
|
<ImageIcon className="size-5" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <Skeleton className="size-20 shrink-0 rounded-md" />;
|
|
}
|
|
|
|
if (isError || !objectUrl) {
|
|
return (
|
|
<div className="flex size-20 shrink-0 items-center justify-center rounded-md border border-dashed bg-muted/20 text-[10px] text-muted-foreground">
|
|
N/A
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="size-20 shrink-0 overflow-hidden rounded-md border bg-muted/20">
|
|
<img
|
|
src={objectUrl}
|
|
alt={label}
|
|
className="size-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const DetectionLogs = ({ logs, activeLogId, onSelect }: DetectionLogsProps) => {
|
|
const activeLogRef = useRef<HTMLButtonElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
activeLogRef.current?.scrollIntoView({
|
|
block: 'nearest',
|
|
behavior: 'smooth',
|
|
});
|
|
}, [activeLogId]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="flex items-center gap-2 text-sm font-bold">
|
|
<Activity className="h-4 w-4" />
|
|
Detection Logs
|
|
</h4>
|
|
</div>
|
|
<ScrollArea className="h-107.5 rounded-lg border bg-muted/20">
|
|
<div className="space-y-3 p-4">
|
|
{logs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<Film className="mb-2 h-8 w-8 opacity-20" />
|
|
<p className="text-xs">No detections found</p>
|
|
</div>
|
|
) : (
|
|
logs.map((log) => {
|
|
const timestampSeconds = log.frame.timestamp_seconds;
|
|
const { latitude, longitude } = log.location;
|
|
const isActive = activeLogId === log.id;
|
|
const label = log.detection.display_name;
|
|
|
|
return (
|
|
<button
|
|
key={`${log.id}-${log.frame.number}`}
|
|
ref={isActive ? activeLogRef : null}
|
|
type="button"
|
|
onClick={() => onSelect(log)}
|
|
className={cn(
|
|
'w-full rounded-md border bg-card p-3 text-left transition-colors hover:border-primary',
|
|
isActive && 'border-primary bg-primary/5',
|
|
)}
|
|
>
|
|
<div className="flex gap-3">
|
|
<DetectionLogThumbnail
|
|
url={log.detection.context_image_url}
|
|
label={label}
|
|
/>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="mb-2 flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{label}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Frame {log.frame.number}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="shrink-0 text-right">
|
|
<div className="text-xs font-bold">
|
|
{formatVideoTime(timestampSeconds)}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
{timestampSeconds.toFixed(2)}s
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 text-[12px] text-muted-foreground">
|
|
<p>{(log.detection.confidence * 100).toFixed(1)}%</p>
|
|
{typeof latitude === 'number' &&
|
|
typeof longitude === 'number' && (
|
|
<div className="flex items-center gap-1.5">
|
|
<MapPin className="h-3 w-3 shrink-0" />
|
|
<span className="truncate font-mono text-[11px]">
|
|
{latitude.toFixed(8)}, {longitude.toFixed(8)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DetectionLogs;
|