feat(ticket): add paginated class detection preview with image and map
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
'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 isPreviousDisabled = currentIndex === 0 || detectionsQuery.isLoading;
|
||||
const isNextDisabled =
|
||||
detectionCount === 0 ||
|
||||
currentIndex + 1 >= detectionCount ||
|
||||
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 items-center justify-center rounded-full',
|
||||
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 className="truncate text-lg font-semibold text-foreground">
|
||||
{displayName}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'rounded-full border-0 px-2.5 py-1 text-xs font-semibold',
|
||||
visual.cardClassName,
|
||||
visual.colorClassName,
|
||||
)}
|
||||
>
|
||||
{detectionCount} Detections
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm 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="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentIndex((index) => Math.max(index - 1, 0))}
|
||||
disabled={isPreviousDisabled}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</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="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentIndex((index) => index + 1)}
|
||||
disabled={isNextDisabled}
|
||||
>
|
||||
Next
|
||||
<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-xs font-medium 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>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CalendarCheck2, Component, FileVideo, ListChecks } from 'lucide-react';
|
||||
import { Component, ListChecks } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { DEFECT_CLASS_STATUS_CONFIG } from '@/constants/defectClassStatus';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TicketOverviewDetail } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { useTicketDefectClassesQuery } from '../../hooks/useTicketQueries';
|
||||
import { TicketClassDetectionPreview } from './TicketClassDetectionPreview';
|
||||
|
||||
interface TicketDefectClassTabsProps {
|
||||
ticketId: string;
|
||||
@@ -64,10 +63,15 @@ export function TicketDefectClassTabs({
|
||||
const selectedIssue = ticket?.ai_result.detections_by_class.find(
|
||||
(item) => item.class_name === selectedClass,
|
||||
);
|
||||
const selectedIssueVisual = getDefectVisual(selectedIssue?.class_name ?? '');
|
||||
const SelectedIssueIcon = selectedIssueVisual.icon;
|
||||
const completedAt = ticket?.ai_result.completed_at;
|
||||
const videoName = ticket?.video?.name;
|
||||
const selectedClassItem = defectClasses.find(
|
||||
(item) => item.class_name === selectedClass,
|
||||
);
|
||||
const previewDisplayName =
|
||||
selectedIssue?.display_name ??
|
||||
selectedClassItem?.display_name ??
|
||||
selectedClass ??
|
||||
'Detection';
|
||||
const previewVideoId = ticket?.video_id ?? defectClassesQuery.data?.video_id;
|
||||
|
||||
if (defectClassesQuery.isLoading) {
|
||||
return (
|
||||
@@ -145,89 +149,12 @@ export function TicketDefectClassTabs({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 p-5">
|
||||
{selectedIssue ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-full',
|
||||
selectedIssueVisual.cardClassName,
|
||||
)}
|
||||
>
|
||||
<SelectedIssueIcon
|
||||
className={cn(
|
||||
'size-5',
|
||||
selectedIssueVisual.colorClassName,
|
||||
)}
|
||||
<TicketClassDetectionPreview
|
||||
videoId={previewVideoId}
|
||||
defectClassName={selectedClass}
|
||||
displayName={previewDisplayName}
|
||||
totalCount={selectedIssue?.count}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h3 className="truncate text-lg font-semibold text-foreground">
|
||||
{selectedIssue.display_name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border-0 px-2.5 py-1 text-xs font-semibold',
|
||||
selectedIssueVisual.cardClassName,
|
||||
selectedIssueVisual.colorClassName,
|
||||
)}
|
||||
>
|
||||
{selectedIssue.count} Detections
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Current Issue
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{selectedIssue.display_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Current Issue Count
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-violet-600">
|
||||
{selectedIssue.count}
|
||||
</p>
|
||||
</div>
|
||||
{completedAt ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<CalendarCheck2 className="size-4" />
|
||||
<span>AI Completed At</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{formatDate(completedAt)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{videoName ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<FileVideo className="size-4" />
|
||||
<span>Video</span>
|
||||
</div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{videoName}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-48 items-center justify-center text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Select an issue to view details.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ticketService } from '@/services/api';
|
||||
import { ticketService, videoService } from '@/services/api';
|
||||
import type {
|
||||
AssignTicketPayload,
|
||||
CloseTicketPayload,
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
SubmitRepairUploadPayload,
|
||||
TicketDetail,
|
||||
TicketListParams,
|
||||
VideoDetectionsParams,
|
||||
} from '@/types';
|
||||
|
||||
import { ticketKeys } from '../queries/ticketKeys';
|
||||
@@ -85,6 +86,37 @@ export function useTicketDefectClassesQuery(ticketId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketClassDetectionsQuery(
|
||||
videoId: string | undefined,
|
||||
params: VideoDetectionsParams,
|
||||
) {
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
skip: params.skip ?? 0,
|
||||
limit: params.limit ?? 1,
|
||||
class_name: params.class_name,
|
||||
min_confidence: params.min_confidence,
|
||||
sort: params.sort ?? 'timestamp_asc',
|
||||
search: params.search,
|
||||
}),
|
||||
[
|
||||
params.class_name,
|
||||
params.limit,
|
||||
params.min_confidence,
|
||||
params.search,
|
||||
params.skip,
|
||||
params.sort,
|
||||
],
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.classDetections(videoId ?? '', queryParams),
|
||||
queryFn: () =>
|
||||
videoService.getVideoDetections(videoId as string, queryParams),
|
||||
enabled: Boolean(videoId && queryParams.class_name),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTicketExtensionRequestQuery(
|
||||
ticketId: string | undefined,
|
||||
assignmentId: number | undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TicketListParams } from '@/types';
|
||||
import type { TicketListParams, VideoDetectionsParams } from '@/types';
|
||||
|
||||
export const ticketKeys = {
|
||||
all: ['tickets'] as const,
|
||||
@@ -9,6 +9,8 @@ export const ticketKeys = {
|
||||
[...ticketKeys.details(), ticketId, 'overview'] as const,
|
||||
classDetail: (ticketId: string, defectClass: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'class', defectClass] as const,
|
||||
classDetections: (videoId: string, params: VideoDetectionsParams) =>
|
||||
[...ticketKeys.details(), 'video', videoId, 'detections', params] as const,
|
||||
defectClasses: (ticketId: string) =>
|
||||
[...ticketKeys.details(), ticketId, 'defect-classes'] as const,
|
||||
extensionRequest: (ticketId: string, assignmentId: number | undefined) =>
|
||||
|
||||
@@ -46,11 +46,7 @@ export function useUploadListColumns(
|
||||
: undefined
|
||||
}
|
||||
role={thumbnail ? 'img' : undefined}
|
||||
aria-label={
|
||||
thumbnail
|
||||
? `Thumbnail for ${row.original.video_name}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={thumbnail ? 'Upload thumbnail' : undefined}
|
||||
>
|
||||
{!thumbnail ? (
|
||||
<Video className="size-5 text-muted-foreground" />
|
||||
@@ -59,14 +55,6 @@ export function useUploadListColumns(
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'video_name',
|
||||
header: 'Video Name',
|
||||
size: 150,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.video_name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'uploaded_by',
|
||||
header: 'Uploader',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import type { DetectionResultLog } from '@/types';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
|
||||
interface BoundingBoxOverlayProps {
|
||||
detections: DetectionResultLog[];
|
||||
detections: DetectionResultItem[];
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -14,6 +15,12 @@ type DetectionLocationMapProps = {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
label: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
aspectRatio?: string;
|
||||
showCoordinates?: boolean;
|
||||
variant?: 'card' | 'plain';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type ClientDetectionLocationMapProps = {
|
||||
@@ -70,35 +77,32 @@ export default function DetectionLocationMap({
|
||||
latitude,
|
||||
longitude,
|
||||
label,
|
||||
title = 'Detection Location',
|
||||
description = 'Selected log GPS coordinates',
|
||||
aspectRatio = '16 / 9',
|
||||
showCoordinates = true,
|
||||
variant = 'card',
|
||||
className,
|
||||
}: DetectionLocationMapProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded bg-secondary p-2">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Detection Location
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Selected log GPS coordinates
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
{latitude == null || longitude == null ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-sm text-muted-foreground">
|
||||
const mapContent =
|
||||
latitude == null || longitude == null ? (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border border-dashed bg-muted/30 px-4 py-8 text-sm text-muted-foreground"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
Coordinates are unavailable for the selected log.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showCoordinates ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{latitude.toFixed(6)}, {longitude.toFixed(6)}
|
||||
</div>
|
||||
<div className="h-72 overflow-hidden rounded-lg border bg-muted">
|
||||
) : null}
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border bg-muted"
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
<ClientDetectionLocationMap
|
||||
latitude={latitude}
|
||||
longitude={longitude}
|
||||
@@ -106,8 +110,33 @@ export default function DetectionLocationMap({
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{title ? (
|
||||
<p className="text-xs font-medium text-muted-foreground">{title}</p>
|
||||
) : null}
|
||||
{mapContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', className)}>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded bg-secondary p-2">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-6">{mapContent}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { AlertTriangle, ImageIcon, Loader2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { useProtectedMediaObjectUrl } from '@/hooks/useProtectedMedia';
|
||||
import type { DetectionResultLog } from '@/types';
|
||||
import type { DetectionResultItem } from '@/types';
|
||||
|
||||
import BoundingBoxOverlay from '@/components/annotation/boundingBoxOverlay';
|
||||
|
||||
interface AnnotatedDetectionImageProps {
|
||||
detection?: DetectionResultLog;
|
||||
detection?: DetectionResultItem;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const API_ROUTES = {
|
||||
UPLOAD_EVENTS: (token: string) =>
|
||||
`/biz/api/v1/uploads/events?sse_token=${encodeURIComponent(token)}`,
|
||||
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
|
||||
DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`,
|
||||
},
|
||||
TICKETS: {
|
||||
BASE: '/biz/api/v1/tickets',
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
PaginationParams,
|
||||
SseTokenResponse,
|
||||
UploadListResponse,
|
||||
VideoDetectionsParams,
|
||||
VideoDetectionsResponse,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
@@ -59,6 +61,26 @@ export const videoService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getVideoDetections: async (
|
||||
videoId: string,
|
||||
params?: VideoDetectionsParams,
|
||||
): Promise<VideoDetectionsResponse> => {
|
||||
const response = await axiosClient.get<VideoDetectionsResponse>(
|
||||
API_ROUTES.VIDEOS.DETECTIONS(videoId),
|
||||
{
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 1,
|
||||
class_name: params?.class_name,
|
||||
min_confidence: params?.min_confidence,
|
||||
sort: params?.sort ?? 'timestamp_asc',
|
||||
search: params?.search,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the annotated video as a blob through the authenticated axios client.
|
||||
*
|
||||
|
||||
@@ -14,7 +14,7 @@ export type DetectionBoundingBox = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type DetectionResultLog = {
|
||||
export type DetectionResultItem = {
|
||||
id: string;
|
||||
detection: {
|
||||
id: number;
|
||||
@@ -34,6 +34,9 @@ export type DetectionResultLog = {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DetectionResultLog = DetectionResultItem & {
|
||||
running_counts: {
|
||||
total: number;
|
||||
by_class: Array<{
|
||||
@@ -43,3 +46,23 @@ export type DetectionResultLog = {
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoDetectionsParams = {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
class_name?: string | string[];
|
||||
min_confidence?: number;
|
||||
sort?: string;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type VideoDetectionsResponse = {
|
||||
video_id: string;
|
||||
video_width: number;
|
||||
video_height: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
sort: string;
|
||||
items: DetectionResultItem[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user