282 lines
8.8 KiB
TypeScript
282 lines
8.8 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
|
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import { useTicketClassDetectionsQuery } from '../../hooks/useTicketQueries';
|
|
|
|
interface TicketClassDetectionPreviewProps {
|
|
videoId?: string | null;
|
|
defectClassName: string;
|
|
displayName: string;
|
|
totalCount?: number;
|
|
}
|
|
|
|
function DetectionMetadataBar({
|
|
items,
|
|
}: {
|
|
items: { label: string; value: string | number }[];
|
|
}) {
|
|
return (
|
|
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
|
{items.map((item, index) => (
|
|
<div
|
|
key={item.label}
|
|
className={cn(
|
|
'space-y-1',
|
|
index === 0 && 'sm:pr-4',
|
|
index === 1 && 'sm:px-4',
|
|
index === 2 && 'sm:pl-4',
|
|
)}
|
|
>
|
|
<p className="text-xs text-muted-foreground">{item.label}</p>
|
|
<div className="text-sm font-semibold text-foreground">
|
|
{item.value}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TicketClassDetectionPreviewSkeleton() {
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
<div className="min-w-0 space-y-2">
|
|
<Skeleton className="h-3 w-24" />
|
|
<Skeleton className="aspect-video w-full rounded-lg" />
|
|
</div>
|
|
<div className="min-w-0 space-y-2">
|
|
<Skeleton className="h-3 w-28" />
|
|
<Skeleton
|
|
className="w-full rounded-lg"
|
|
style={{ aspectRatio: '16 / 9' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg bg-muted/40 px-4 py-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:divide-x sm:divide-border/70">
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className={cn(
|
|
'space-y-1',
|
|
index === 0 && 'sm:pr-4',
|
|
index === 1 && 'sm:px-4',
|
|
index === 2 && 'sm:pl-4',
|
|
)}
|
|
>
|
|
<Skeleton className="h-3 w-16" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function TicketClassDetectionPreview({
|
|
videoId,
|
|
defectClassName,
|
|
displayName,
|
|
totalCount,
|
|
}: TicketClassDetectionPreviewProps) {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const visual = getDefectVisual(defectClassName);
|
|
const IssueIcon = visual.icon;
|
|
|
|
useEffect(() => {
|
|
setCurrentIndex(0);
|
|
}, [defectClassName]);
|
|
|
|
const queryParams = useMemo(
|
|
() => ({
|
|
class_name: defectClassName,
|
|
skip: currentIndex,
|
|
limit: 1,
|
|
sort: 'timestamp_asc',
|
|
}),
|
|
[currentIndex, defectClassName],
|
|
);
|
|
|
|
const detectionsQuery = useTicketClassDetectionsQuery(
|
|
videoId ?? undefined,
|
|
queryParams,
|
|
);
|
|
const detectionResult = detectionsQuery.data;
|
|
const activeDetection = detectionResult?.items[0];
|
|
const detectionCount = detectionResult?.total ?? totalCount ?? 0;
|
|
const mediaAspectRatio =
|
|
detectionResult &&
|
|
detectionResult.video_width > 0 &&
|
|
detectionResult.video_height > 0
|
|
? `${detectionResult.video_width} / ${detectionResult.video_height}`
|
|
: '16 / 9';
|
|
|
|
useEffect(() => {
|
|
if (detectionCount === 0 && currentIndex !== 0) {
|
|
setCurrentIndex(0);
|
|
return;
|
|
}
|
|
|
|
if (detectionCount > 0 && currentIndex > detectionCount - 1) {
|
|
setCurrentIndex(detectionCount - 1);
|
|
}
|
|
}, [currentIndex, detectionCount]);
|
|
|
|
const isNavigationDisabled =
|
|
detectionCount === 0 || detectionsQuery.isLoading;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span
|
|
className={cn(
|
|
'flex size-10 shrink-0 self-start items-center justify-center rounded-lg',
|
|
visual.cardClassName,
|
|
)}
|
|
>
|
|
<IssueIcon className={cn('size-5', visual.colorClassName)} />
|
|
</span>
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<h3>{displayName}</h3>
|
|
<Badge
|
|
variant="secondary"
|
|
className={cn(
|
|
'rounded-lg',
|
|
visual.cardClassName,
|
|
visual.colorClassName,
|
|
)}
|
|
>
|
|
{detectionCount} Detections
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground">
|
|
Review detections in ascending timestamp order.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 self-start sm:self-auto">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() =>
|
|
setCurrentIndex((index) =>
|
|
detectionCount === 0
|
|
? 0
|
|
: (index - 1 + detectionCount) % detectionCount,
|
|
)
|
|
}
|
|
disabled={isNavigationDisabled}
|
|
aria-label="Previous detection"
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</Button>
|
|
<div className="min-w-16 text-center text-sm font-medium text-foreground">
|
|
{detectionCount === 0
|
|
? '0 of 0'
|
|
: `${currentIndex + 1} of ${detectionCount}`}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() =>
|
|
setCurrentIndex((index) =>
|
|
detectionCount === 0 ? 0 : (index + 1) % detectionCount,
|
|
)
|
|
}
|
|
disabled={isNavigationDisabled}
|
|
aria-label="Next detection"
|
|
>
|
|
<ChevronRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{detectionsQuery.isLoading && !detectionsQuery.data ? (
|
|
<TicketClassDetectionPreviewSkeleton />
|
|
) : detectionsQuery.isError ? (
|
|
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-8 text-center">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<AlertTriangle className="size-6 text-destructive" />
|
|
<p className="text-sm font-medium text-destructive">
|
|
Failed to load detection preview.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void detectionsQuery.refetch()}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : !activeDetection || !detectionResult ? (
|
|
<div className="rounded-lg border border-dashed bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
|
|
No detections are available for this issue.
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-2">
|
|
<div className="min-w-0 space-y-2">
|
|
<p className="text-muted-foreground">Detection Image</p>
|
|
<div className="overflow-hidden rounded-lg">
|
|
<AnnotatedDetectionImage
|
|
detection={activeDetection}
|
|
videoWidth={detectionResult.video_width}
|
|
videoHeight={detectionResult.video_height}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DetectionLocationMap
|
|
className="min-w-0"
|
|
latitude={activeDetection.location.latitude}
|
|
longitude={activeDetection.location.longitude}
|
|
label={`${displayName} - Frame ${activeDetection.frame.number}`}
|
|
title="Location on Map"
|
|
variant="plain"
|
|
aspectRatio={mediaAspectRatio}
|
|
showCoordinates={false}
|
|
/>
|
|
</div>
|
|
|
|
<DetectionMetadataBar
|
|
items={[
|
|
{
|
|
label: 'Confidence',
|
|
value: `${Math.round(activeDetection.detection.confidence * 100)}%`,
|
|
},
|
|
{
|
|
label: 'Frame',
|
|
value: activeDetection.frame.number,
|
|
},
|
|
{
|
|
label: 'Timestamp',
|
|
value: `${activeDetection.frame.timestamp_seconds}s`,
|
|
},
|
|
]}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|