feat: add My Uploads listing with card and table views

This commit is contained in:
2026-06-18 21:22:44 +05:30
parent dff9e2222f
commit a18fff4d6f
28 changed files with 1060 additions and 127 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -7,6 +7,7 @@ import { toast } from 'sonner';
import { packageService, projectService } from '@/services/api'; import { packageService, projectService } from '@/services/api';
import type { Package, PaginationParams } from '@/types'; import type { Package, PaginationParams } from '@/types';
import { projectKeys } from '../../project/queries/projectKeys';
import { packageKeys } from '../queries/packageKeys'; import { packageKeys } from '../queries/packageKeys';
interface UsePackagesQueryParams { interface UsePackagesQueryParams {
@@ -28,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
export function useProjectOptionsQuery() { export function useProjectOptionsQuery() {
return useQuery({ return useQuery({
queryKey: ['projects', 'options'], queryKey: projectKeys.options(),
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
}); });
} }

View File

@@ -4,6 +4,7 @@ export const packageKeys = {
all: ['packages'] as const, all: ['packages'] as const,
lists: () => [...packageKeys.all, 'list'] as const, lists: () => [...packageKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const, list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
options: () => [...packageKeys.all, 'options'] as const,
details: () => [...packageKeys.all, 'detail'] as const, details: () => [...packageKeys.all, 'detail'] as const,
detail: (id: string) => [...packageKeys.details(), id] as const, detail: (id: string) => [...packageKeys.details(), id] as const,
}; };

View File

@@ -4,6 +4,7 @@ export const projectKeys = {
all: ['projects'] as const, all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const, lists: () => [...projectKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const, list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
options: () => [...projectKeys.all, 'options'] as const,
details: () => [...projectKeys.all, 'detail'] as const, details: () => [...projectKeys.all, 'detail'] as const,
detail: (id: string) => [...projectKeys.details(), id] as const, detail: (id: string) => [...projectKeys.details(), id] as const,
}; };

View File

@@ -11,6 +11,8 @@ import {
} from '@/services/api'; } from '@/services/api';
import type { Chainage, PaginationParams } from '@/types'; import type { Chainage, PaginationParams } from '@/types';
import { packageKeys } from '../../package/queries/packageKeys';
import { projectKeys } from '../../project/queries/projectKeys';
import { segmentKeys } from '../queries/segmentKeys'; import { segmentKeys } from '../queries/segmentKeys';
interface UseSegmentsQueryParams { interface UseSegmentsQueryParams {
@@ -32,14 +34,14 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
export function useProjectOptionsQuery() { export function useProjectOptionsQuery() {
return useQuery({ return useQuery({
queryKey: ['projects', 'options'], queryKey: projectKeys.options(),
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
}); });
} }
export function useAllPackageOptionsQuery() { export function useAllPackageOptionsQuery() {
return useQuery({ return useQuery({
queryKey: ['packages', 'options'], queryKey: packageKeys.options(),
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }), queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
}); });
} }

View File

@@ -0,0 +1,79 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const pageSizes = [10, 20, 40, 50, 100];
interface CardPaginationProps {
skip: number;
limit: number;
total: number;
count: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
}
export function CardPagination({
skip,
limit,
total,
count,
onPageChange,
onLimitChange,
}: CardPaginationProps) {
return (
<div className="flex flex-col gap-3 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-3">
<span>Showing {count} of {total}</span>
<div className="flex items-center gap-2">
<span>Rows per page</span>
<Select
value={String(limit)}
onValueChange={(value) => onLimitChange(Number(value))}
>
<SelectTrigger className="h-8 w-18 bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizes.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={skip === 0}
onClick={() => onPageChange(Math.max(0, skip - limit))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={skip + limit >= total}
onClick={() => onPageChange(skip + limit)}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useRef, useState } from 'react';
import { Loader2, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import type { SessionContext } from '@/types';
import { useCreateUploadMutation } from '../hooks/useUploadQueries';
import { InputDataSection } from './InputDataSection';
import { LocationSection } from './LocationSection';
import { UploadSettingsSection } from './UploadSettingsSection';
interface CreateUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateUploadDialog({
open,
onOpenChange,
}: CreateUploadDialogProps) {
const [session, setSession] = useState<SessionContext | null>(null);
const [videoFile, setVideoFile] = useState<File | null>(null);
const [gpsFile, setGpsFile] = useState<File | null>(null);
const [detectionMode, setDetectionMode] = useState('yolo');
const [error, setError] = useState<string | null>(null);
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const createUploadMutation = useCreateUploadMutation();
const isSubmitting = createUploadMutation.isPending;
const resetForm = () => {
setSession(null);
setVideoFile(null);
setGpsFile(null);
setDetectionMode('yolo');
setError(null);
createUploadMutation.reset();
};
const canSubmit = Boolean(
session?.chainageId && videoFile && gpsFile && detectionMode,
);
const handleOpenChange = (nextOpen: boolean) => {
onOpenChange(nextOpen);
if (!nextOpen) {
resetForm();
}
};
const handleSubmit = async () => {
if (!session?.chainageId || !videoFile || !gpsFile) {
setError('Complete location and input data before uploading.');
return;
}
const formData = new FormData();
formData.append('file', videoFile);
formData.append('detection_mode', detectionMode);
formData.append('chainage_id', session.chainageId);
formData.append('json_file', gpsFile);
setError(null);
try {
await createUploadMutation.mutateAsync(formData);
handleOpenChange(false);
} catch (err) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
setError(
'Cannot connect to server. Please check if backend is running.',
);
return;
}
setError(err instanceof Error ? err.message : 'Upload failed');
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-5xl"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
Upload Video
</DialogTitle>
<DialogDescription>
Configure location and upload road inspection data.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4">
<LocationSection
value={session}
onChange={setSession}
disabled={isSubmitting}
portalContainer={dropdownPortalRef}
/>
<Separator />
<InputDataSection
videoFile={videoFile}
gpsFile={gpsFile}
onVideoFileChange={(file) => {
setVideoFile(file);
setError(null);
}}
onGpsFileChange={(file) => {
setGpsFile(file);
setError(null);
}}
disabled={isSubmitting}
/>
<Separator />
<UploadSettingsSection
value={detectionMode}
onValueChange={setDetectionMode}
disabled={isSubmitting}
portalContainer={dropdownPortalRef}
/>
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
</div>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"
disabled={isSubmitting}
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
disabled={!canSubmit || isSubmitting}
onClick={handleSubmit}
className="gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Uploading...
</>
) : (
<>
<Play className="size-4" />
Upload
</>
)}
</Button>
</DialogFooter>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);
}

View File

@@ -1,24 +1,36 @@
'use client'; 'use client';
import type { RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { FormField } from '@/components/form'; import { FormField } from '@/components/form';
import { PackageSelect } from '@/components/lookups/PackageSelect'; import { PackageSelect } from '@/components/lookups/PackageSelect';
import { ProjectSelect } from '@/components/lookups/ProjectSelect'; import { ProjectSelect } from '@/components/lookups/ProjectSelect';
import { SegmentSelect } from '@/components/lookups/SegmentSelect'; import { SegmentSelect } from '@/components/lookups/SegmentSelect';
import { packageService, projectService, chainageService } from '@/services/api'; import {
import type { Chainage, Package as PackageType, Project, SessionContext } from '@/types'; packageService,
projectService,
chainageService,
} from '@/services/api';
import type {
Chainage,
Package as PackageType,
Project,
SessionContext,
} from '@/types';
interface LocationSectionProps { interface LocationSectionProps {
value: SessionContext | null; value: SessionContext | null;
onChange: (session: SessionContext | null) => void; onChange: (session: SessionContext | null) => void;
disabled?: boolean; disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
} }
export function LocationSection({ export function LocationSection({
value, value,
onChange, onChange,
disabled = false, disabled = false,
portalContainer,
}: LocationSectionProps) { }: LocationSectionProps) {
const [projectId, setProjectId] = useState(value?.projectId ?? ''); const [projectId, setProjectId] = useState(value?.projectId ?? '');
const [packageId, setPackageId] = useState(value?.packageId ?? ''); const [packageId, setPackageId] = useState(value?.packageId ?? '');
@@ -106,7 +118,7 @@ export function LocationSection({
<div> <div>
<h2 className="text-sm font-semibold text-foreground">Location</h2> <h2 className="text-sm font-semibold text-foreground">Location</h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Select the project hierarchy for this analysis job. Select the project hierarchy for this upload.
</p> </p>
</div> </div>
@@ -116,6 +128,7 @@ export function LocationSection({
value={projectId} value={projectId}
onValueChange={handleProjectChange} onValueChange={handleProjectChange}
disabled={disabled} disabled={disabled}
portalContainer={portalContainer}
/> />
</FormField> </FormField>
@@ -126,6 +139,7 @@ export function LocationSection({
projectId={projectId} projectId={projectId}
enabled={Boolean(projectId)} enabled={Boolean(projectId)}
disabled={disabled || !projectId} disabled={disabled || !projectId}
portalContainer={portalContainer}
/> />
</FormField> </FormField>
@@ -136,10 +150,10 @@ export function LocationSection({
packageId={packageId} packageId={packageId}
enabled={Boolean(packageId)} enabled={Boolean(packageId)}
disabled={disabled || !packageId} disabled={disabled || !packageId}
portalContainer={portalContainer}
/> />
</FormField> </FormField>
</div> </div>
</section> </section>
); );
} }

View File

@@ -0,0 +1,53 @@
'use client';
import { Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
import type { UploadVideo } from '@/types';
interface ProcessedVideoDialogProps {
upload: UploadVideo | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ProcessedVideoDialog({
upload,
open,
onOpenChange,
}: ProcessedVideoDialogProps) {
const { videoUrl, isLoading, isError } = useAnnotatedVideoQuery(
open ? upload?.processed_video_url ?? undefined : undefined,
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{upload?.filename ?? 'Processed Video'}</DialogTitle>
<DialogDescription>Processed video preview</DialogDescription>
</DialogHeader>
<div className="flex aspect-video items-center justify-center overflow-hidden rounded-md border bg-muted/30">
{isLoading ? (
<Loader2 className="size-6 animate-spin text-primary" />
) : isError || !videoUrl ? (
<p className="text-sm text-muted-foreground">
Processed video is not available.
</p>
) : (
<video src={videoUrl} controls className="h-full w-full" />
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { Eye, MoreVertical, PlayCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UploadVideo } from '@/types';
interface UploadActionsMenuProps {
upload: UploadVideo;
onViewDetails: (upload: UploadVideo) => void;
onViewProcessedVideo: (upload: UploadVideo) => void;
}
export function UploadActionsMenu({
upload,
onViewDetails,
onViewProcessedVideo,
}: UploadActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={(event) => event.stopPropagation()}
>
<MoreVertical className="size-4" />
<span className="sr-only">Open actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onViewDetails(upload)}>
<Eye className="size-4" />
View Details
</DropdownMenuItem>
{upload.processed_video_url ? (
<DropdownMenuItem onClick={() => onViewProcessedVideo(upload)}>
<PlayCircle className="size-4" />
View Processed Video
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { FileVideo } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import type { UploadVideo } from '@/types';
import { CardPagination } from './CardPagination';
import { UploadActionsMenu } from './UploadActionsMenu';
import { UploadThumbnail } from './UploadThumbnail';
import { formatUploadedAt } from './uploadUtils';
interface UploadCardGridProps {
uploads: UploadVideo[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onUploadNew: () => void;
onOpenUpload: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void;
onViewProcessedVideo: (upload: UploadVideo) => void;
}
export function UploadCardGrid({
uploads,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
onUploadNew,
onOpenUpload,
onViewDetails,
onViewProcessedVideo,
}: UploadCardGridProps) {
const skeletonItems = Array.from({ length: Math.min(limit, 20) });
if (!isLoading && uploads.length === 0) {
return (
<Card>
<CardContent className="flex min-h-80 flex-col items-center justify-center gap-3 text-center">
<div className="flex size-12 items-center justify-center rounded-md border bg-muted/40">
<FileVideo className="size-6 text-muted-foreground" />
</div>
<div>
<h2 className="text-base">No uploads found</h2>
<p className="text-sm text-muted-foreground">
Upload your first road inspection video.
</p>
</div>
<Button onClick={onUploadNew}>Upload New</Button>
</CardContent>
</Card>
);
}
return (
<section className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
{isLoading
? skeletonItems.map((_, index) => (
<Card key={index} className="h-62 animate-pulse bg-muted/30" />
))
: uploads.map((upload) => (
<Card
key={upload.video_id}
className="cursor-pointer overflow-hidden transition-colors hover:bg-muted/30"
onClick={() => onOpenUpload(upload)}
>
<CardContent className="space-y-3 p-3">
<UploadThumbnail upload={upload} />
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{upload.filename}
</p>
<p className="text-xs text-muted-foreground">
Uploaded {formatUploadedAt(upload.uploaded_at)}
</p>
</div>
<UploadActionsMenu
upload={upload}
onViewDetails={onViewDetails}
onViewProcessedVideo={onViewProcessedVideo}
/>
</div>
</CardContent>
</Card>
))}
</div>
<CardPagination
skip={skip}
limit={limit}
total={total}
count={uploads.length}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
/>
</section>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { UploadActionsMenu } from './UploadActionsMenu';
import { UploadThumbnail } from './UploadThumbnail';
import { formatUploadedAt, getUploadStatus } from './uploadUtils';
import { Badge } from '@/components/ui/badge';
import type { UploadVideo } from '@/types';
interface UseUploadColumnsProps {
onViewDetails: (upload: UploadVideo) => void;
onViewProcessedVideo: (upload: UploadVideo) => void;
}
export function useUploadColumns({
onViewDetails,
onViewProcessedVideo,
}: UseUploadColumnsProps) {
return useMemo<ColumnDef<UploadVideo>[]>(
() => [
{
id: 'thumbnail',
header: 'Thumbnail',
size: 140,
minSize: 120,
enableSorting: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<UploadThumbnail upload={row.original} className="w-28" />
),
},
{
accessorKey: 'filename',
header: 'Filename',
cell: ({ row }) => (
<span className="font-medium">{row.original.filename}</span>
),
},
{
id: 'status',
header: 'Status',
size: 130,
cell: ({ row }) => (
<Badge className="rounded-full">
{getUploadStatus(row.original)}
</Badge>
),
},
{
accessorKey: 'uploaded_at',
header: 'Uploaded At',
cell: ({ row }) => formatUploadedAt(row.original.uploaded_at),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 56,
minSize: 56,
enableSorting: false,
enableHiding: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<div className="flex justify-center">
<UploadActionsMenu
upload={row.original}
onViewDetails={onViewDetails}
onViewProcessedVideo={onViewProcessedVideo}
/>
</div>
),
},
],
[onViewDetails, onViewProcessedVideo],
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { UploadVideo } from '@/types';
import { formatUploadedAt, getUploadStatus } from './uploadUtils';
interface UploadDetailsDialogProps {
upload: UploadVideo | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UploadDetailsDialog({
upload,
open,
onOpenChange,
}: UploadDetailsDialogProps) {
const title = upload?.filename ?? 'Upload Details';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader className="min-w-0 pr-8">
<DialogTitle className="min-w-0 max-w-full overflow-hidden text-left">
{upload?.filename ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
title={title}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 break-all">
{title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
title
)}
</DialogTitle>
<DialogDescription>Uploaded video details</DialogDescription>
</DialogHeader>
{upload ? (
<dl className="grid grid-cols-[120px_1fr] gap-x-4 gap-y-3 text-sm">
<dt className="text-muted-foreground">Status</dt>
<dd>
<Badge className="rounded-full">{getUploadStatus(upload)}</Badge>
</dd>
<dt className="text-muted-foreground">Uploaded At</dt>
<dd>{formatUploadedAt(upload.uploaded_at)}</dd>
<dt className="text-muted-foreground">Uploaded By</dt>
<dd>{upload.uploaded_by_email ?? '-'}</dd>
<dt className="text-muted-foreground">Video ID</dt>
<dd className="break-all font-mono text-xs">{upload.video_id}</dd>
</dl>
) : null}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import type { RefObject } from 'react';
import { FormField, SelectPopover } from '@/components/form'; import { FormField, SelectPopover } from '@/components/form';
const options = [ const options = [
@@ -14,12 +16,14 @@ interface UploadSettingsSectionProps {
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
} }
export function UploadSettingsSection({ export function UploadSettingsSection({
value, value,
onValueChange, onValueChange,
disabled = false, disabled = false,
portalContainer,
}: UploadSettingsSectionProps) { }: UploadSettingsSectionProps) {
return ( return (
<section className="space-y-3"> <section className="space-y-3">
@@ -33,12 +37,13 @@ export function UploadSettingsSection({
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<FormField id="detection-method" label="Detection Method"> <FormField id="detection-mode" label="Detection Mode">
<SelectPopover <SelectPopover
options={options} options={options}
value={value} value={value}
onValueChange={onValueChange} onValueChange={onValueChange}
disabled={disabled} disabled={disabled}
portalContainer={portalContainer}
placeholder="Select method" placeholder="Select method"
searchPlaceholder="Search methods" searchPlaceholder="Search methods"
emptyMessage="No methods found." emptyMessage="No methods found."

View File

@@ -0,0 +1,50 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { UploadVideo } from '@/types';
interface UploadTableProps {
columns: ColumnDef<UploadVideo>[];
uploads: UploadVideo[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onRowClick: (upload: UploadVideo) => void;
}
export function UploadTable({
columns,
uploads,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
onRowClick,
}: UploadTableProps) {
return (
<DataTable
data={uploads}
columns={columns}
isLoading={isLoading}
emptyTitle="No uploads found"
emptyDescription="Upload your first road inspection video."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange,
}}
onRowClick={onRowClick}
enableColumnControls={false}
minColumnSize={120}
/>
);
}

View File

@@ -0,0 +1,33 @@
import { ImageIcon } from 'lucide-react';
import type { UploadVideo } from '@/types';
import { resolveMediaUrl } from './uploadUtils';
interface UploadThumbnailProps {
upload: UploadVideo;
className?: string;
}
export function UploadThumbnail({ upload, className }: UploadThumbnailProps) {
const thumbnailUrl = resolveMediaUrl(upload.thumbnail);
return (
<div className={className}>
<div className="aspect-video overflow-hidden rounded-md border bg-muted/40">
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={upload.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="size-8" />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { Grid2X2, Table2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { UploadViewMode } from '../hooks/useUploadFilters';
interface ViewSwitcherProps {
value: UploadViewMode;
onValueChange: (value: UploadViewMode) => void;
}
export function ViewSwitcher({ value, onValueChange }: ViewSwitcherProps) {
const items: Array<{
value: UploadViewMode;
label: string;
icon: React.ReactNode;
}> = [
{ value: 'card', label: 'Card View', icon: <Grid2X2 className="size-4" /> },
{ value: 'table', label: 'Table View', icon: <Table2 className="size-4" /> },
];
return (
<div className="inline-flex rounded-md border bg-card p-1">
{items.map((item) => (
<Button
key={item.value}
type="button"
variant="ghost"
size="sm"
onClick={() => onValueChange(item.value)}
className={cn(
'gap-2',
value === item.value && 'bg-secondary text-foreground',
)}
>
{item.icon}
{item.label}
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { ENV_CONSTANT } from '@/constants/secrect.constant';
import type { UploadVideo } from '@/types';
export function getUploadStatus(upload: UploadVideo) {
return upload.status?.status || 'unknown';
}
export function formatUploadedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
export function resolveMediaUrl(url?: string | null) {
if (!url) return null;
if (/^https?:\/\//i.test(url)) return url;
const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, '');
const path = url.startsWith('/') ? url : `/${url}`;
return baseUrl ? `${baseUrl}${path}` : path;
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useState } from 'react';
export type UploadViewMode = 'card' | 'table';
const DEFAULT_LIMIT = 20;
export function useUploadFilters() {
const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(DEFAULT_LIMIT);
const [viewMode, setViewMode] = useState<UploadViewMode>('card');
const setLimit = (nextLimit: number) => {
setLimitValue(nextLimit);
setSkip(0);
};
return {
skip,
setSkip,
limit,
setLimit,
viewMode,
setViewMode,
};
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { videoService } from '@/services/api';
import type { PaginationParams } from '@/types';
export const uploadKeys = {
all: ['uploads'] as const,
lists: () => [...uploadKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...uploadKeys.lists(), params] as const,
};
interface UseMyUploadsQueryParams {
skip: number;
limit: number;
}
function buildUploadListParams({
skip,
limit,
}: UseMyUploadsQueryParams): PaginationParams {
return {
skip,
limit,
};
}
export function useMyUploadsQuery(params: UseMyUploadsQueryParams) {
const { skip, limit } = params;
const listParams = useMemo(
() => buildUploadListParams({ skip, limit }),
[limit, skip],
);
return useQuery({
queryKey: uploadKeys.list(listParams),
queryFn: () => videoService.getMyUploads(listParams),
});
}
export function useCreateUploadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (formData: FormData) => videoService.uploadVideo(formData),
onSuccess: () => {
toast.success('Upload created');
queryClient.invalidateQueries({ queryKey: uploadKeys.all });
},
});
}

View File

@@ -1,134 +1,123 @@
'use client'; 'use client';
import { useState } from 'react'; import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation'; import { Plus, UploadCloud } from 'lucide-react';
import { Loader2, TrendingUp, UploadCloud } from 'lucide-react';
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 { Card, CardContent } from '@/components/ui/card'; import type { UploadVideo } from '@/types';
import { Separator } from '@/components/ui/separator';
import { videoService } from '@/services/api';
import type { SessionContext } from '@/types';
import { ROUTES } from '@/utils/routes';
import { InputDataSection } from './components/InputDataSection'; import { CreateUploadDialog } from './components/CreateUploadDialog';
import { LocationSection } from './components/LocationSection'; import { ProcessedVideoDialog } from './components/ProcessedVideoDialog';
import { UploadSettingsSection } from './components/UploadSettingsSection'; import { UploadCardGrid } from './components/UploadCardGrid';
import { useUploadColumns } from './components/UploadColumns';
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
import { UploadTable } from './components/UploadTable';
import { ViewSwitcher } from './components/ViewSwitcher';
import { useUploadFilters } from './hooks/useUploadFilters';
import { useMyUploadsQuery } from './hooks/useUploadQueries';
export default function UploadPage() { export default function UploadPage() {
const router = useRouter(); const { skip, setSkip, limit, setLimit, viewMode, setViewMode } =
const [session, setSession] = useState<SessionContext | null>(null); useUploadFilters();
const [videoFile, setVideoFile] = useState<File | null>(null); const uploadsQuery = useMyUploadsQuery({ skip, limit });
const [gpsFile, setGpsFile] = useState<File | null>(null); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [analysisMethod, setAnalysisMethod] = useState('yolo'); const [detailsUpload, setDetailsUpload] = useState<UploadVideo | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [processedUpload, setProcessedUpload] = useState<UploadVideo | null>(
const [error, setError] = useState<string | null>(null); null,
const isLocationComplete = Boolean(
session?.projectId && session.packageId && session.chainageId,
);
const canSubmit = Boolean(
isLocationComplete && videoFile && gpsFile && analysisMethod,
); );
const handleSubmit = async () => { const uploads = uploadsQuery.data?.items ?? [];
if (!session?.chainageId || !videoFile || !gpsFile) { const total = uploadsQuery.data?.total ?? 0;
setError('Complete location and input data before upload.'); const isLoading = uploadsQuery.isLoading;
return;
}
const formData = new FormData(); const handleViewDetails = useCallback((upload: UploadVideo) => {
formData.append('file', videoFile); setDetailsUpload(upload);
formData.append('detection_mode', analysisMethod); }, []);
formData.append('chainage_id', session.chainageId);
formData.append('json_file', gpsFile);
setIsSubmitting(true); const handleViewProcessedVideo = useCallback((upload: UploadVideo) => {
setError(null); setProcessedUpload(upload);
}, []);
try { const handleOpenUpload = useCallback(
await videoService.uploadVideo(formData); (upload: UploadVideo) => {
router.push(ROUTES.TICKET); if (upload.processed_video_url) {
} catch (err) { handleViewProcessedVideo(upload);
setIsSubmitting(false);
if (err instanceof TypeError && err.message === 'Failed to fetch') {
setError('Cannot connect to server. Please check if backend is running.');
return; return;
} }
setError(err instanceof Error ? err.message : 'Upload failed'); handleViewDetails(upload);
} },
}; [handleViewDetails, handleViewProcessedVideo],
);
const columns = useUploadColumns({
onViewDetails: handleViewDetails,
onViewProcessedVideo: handleViewProcessedVideo,
});
return ( return (
<div className="space-y-5"> <>
<PageHeader <main className="space-y-5">
title="Upload" <PageHeader
description="Configure location, upload data, and submit it for processing." title="My Uploads"
icon={TrendingUp} description="View and manage uploaded road inspection videos."
/> icon={UploadCloud}
actions={
<Card> <Button onClick={() => setIsCreateOpen(true)} size="sm">
<CardContent className="space-y-5 p-5"> <Plus className="size-4" />
<LocationSection Upload New
value={session}
onChange={setSession}
disabled={isSubmitting}
/>
<Separator />
<InputDataSection
videoFile={videoFile}
gpsFile={gpsFile}
onVideoFileChange={(file) => {
setVideoFile(file);
setError(null);
}}
onGpsFileChange={(file) => {
setGpsFile(file);
setError(null);
}}
disabled={isSubmitting}
/>
<Separator />
<UploadSettingsSection
value={analysisMethod}
onValueChange={setAnalysisMethod}
disabled={isSubmitting}
/>
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="flex justify-end">
<Button
type="button"
size="lg"
disabled={!canSubmit || isSubmitting}
onClick={handleSubmit}
className="w-full gap-2 md:w-auto"
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Uploading...
</>
) : (
<>
<UploadCloud className="size-4" />
Upload
</>
)}
</Button> </Button>
</div> }
</CardContent> />
</Card>
</div> <div className="flex justify-end">
<ViewSwitcher value={viewMode} onValueChange={setViewMode} />
</div>
{viewMode === 'card' ? (
<UploadCardGrid
uploads={uploads}
isLoading={isLoading}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onUploadNew={() => setIsCreateOpen(true)}
onOpenUpload={handleOpenUpload}
onViewDetails={handleViewDetails}
onViewProcessedVideo={handleViewProcessedVideo}
/>
) : (
<UploadTable
columns={columns}
uploads={uploads}
isLoading={isLoading}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
onLimitChange={setLimit}
onRowClick={handleOpenUpload}
/>
)}
</main>
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
<UploadDetailsDialog
upload={detailsUpload}
open={Boolean(detailsUpload)}
onOpenChange={(open) => {
if (!open) setDetailsUpload(null);
}}
/>
<ProcessedVideoDialog
upload={processedUpload}
open={Boolean(processedUpload)}
onOpenChange={(open) => {
if (!open) setProcessedUpload(null);
}}
/>
</>
); );
} }

View File

@@ -73,6 +73,7 @@ export interface DataTableProps<TData, TValue> {
sorting?: SortingState; sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void; onSortingChange?: (sorting: SortingState) => void;
enableColumnControls?: boolean; enableColumnControls?: boolean;
onRowClick?: (row: TData) => void;
/** Minimum width (px) for every column unless overridden in the column def. */ /** Minimum width (px) for every column unless overridden in the column def. */
minColumnSize?: number; minColumnSize?: number;
} }
@@ -111,6 +112,7 @@ function DataTableContent<TData, TValue>({
emptyTitle = 'No results found.', emptyTitle = 'No results found.',
emptyDescription = 'Try adjusting your filters or search terms.', emptyDescription = 'Try adjusting your filters or search terms.',
pagination, pagination,
onRowClick,
sorting: controlledSorting, sorting: controlledSorting,
onSortingChange, onSortingChange,
enableColumnControls = true, enableColumnControls = true,
@@ -281,6 +283,8 @@ function DataTableContent<TData, TValue>({
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
onClick={() => onRowClick?.(row.original)}
className={cn(onRowClick && 'cursor-pointer')}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const truncate = shouldTruncateCell(cell.column); const truncate = shouldTruncateCell(cell.column);

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import type { RefObject } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
@@ -27,6 +28,7 @@ interface SelectPopoverProps {
emptyMessage?: string; emptyMessage?: string;
disabled?: boolean; disabled?: boolean;
align?: 'start' | 'center' | 'end'; align?: 'start' | 'center' | 'end';
portalContainer?: RefObject<HTMLDivElement | null>;
} }
export function SelectPopover({ export function SelectPopover({
@@ -38,6 +40,7 @@ export function SelectPopover({
emptyMessage = 'No options found.', emptyMessage = 'No options found.',
disabled = false, disabled = false,
align = 'start', align = 'start',
portalContainer,
}: SelectPopoverProps) { }: SelectPopoverProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -70,7 +73,11 @@ export function SelectPopover({
<ChevronDown className="size-4 opacity-60" /> <ChevronDown className="size-4 opacity-60" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align={align} className="w-72 p-0 shadow-none"> <PopoverContent
align={align}
container={portalContainer}
className="w-72 p-0 shadow-none"
>
<div className="border-b p-3"> <div className="border-b p-3">
<Input <Input
value={search} value={search}

View File

@@ -21,10 +21,18 @@ function PopoverContent({
className, className,
align = 'center', align = 'center',
sideOffset = 4, sideOffset = 4,
container,
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) { }: React.ComponentProps<typeof PopoverPrimitive.Content> & {
container?:
| React.ComponentProps<typeof PopoverPrimitive.Portal>['container']
| React.RefObject<HTMLElement | null>;
}) {
const portalContainer =
container && 'current' in container ? container.current : container;
return ( return (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal container={portalContainer}>
<PopoverPrimitive.Content <PopoverPrimitive.Content
data-slot="popover-content" data-slot="popover-content"
align={align} align={align}

View File

@@ -52,6 +52,7 @@ export const API_ROUTES = {
}, },
VIDEOS: { VIDEOS: {
UPLOAD: '/biz/api/v1/upload', UPLOAD: '/biz/api/v1/upload',
MY_UPLOADS: '/biz/api/v1/videos/me',
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`, RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
}, },
} as const; } as const;

View File

@@ -1,11 +1,26 @@
import axiosClient from '../axios/axios'; import axiosClient from '../axios/axios';
import { API_ROUTES } from '@/constants/apiRoutes'; import { API_ROUTES } from '@/constants/apiRoutes';
import { CompletedVideoResult } from '@/types'; import { CompletedVideoResult, PaginationParams, UploadVideosResponse } from '@/types';
/** /**
* Video Service * Video Service
*/ */
export const videoService = { export const videoService = {
getMyUploads: async (
params?: PaginationParams,
): Promise<UploadVideosResponse> => {
const response = await axiosClient.get<UploadVideosResponse>(
API_ROUTES.VIDEOS.MY_UPLOADS,
{
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 20,
},
},
);
return response.data;
},
/** /**
* Upload a video for processing * Upload a video for processing
*/ */

View File

@@ -5,6 +5,7 @@ export * from './chainage';
export * from './detection'; export * from './detection';
export * from './analysis'; export * from './analysis';
export * from './session'; export * from './session';
export * from './upload';
export * from './auth.type'; export * from './auth.type';
export * from './permission'; export * from './permission';
export * from './role'; export * from './role';

25
src/types/upload.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface UploadVideoStatus {
status: string;
progress: number;
}
export interface UploadVideo {
video_id: string;
filename: string;
chainage_id: string;
detection_mode: string;
speed_kmh?: number | null;
thumbnail?: string | null;
uploaded_at: string;
uploaded_by_user_id?: number | null;
uploaded_by_email?: string | null;
processed_video_path?: string | null;
processed_video_url?: string | null;
status: UploadVideoStatus;
}
export interface UploadVideosResponse {
items: UploadVideo[];
total: number;
}