3 Commits

15 changed files with 460 additions and 96 deletions

28
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3",
"@vidstack/react": "^1.15.6",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -3394,7 +3395,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3961,6 +3961,23 @@
"win32"
]
},
"node_modules/@vidstack/react": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/@vidstack/react/-/react-1.15.6.tgz",
"integrity": "sha512-hO7cLld5yfxCdDnDCYqh5aS7JxmHfsmX43PmKBJWzDTwK40P3hh5qAdDQzl3qDtEJLYjuo3Sv+kr6tt/VRtJzQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.10",
"media-captions": "^1.0.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -7180,6 +7197,15 @@
"node": ">= 0.4"
}
},
"node_modules/media-captions": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.4.tgz",
"integrity": "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",

View File

@@ -27,6 +27,7 @@
"@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3",
"@vidstack/react": "^1.15.6",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -4,9 +4,6 @@ import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { SparkleButton } from '@/components/ui/sparkle-button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import type { TicketOverviewDetail } from '@/types';
export function TicketDetailHeader({
@@ -25,17 +22,6 @@ export function TicketDetailHeader({
</div>
<div className="flex shrink-0 items-center gap-2">
{ticket.video_id ? (
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<SparkleButton
className="h-10 min-w-36 px-5"
onClick={() => router.push(`/results/${ticket.video_id}`)}
>
View Analysis
</SparkleButton>
</PermissionGuard>
) : null}
<Button
variant="outline"
size="sm"

View File

@@ -1,11 +1,34 @@
'use client';
import { LayoutDashboard } from 'lucide-react';
import {
Activity,
ArrowRight,
CalendarDays,
Clock3,
Gauge,
LayoutDashboard,
MapPin,
Monitor,
UserRound,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PersonInfo } from '@/components/person-avatar';
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { cn } from '@/lib/utils';
import type { TicketOverviewDetail } from '@/types';
import { formatDateOnly } from '@/utils/date';
import { formatDate } from '@/utils/date';
type IconComponent = React.ComponentType<{ className?: string }>;
function MetaLabel({ children }: { children: React.ReactNode }) {
return (
@@ -27,46 +50,221 @@ function OverviewField({
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<MetaLabel>{label}</MetaLabel>
{children}
</div>
);
}
export function TicketOverviewCard({
ticket,
function SummaryCard({
label,
value,
colorClassName,
cardClassName,
icon: Icon,
}: {
ticket: TicketOverviewDetail;
label: string;
value: number;
colorClassName: string;
cardClassName: string;
icon?: IconComponent;
}) {
return (
<div
className={cn(
'rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60',
cardClassName,
)}
>
<p className={cn('text-2xl font-bold tracking-tight', colorClassName)}>
{value}
</p>
<p className="mt-2 truncate text-xs font-semibold text-foreground">
{label}
</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">Detected</p>
</div>
);
}
function VideoMetric({
icon: Icon,
label,
}: {
icon: IconComponent;
label: string;
}) {
return (
<div className="flex items-center justify-center gap-2 font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{label}</span>
</div>
);
}
function OverviewStat({
label,
value,
valueClassName,
icon: Icon,
}: {
label: string;
value: React.ReactNode;
valueClassName?: string;
icon: IconComponent;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60">
<div className="min-w-0">
<MetaLabel>{label}</MetaLabel>
<p
className={cn(
'mt-2 truncate text-lg font-bold tracking-tight text-foreground',
valueClassName,
)}
>
{value}
</p>
</div>
<Icon className={cn('size-5 shrink-0 opacity-80', valueClassName)} />
</div>
);
}
export function TicketOverviewCard({
ticket,
defectClass,
}: {
ticket: TicketOverviewDetail;
defectClass?: string;
}) {
const router = useRouter();
const currentIssue =
ticket.ai_result.detections_by_class.find(
(item) => item.class_name === defectClass,
) ?? ticket.ai_result.detections_by_class[0];
const currentVisual = getDefectVisual(currentIssue?.class_name ?? '');
const minutes = Math.floor(ticket.video_metadata.duration_seconds / 60);
const seconds = Math.floor(ticket.video_metadata.duration_seconds % 60);
const duration = `${minutes}:${seconds.toString().padStart(2, '0')}`;
const chainageName =
ticket.location.chainage.name || ticket.chainage_name || null;
const packageName = ticket.location.package.name || null;
const locationLabel =
[chainageName, packageName].filter(Boolean).join(', ') || '—';
return (
<div className="space-y-5">
<Card>
<CardContent className="grid gap-4 py-4 sm:grid-cols-3 sm:divide-x">
<div className="flex items-center gap-3 sm:pr-4">
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Uploaded On">
<MetaValue>{formatDate(ticket.timestamps.created_at)}</MetaValue>
</OverviewField>
</div>
<div className="flex items-center gap-3 sm:px-4">
<UserRound className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Uploaded By">
<MetaValue>{ticket.uploader.name}</MetaValue>
</OverviewField>
</div>
<div className="flex items-center gap-3 sm:pl-4">
<MapPin className="size-5 shrink-0 text-muted-foreground" />
<OverviewField label="Location">
<MetaValue>{locationLabel}</MetaValue>
</OverviewField>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="size-5 text-primary" />
AI Detection Summary
</CardTitle>
{ticket.video_id ? (
<CardAction>
<PermissionGuard permissions={PERMISSIONS.TICKET.ASSIGN}>
<Button
variant="ghost"
size="sm"
className="text-primary hover:bg-primary/10 hover:text-primary"
onClick={() => router.push(`/results/${ticket.video_id}`)}
>
View Analysis
<ArrowRight className="size-3.5" />
</Button>
</PermissionGuard>
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-6">
<SummaryCard
label="Total Detections"
value={ticket.ai_result.detection_count}
colorClassName="text-violet-600"
cardClassName="bg-violet-500/8"
/>
{ticket.ai_result.detections_by_class.map((item) => {
const visual = getDefectVisual(item.class_name);
return (
<SummaryCard
key={item.class_name}
label={item.display_name}
value={item.count}
icon={visual.icon}
colorClassName={visual.colorClassName}
cardClassName={visual.cardClassName}
/>
);
})}
</div>
<div className="grid gap-3 rounded-lg bg-muted/35 px-4 py-3 text-sm sm:grid-cols-3">
<VideoMetric
icon={Gauge}
label={`${ticket.video_metadata.fps} FPS`}
/>
<VideoMetric icon={Clock3} label={`${duration} Duration`} />
<VideoMetric
icon={Monitor}
label={`${ticket.video_metadata.resolution.label} Resolution`}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutDashboard className="size-5 text-primary" />
Overview
Ticket Overview
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-x-4 gap-y-6 md:grid-cols-4">
<OverviewField label="Detection Count">
<MetaValue>{ticket.ai_result.detection_count} Occurrences</MetaValue>
</OverviewField>
<OverviewField label="Chainage">
<MetaValue>{ticket.chainage_name}</MetaValue>
</OverviewField>
<OverviewField label="Uploaded By">
<PersonInfo person={ticket.uploader} />
</OverviewField>
<OverviewField label="Created Date">
<MetaValue>
{formatDateOnly(ticket.timestamps.created_at)}
</MetaValue>
</OverviewField>
<CardContent className="grid gap-3 md:grid-cols-3">
<OverviewStat
icon={currentVisual.icon}
label="Current Issue"
value={currentIssue?.display_name ?? '-'}
valueClassName={currentVisual.colorClassName}
/>
<OverviewStat
icon={Activity}
valueClassName="text-violet-600"
label="Current Issue Count"
value={currentIssue?.count ?? 0}
/>
<OverviewStat
icon={LayoutDashboard}
valueClassName="text-violet-600"
label="Total AI Detections"
value={ticket.ai_result.detection_count}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -69,7 +69,7 @@ export default function TicketDetailPage() {
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
<div className="space-y-5">
{overview ? (
<TicketOverviewCard ticket={overview} />
<TicketOverviewCard ticket={overview} defectClass={defectClass} />
) : (
<TicketOverviewCardSkeleton />
)}

View File

@@ -144,3 +144,12 @@
scrollbar-gutter: stable;
}
}
.detection-video-player .vds-slider-preview {
display: none;
}
.detection-video-player
.vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) {
display: flex;
}

View File

@@ -1,5 +1,7 @@
import type { Metadata } from 'next';
import { Geist_Mono, Poppins } from 'next/font/google';
import '@vidstack/react/player/styles/default/theme.css';
import '@vidstack/react/player/styles/default/layouts/video.css';
import './globals.css';
import { ThemeProvider } from '@/providers/ThemeProvider';
import AppInitializer from '@/providers/AppInitializer';

View File

@@ -2,9 +2,22 @@
import { RefObject } from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import type { DetectionResultLog } from '@/types';
import { useDetectionThumbnails } from './useDetectionThumbnails';
import {
MediaPlayer,
MediaProvider,
Poster,
type MediaPlayerInstance,
} from '@vidstack/react';
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
interface AnnotatedVideoPlayerProps {
videoRef: RefObject<HTMLVideoElement | null>;
videoRef: RefObject<MediaPlayerInstance | null>;
logs: DetectionResultLog[];
videoUrl: string | null;
isLoading: boolean;
isError: boolean;
@@ -15,6 +28,7 @@ interface AnnotatedVideoPlayerProps {
export default function AnnotatedVideoPlayer({
videoRef,
logs,
videoUrl,
isLoading,
isError,
@@ -22,18 +36,30 @@ export default function AnnotatedVideoPlayer({
onTimeUpdate,
onSeeked,
}: AnnotatedVideoPlayerProps) {
const thumbnails = useDetectionThumbnails(logs);
return (
<div className="relative overflow-hidden rounded-lg border bg-black">
{videoUrl ? (
<video
<MediaPlayer
ref={videoRef}
src={videoUrl}
controls
preload="metadata"
src={{ src: videoUrl, type: 'video/mp4' }}
viewType="video"
streamType="on-demand"
crossOrigin
playsInline
onTimeUpdate={onTimeUpdate}
onSeeked={onSeeked}
className="block w-full aspect-video"
className="detection-video-player aspect-video w-full"
>
<MediaProvider>
<Poster className="vds-poster" />
</MediaProvider>
<DefaultVideoLayout
thumbnails={thumbnails}
icons={defaultLayoutIcons}
/>
</MediaPlayer>
) : (
<div className="flex aspect-video w-full flex-col items-center justify-center gap-3 text-center text-muted-foreground">
{isError ? (
@@ -54,7 +80,9 @@ export default function AnnotatedVideoPlayer({
<>
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<p className="text-xs">
{isLoading ? 'Loading annotated video...' : 'Preparing video...'}
{isLoading
? 'Loading annotated video...'
: 'Preparing video...'}
</p>
</>
)}

View File

@@ -4,7 +4,7 @@ import { Activity, MapPin } from 'lucide-react';
import type { CompletedVideoResult, DetectionResultLog } from '@/types';
import { getDefectVisual } from './defectVisualConfig';
import { getDefectVisual } from '@/constants/defectVisualConfig';
interface CurrentDetectionBarProps {
activeLog?: DetectionResultLog;

View File

@@ -1,9 +1,10 @@
'use client';
import { Activity, Clock, Gauge } from 'lucide-react';
import { CompletedVideoResult } from '@/types';
import { getDefectVisual } from './defectVisualConfig';
import { getDefectVisual } from '@/constants/defectVisualConfig';
import { cn } from '@/lib/utils';
import type { CompletedVideoResult } from '@/types';
const formatDuration = (seconds?: number) => {
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return '-';
@@ -28,8 +29,8 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
label: item.display_name,
value: item.count,
icon: visual.icon,
color: visual.colorClassName,
bgColor: visual.iconClassName,
colorClassName: visual.colorClassName,
iconClassName: visual.iconClassName,
};
});
@@ -38,23 +39,23 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
label: 'Total Detections',
value: data.summary.total_detections,
icon: Activity,
color: 'text-green-500',
bgColor: 'bg-green-500/10 text-green-500',
colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
},
...defectStats,
{
label: 'FPS',
value: formatFps(data.summary.fps),
icon: Gauge,
color: 'text-purple-500',
bgColor: 'bg-purple-500/10 text-purple-500',
colorClassName: 'text-purple-500',
iconClassName: 'bg-purple-500/10 text-purple-500',
},
{
label: 'Duration',
value: formatDuration(data.summary.duration_seconds),
icon: Clock,
color: 'text-cyan-500',
bgColor: 'bg-cyan-500/10 text-cyan-500',
colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500',
},
];
@@ -66,17 +67,29 @@ export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
return (
<div
key={stat.label}
className="flex flex-col items-center rounded-lg border border-muted bg-muted/30 p-4 text-center"
className="rounded-xl bg-muted/40 p-4 transition-colors hover:bg-muted/60"
>
<div className="flex items-center justify-between gap-3">
<p
className={cn(
'text-2xl font-bold tracking-tight',
stat.colorClassName,
)}
>
<div className={`mb-2 rounded-md p-2 ${stat.bgColor}`}>
<Icon className="h-5 w-5" />
</div>
<div className={`text-xl font-bold ${stat.color}`}>
{stat.value}
</p>
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-lg',
stat.iconClassName,
)}
>
<Icon className="size-3.5" />
</span>
</div>
<div className="mt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<p className="mt-2 truncate text-xs font-semibold text-foreground">
{stat.label}
</div>
</p>
</div>
);
})}

View File

@@ -1,11 +1,12 @@
'use client';
import { RefObject, useMemo, useRef, useState } from 'react';
import type { MediaPlayerInstance } from '@vidstack/react';
import { CompletedVideoResult, DetectionResultLog } from '@/types';
interface UseDetectionPlaybackParams {
logs: CompletedVideoResult['logs'];
videoRef: RefObject<HTMLVideoElement | null>;
videoRef: RefObject<MediaPlayerInstance | null>;
}
export function useDetectionPlayback({

View File

@@ -0,0 +1,66 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQueries } from '@tanstack/react-query';
import { protectedMediaService } from '@/services/api';
import type { DetectionResultLog } from '@/types';
type DetectionThumbnail = {
url: string;
startTime: number;
endTime: number;
};
const PREVIEW_WINDOW_SECONDS = 0.5;
export function useDetectionThumbnails(logs: DetectionResultLog[]) {
const imageUrls = useMemo(
() => logs.map((log) => log.detection.context_image_url),
[logs],
);
const blobs = useQueries({
queries: imageUrls.map((url) => ({
queryKey: ['protected-media', url],
queryFn: () => protectedMediaService.getBlob(url),
enabled: Boolean(url),
staleTime: Infinity,
gcTime: 1000 * 60 * 30,
})),
combine: (results) => results.map((result) => result.data),
});
const [objectUrls, setObjectUrls] = useState<Array<string | null>>([]);
useEffect(() => {
const nextUrls = blobs.map((blob) =>
blob ? URL.createObjectURL(blob) : null,
);
setObjectUrls(nextUrls);
return () => {
nextUrls.forEach((url) => {
if (url) URL.revokeObjectURL(url);
});
};
}, [blobs]);
return useMemo<DetectionThumbnail[]>(
() =>
logs.flatMap((log, index) => {
const url = objectUrls[index];
if (!url) return [];
const timestamp = log.frame.timestamp_seconds;
return [
{
url,
startTime: Math.max(0, timestamp - PREVIEW_WINDOW_SECONDS),
endTime: timestamp + PREVIEW_WINDOW_SECONDS,
},
];
}),
[logs, objectUrls],
);
}

View File

@@ -2,6 +2,7 @@
import { useRef } from 'react';
import { Film } from 'lucide-react';
import type { MediaPlayerInstance } from '@vidstack/react';
import {
Card,
CardContent,
@@ -22,14 +23,9 @@ type VideoPlayerSectionProps = {
};
export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const {
activeLog,
sortedLogs,
handleSeek,
handleSeeked,
handleTimeUpdate,
} = useDetectionPlayback({
const videoRef = useRef<MediaPlayerInstance>(null);
const { activeLog, sortedLogs, handleSeek, handleSeeked, handleTimeUpdate } =
useDetectionPlayback({
logs: data.logs,
videoRef,
});
@@ -63,6 +59,7 @@ export default function VideoPlayerSection({ data }: VideoPlayerSectionProps) {
<div className="space-y-6 lg:col-span-2">
<AnnotatedVideoPlayer
videoRef={videoRef}
logs={sortedLogs}
videoUrl={videoUrl}
isLoading={isVideoLoading}
isError={isVideoError}

View File

@@ -2,11 +2,11 @@
import {
Activity,
AlertTriangle,
CircleAlert,
Construction,
Spline,
CircleDot,
Droplets,
LucideIcon,
type LucideIcon,
SignpostBig,
} from 'lucide-react';
@@ -14,6 +14,7 @@ type DefectVisualConfig = {
icon: LucideIcon;
colorClassName: string;
iconClassName: string;
cardClassName: string;
};
const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
@@ -21,26 +22,31 @@ const DEFECT_VISUALS: Record<string, DefectVisualConfig> = {
icon: CircleDot,
colorClassName: 'text-zinc-500',
iconClassName: 'bg-zinc-500/10 text-zinc-500',
cardClassName: 'bg-zinc-500/5',
},
pothole: {
icon: AlertTriangle,
icon: Construction,
colorClassName: 'text-orange-500',
iconClassName: 'bg-orange-500/10 text-orange-500',
cardClassName: 'bg-orange-500/5',
},
road_crack: {
icon: CircleAlert,
icon: Spline,
colorClassName: 'text-rose-500',
iconClassName: 'bg-rose-500/10 text-rose-500',
cardClassName: 'bg-rose-500/5',
},
sign_board: {
icon: SignpostBig,
colorClassName: 'text-blue-500',
iconClassName: 'bg-blue-500/10 text-blue-500',
cardClassName: 'bg-blue-500/5',
},
water_puddle: {
icon: Droplets,
colorClassName: 'text-cyan-500',
iconClassName: 'bg-cyan-500/10 text-cyan-500',
cardClassName: 'bg-cyan-500/5',
},
};
@@ -48,6 +54,7 @@ const DEFAULT_VISUAL: DefectVisualConfig = {
icon: Activity,
colorClassName: 'text-green-500',
iconClassName: 'bg-green-500/10 text-green-500',
cardClassName: 'bg-green-500/5',
};
export const getDefectVisual = (className: string) =>

View File

@@ -80,12 +80,41 @@ export interface TicketTimestamps {
updated_at: string;
}
export interface TicketLocationReference {
id: string;
name: string;
}
export interface TicketLocation {
project: TicketLocationReference;
package: TicketLocationReference;
chainage: TicketLocationReference;
}
export interface TicketVideoMetadata {
duration_seconds: number;
fps: number;
resolution: {
width: number;
height: number;
label: string;
};
}
export interface TicketDetectionClassCount {
class_name: string;
display_name: string;
count: number;
}
export interface TicketOverviewDetail {
id: string;
ticket_name: string;
video_id: string;
chainage_id: string;
chainage_name: string;
location: TicketLocation;
video_metadata: TicketVideoMetadata;
uploader: {
user_id: number;
name: string;
@@ -95,6 +124,7 @@ export interface TicketOverviewDetail {
detection_count: number;
completed_at: string;
available_defect_classes: string[];
detections_by_class: TicketDetectionClassCount[];
};
timestamps: TicketTimestamps;
}