feat(results): add video results skeleton and refresh detection summary UI
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
function StatSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center">
|
||||||
|
<Skeleton className="mb-2 size-9 rounded-md" />
|
||||||
|
<Skeleton className="h-6 w-12" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetectionLogSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-card p-4">
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-12" />
|
||||||
|
<Skeleton className="h-3 w-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-32" />
|
||||||
|
<Skeleton className="h-3 w-44" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoResultsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<Skeleton className="size-10 rounded-md" />
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<Skeleton className="h-7 w-60 max-w-full" />
|
||||||
|
<Skeleton className="h-3 w-80 max-w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-xl border bg-card">
|
||||||
|
<div className="border-b p-6 pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="size-10 rounded" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-44" />
|
||||||
|
<Skeleton className="h-3 w-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="space-y-6 lg:col-span-2">
|
||||||
|
<Skeleton className="aspect-video w-full rounded-lg border" />
|
||||||
|
|
||||||
|
<section className="overflow-hidden rounded-lg border bg-card/80">
|
||||||
|
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-3 w-72 max-w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-16 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-5 w-36" />
|
||||||
|
</div>
|
||||||
|
<div className="h-107.5 rounded-lg border bg-muted/20 p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<DetectionLogSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<StatSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, Loader2, TrendingUp } from 'lucide-react';
|
import { ArrowLeft, TrendingUp } from 'lucide-react';
|
||||||
import VideoPlayerSection from '@/components/videoPlayerSection';
|
import VideoPlayerSection from '@/components/videoPlayerSection';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CompletedVideoResult, DetectionType } from '@/types';
|
import { CompletedVideoResult, DetectionType } from '@/types';
|
||||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
||||||
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||||
|
import { VideoResultsSkeleton } from './components/VideoResultsSkeleton';
|
||||||
|
|
||||||
const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
|
const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
|
||||||
if (data.detection_mode) return data.detection_mode as DetectionType;
|
if (data.detection_mode) return data.detection_mode as DetectionType;
|
||||||
@@ -50,11 +51,7 @@ export default function VideoResultsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <VideoResultsSkeleton />;
|
||||||
<div className="min-h-[60vh] flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function AnnotatedVideoPlayer({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-7 w-7 animate-spin text-primary" />
|
<Loader2 className="h-7 w-7 animate-spin text-primary" />
|
||||||
<p className="text-xs font-medium uppercase tracking-widest">
|
<p className="text-xs">
|
||||||
{isLoading ? 'Loading annotated video...' : 'Preparing video...'}
|
{isLoading ? 'Loading annotated video...' : 'Preparing video...'}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { MapPin } from 'lucide-react';
|
import { Activity, AlertTriangle, MapPin, SignpostBig } from 'lucide-react';
|
||||||
import { CompletedVideoResult, DetectionResultLog } from '@/types';
|
|
||||||
|
import type { CompletedVideoResult, DetectionResultLog } from '@/types';
|
||||||
|
|
||||||
interface CurrentDetectionBarProps {
|
interface CurrentDetectionBarProps {
|
||||||
activeLog?: DetectionResultLog;
|
activeLog?: DetectionResultLog;
|
||||||
@@ -17,17 +18,23 @@ export default function CurrentDetectionBar({
|
|||||||
{
|
{
|
||||||
label: 'Potholes',
|
label: 'Potholes',
|
||||||
value: currentCounts.unique_potholes || 0,
|
value: currentCounts.unique_potholes || 0,
|
||||||
|
icon: AlertTriangle,
|
||||||
className: 'text-orange-500',
|
className: 'text-orange-500',
|
||||||
|
iconClassName: 'bg-orange-500/10 text-orange-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Signboards',
|
label: 'Signboards',
|
||||||
value: currentCounts.unique_signboards || 0,
|
value: currentCounts.unique_signboards || 0,
|
||||||
|
icon: SignpostBig,
|
||||||
className: 'text-blue-500',
|
className: 'text-blue-500',
|
||||||
|
iconClassName: 'bg-blue-500/10 text-blue-500',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
value: currentCounts.total_detections || 0,
|
value: currentCounts.total_detections || 0,
|
||||||
|
icon: Activity,
|
||||||
className: 'text-green-500',
|
className: 'text-green-500',
|
||||||
|
iconClassName: 'bg-green-500/10 text-green-500',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const latitude = activeLog?.latitude;
|
const latitude = activeLog?.latitude;
|
||||||
@@ -38,33 +45,56 @@ export default function CurrentDetectionBar({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card px-4 py-3">
|
<section className="overflow-hidden rounded-lg border bg-card/80">
|
||||||
{coordinates && (
|
<div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="mb-3 flex justify-end">
|
<div>
|
||||||
<div className="flex max-w-full items-center gap-2 rounded-md border bg-muted/30 px-3 py-1.5">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
<MapPin className="size-3.5 shrink-0 text-muted-foreground" />
|
Current Detection Summary
|
||||||
<span className="truncate font-mono text-xs font-semibold text-foreground">
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Counts update as the playback timeline moves.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{coordinates ? (
|
||||||
|
<div className="flex max-w-full items-center gap-2 rounded-md bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="size-3.5 shrink-0" />
|
||||||
|
<span className="shrink-0 font-medium">Location</span>
|
||||||
|
<span className="truncate font-mono font-semibold text-foreground">
|
||||||
{coordinates}
|
{coordinates}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-2 p-3 sm:grid-cols-3">
|
||||||
{countItems.map((item) => (
|
{countItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-4 py-2"
|
className="flex items-center justify-between gap-3 rounded-md bg-muted/25 px-3 py-2.5 ring-1 ring-border/60"
|
||||||
>
|
>
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`flex size-8 shrink-0 items-center justify-center rounded-md ${item.iconClassName}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-2xl font-bold leading-none ${item.className}`}>
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-2xl font-bold leading-none ${item.className}`}
|
||||||
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user