refactor(upload): replace upload card views with list table and addition details in table

This commit is contained in:
2026-06-30 18:03:50 +05:30
parent ec77696157
commit ea684fc9fe
21 changed files with 343 additions and 587 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/dev/types/routes.d.ts"; import "./.next/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

@@ -1,79 +0,0 @@
'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

@@ -1,26 +1,33 @@
'use client'; 'use client';
import { Eye, MoreVertical, PlayCircle } from 'lucide-react'; import {
Download,
Eye,
MoreVertical,
PlayCircle,
RotateCcw,
Ticket,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import type { UploadVideo } from '@/types'; import type { UploadListItem } from '@/types';
interface UploadActionsMenuProps { interface UploadActionsMenuProps {
upload: UploadVideo; upload: UploadListItem;
onViewVideo: (upload: UploadVideo) => void; onViewVideo: (upload: UploadListItem) => void;
onViewDetails: (upload: UploadVideo) => void;
} }
export function UploadActionsMenu({ export function UploadActionsMenu({
upload, upload,
onViewVideo, onViewVideo,
onViewDetails,
}: UploadActionsMenuProps) { }: UploadActionsMenuProps) {
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -37,7 +44,7 @@ export function UploadActionsMenu({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{upload.video_url ? ( {upload.raw_video_url ? (
<DropdownMenuItem <DropdownMenuItem
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -48,14 +55,18 @@ export function UploadActionsMenu({
View Video View Video
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
<DropdownMenuItem <DropdownMenuItem>
onClick={(event) => { <Eye /> View Details
event.stopPropagation(); </DropdownMenuItem>
onViewDetails(upload); <DropdownMenuItem>
}} <RotateCcw /> Retry Analysis
> </DropdownMenuItem>
<Eye className="size-4" /> <DropdownMenuItem disabled={!upload.ticket_id}>
View Details <Ticket /> Open Ticket
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2 /> Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -1,107 +0,0 @@
'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;
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void;
}
export function UploadCardGrid({
uploads,
isLoading,
skip,
limit,
total,
onPageChange,
onLimitChange,
onUploadNew,
onOpenUpload,
onViewVideo,
onViewDetails,
}: UploadCardGridProps) {
const skeletonItems = Array.from({ length: 6 });
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}
onViewVideo={onViewVideo}
onViewDetails={onViewDetails}
/>
</div>
</CardContent>
</Card>
))}
</div>
<CardPagination
skip={skip}
limit={limit}
total={total}
count={uploads.length}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
/>
</section>
);
}

View File

@@ -1,77 +0,0 @@
'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 {
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void;
}
export function useUploadColumns({
onViewVideo,
onViewDetails,
}: 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}
onViewVideo={onViewVideo}
onViewDetails={onViewDetails}
/>
</div>
),
},
],
[onViewDetails, onViewVideo],
);
}

View File

@@ -1,79 +0,0 @@
'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

@@ -0,0 +1,153 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Video } from 'lucide-react';
import { ENV_CONSTANT } from '@/constants/secrect.constant';
import type { UploadListItem } from '@/types';
import { formatDate } from '@/utils/date';
import { UploadActionsMenu } from './UploadActionsMenu';
import { UploadResultCell } from './UploadResultCell';
import { UploadStatusBadge } from './UploadStatusBadge';
function resolveThumbnailUrl(value: string | null) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, '');
const path = value.startsWith('/') ? value : `/${value}`;
return baseUrl ? `${baseUrl}${path}` : path;
}
export function useUploadListColumns(
onViewVideo: (upload: UploadListItem) => void,
): ColumnDef<UploadListItem>[] {
return useMemo(
() => [
{
id: 'thumbnail',
header: 'Thumbnail',
size: 104,
minSize: 96,
enableSorting: false,
meta: { disableTruncate: true },
cell: ({ row }) => {
const thumbnail = resolveThumbnailUrl(row.original.thumbnail);
return (
<div
className="flex aspect-video w-20 items-center justify-center overflow-hidden rounded-md border bg-muted bg-cover bg-center"
style={
thumbnail
? { backgroundImage: `url("${thumbnail}")` }
: undefined
}
role={thumbnail ? 'img' : undefined}
aria-label={
thumbnail
? `Thumbnail for ${row.original.video_name}`
: undefined
}
>
{!thumbnail ? (
<Video className="size-5 text-muted-foreground" />
) : null}
</div>
);
},
},
{
accessorKey: 'video_name',
header: 'Video Name',
size: 150,
cell: ({ row }) => (
<span className="font-medium">{row.original.video_name}</span>
),
},
{
id: 'uploaded_by',
header: 'Uploader',
size: 190,
cell: ({ row }) => (
<div>
<p className="font-medium">
{row.original.uploaded_by.name ?? '—'}
</p>
<p className="text-xs text-muted-foreground">
{row.original.uploaded_by.email ?? '—'}
</p>
</div>
),
},
{
accessorKey: 'project',
header: 'Project',
size: 165,
cell: ({ row }) => row.original.project ?? '—',
},
{
accessorKey: 'package',
header: 'Package',
size: 110,
cell: ({ row }) => row.original.package ?? '—',
},
{
accessorKey: 'segment',
header: 'Segment',
size: 140,
cell: ({ row }) => row.original.segment ?? '—',
},
{
accessorKey: 'chainage',
header: 'Chainage',
size: 155,
cell: ({ row }) => row.original.chainage ?? '—',
},
{
accessorKey: 'uploaded_at',
header: 'Uploaded At',
size: 175,
cell: ({ row }) => formatDate(row.original.uploaded_at),
},
{
id: 'status',
header: 'Status',
size: 145,
meta: { disableTruncate: true },
cell: ({ row }) => (
<UploadStatusBadge
status={row.original.status}
progress={row.original.progress}
/>
),
},
{
id: 'result',
header: 'Result',
size: 170,
meta: { disableTruncate: true },
cell: ({ row }) => <UploadResultCell item={row.original} />,
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 64,
minSize: 64,
enableSorting: false,
enableHiding: false,
meta: { disableTruncate: true },
cell: ({ row }) => (
<div className="flex justify-center">
<UploadActionsMenu
upload={row.original}
onViewVideo={onViewVideo}
/>
</div>
),
},
],
[onViewVideo],
);
}

View File

@@ -0,0 +1,32 @@
import { CircleX, TicketCheck } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { UploadListItem } from '@/types';
export function UploadResultCell({ item }: { item: UploadListItem }) {
if (item.result === 'ticket_created') {
return (
<div className="space-y-1">
<Badge className="bg-violet-500/15 text-violet-600 dark:text-violet-400">
<TicketCheck /> Ticket Created
</Badge>
<p className="text-xs font-medium">{item.ticket_id}</p>
</div>
);
}
if (item.result === 'analysis_failed') {
return (
<div className="space-y-1 text-red-600 dark:text-red-400">
<div className="flex items-center gap-1.5 text-xs font-medium">
<CircleX className="size-3.5" /> Analysis Failed
</div>
<p className="text-xs text-muted-foreground">
{item.error_message ?? 'Processing failed'}
</p>
</div>
);
}
return <span className="text-muted-foreground"></span>;
}

View File

@@ -0,0 +1,65 @@
import { CheckCircle2, Clock3, LoaderCircle, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import type { UploadStatus } from '@/types';
interface UploadStatusBadgeProps {
status: UploadStatus;
progress: number;
}
const statusConfig = {
queued: {
label: 'Queued',
icon: Clock3,
className: 'bg-zinc-500/15 text-zinc-500 dark:text-zinc-300',
},
processing: {
label: 'Processing',
icon: LoaderCircle,
className: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
},
completed: {
label: 'Completed',
icon: CheckCircle2,
className: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
},
failed: {
label: 'Failed',
icon: XCircle,
className: 'bg-red-500/15 text-red-600 dark:text-red-400',
},
} satisfies Record<
UploadStatus,
{ label: string; icon: typeof Clock3; className: string }
>;
export function UploadStatusBadge({
status,
progress,
}: UploadStatusBadgeProps) {
const config = statusConfig[status];
const Icon = config.icon;
return (
<div className="space-y-1.5">
<Badge variant="secondary" className={config.className}>
<Icon
className={status === 'processing' ? 'animate-spin' : undefined}
/>
{config.label}
</Badge>
{status === 'processing' ? (
<div className="w-24 space-y-1">
<span className="text-xs text-muted-foreground">
{progress}% complete
</span>
<Progress value={progress} className="h-1.5" />
</div>
) : status === 'completed' ? (
<p className="text-xs text-muted-foreground">100%</p>
) : null}
</div>
);
}

View File

@@ -3,18 +3,17 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { UploadVideo } from '@/types'; import type { UploadListItem } from '@/types';
interface UploadTableProps { interface UploadTableProps {
columns: ColumnDef<UploadVideo>[]; columns: ColumnDef<UploadListItem>[];
uploads: UploadVideo[]; uploads: UploadListItem[];
isLoading: boolean; isLoading: boolean;
skip: number; skip: number;
limit: number; limit: number;
total: number; total: number;
onPageChange: (skip: number) => void; onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void; onLimitChange: (limit: number) => void;
onRowClick: (upload: UploadVideo) => void;
} }
export function UploadTable({ export function UploadTable({
@@ -26,7 +25,6 @@ export function UploadTable({
total, total,
onPageChange, onPageChange,
onLimitChange, onLimitChange,
onRowClick,
}: UploadTableProps) { }: UploadTableProps) {
return ( return (
<DataTable <DataTable
@@ -42,7 +40,6 @@ export function UploadTable({
onPageChange, onPageChange,
onLimitChange, onLimitChange,
}} }}
onRowClick={onRowClick}
enableColumnControls={false} enableColumnControls={false}
minColumnSize={120} minColumnSize={120}
/> />

View File

@@ -1,33 +0,0 @@
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

@@ -8,10 +8,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import type { UploadVideo } from '@/types'; import type { UploadListItem } from '@/types';
interface UploadVideoDialogProps { interface UploadVideoDialogProps {
upload: UploadVideo | null; upload: UploadListItem | null;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@@ -29,12 +29,12 @@ export function UploadVideoDialog({
Video Preview Video Preview
</DialogTitle> </DialogTitle>
<DialogDescription className="truncate text-xs"> <DialogDescription className="truncate text-xs">
{upload?.filename ?? 'Uploaded video'} {upload?.video_name ?? 'Uploaded video'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ProtectedVideoPlayer <ProtectedVideoPlayer
url={open ? upload?.video_url : undefined} url={open ? upload?.raw_video_url : undefined}
className="rounded-lg" className="rounded-lg"
unavailableTitle="Video preview unavailable" unavailableTitle="Video preview unavailable"
/> />

View File

@@ -1,46 +0,0 @@
'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

@@ -1,29 +0,0 @@
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

@@ -2,14 +2,11 @@
import { useState } from 'react'; import { useState } from 'react';
export type UploadViewMode = 'card' | 'table';
const DEFAULT_LIMIT = 20; const DEFAULT_LIMIT = 20;
export function useUploadFilters() { export function useUploadFilters() {
const [skip, setSkip] = useState(0); const [skip, setSkip] = useState(0);
const [limit, setLimitValue] = useState(DEFAULT_LIMIT); const [limit, setLimitValue] = useState(DEFAULT_LIMIT);
const [viewMode, setViewMode] = useState<UploadViewMode>('card');
const setLimit = (nextLimit: number) => { const setLimit = (nextLimit: number) => {
setLimitValue(nextLimit); setLimitValue(nextLimit);
@@ -21,7 +18,5 @@ export function useUploadFilters() {
setSkip, setSkip,
limit, limit,
setLimit, setLimit,
viewMode,
setViewMode,
}; };
} }

View File

@@ -13,7 +13,7 @@ export const uploadKeys = {
list: (params: PaginationParams) => [...uploadKeys.lists(), params] as const, list: (params: PaginationParams) => [...uploadKeys.lists(), params] as const,
}; };
interface UseMyUploadsQueryParams { interface UseUploadsQueryParams {
skip: number; skip: number;
limit: number; limit: number;
} }
@@ -21,14 +21,14 @@ interface UseMyUploadsQueryParams {
function buildUploadListParams({ function buildUploadListParams({
skip, skip,
limit, limit,
}: UseMyUploadsQueryParams): PaginationParams { }: UseUploadsQueryParams): PaginationParams {
return { return {
skip, skip,
limit, limit,
}; };
} }
export function useMyUploadsQuery(params: UseMyUploadsQueryParams) { export function useUploadsQuery(params: UseUploadsQueryParams) {
const { skip, limit } = params; const { skip, limit } = params;
const listParams = useMemo( const listParams = useMemo(
() => buildUploadListParams({ skip, limit }), () => buildUploadListParams({ skip, limit }),
@@ -37,7 +37,7 @@ export function useMyUploadsQuery(params: UseMyUploadsQueryParams) {
return useQuery({ return useQuery({
queryKey: uploadKeys.list(listParams), queryKey: uploadKeys.list(listParams),
queryFn: () => videoService.getMyUploads(listParams), queryFn: () => videoService.getUploads(listParams),
}); });
} }

View File

@@ -1,62 +1,37 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useState } from 'react';
import { Plus, UploadCloud } from 'lucide-react'; import { Plus, 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 type { UploadVideo } from '@/types'; import type { UploadListItem } from '@/types';
import { CreateUploadDialog } from './components/CreateUploadDialog'; import { CreateUploadDialog } from './components/CreateUploadDialog';
import { UploadCardGrid } from './components/UploadCardGrid'; import { useUploadListColumns } from './components/UploadListColumns';
import { useUploadColumns } from './components/UploadColumns';
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
import { UploadTable } from './components/UploadTable'; import { UploadTable } from './components/UploadTable';
import { UploadVideoDialog } from './components/UploadVideoDialog'; import { UploadVideoDialog } from './components/UploadVideoDialog';
import { ViewSwitcher } from './components/ViewSwitcher';
import { useUploadFilters } from './hooks/useUploadFilters'; import { useUploadFilters } from './hooks/useUploadFilters';
import { useMyUploadsQuery } from './hooks/useUploadQueries'; import { useUploadsQuery } from './hooks/useUploadQueries';
export default function UploadPage() { export default function UploadPage() {
const { skip, setSkip, limit, setLimit, viewMode, setViewMode } = const { skip, setSkip, limit, setLimit } = useUploadFilters();
useUploadFilters(); const uploadsQuery = useUploadsQuery({ skip, limit });
const uploadsQuery = useMyUploadsQuery({ skip, limit });
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [detailsUpload, setDetailsUpload] = useState<UploadVideo | null>(null); const [videoUpload, setVideoUpload] = useState<UploadListItem | null>(null);
const [videoUpload, setVideoUpload] = useState<UploadVideo | null>(null);
const uploads = uploadsQuery.data?.items ?? []; const uploads = uploadsQuery.data?.items ?? [];
const total = uploadsQuery.data?.total ?? 0; const total = uploadsQuery.data?.total ?? 0;
const isLoading = uploadsQuery.isLoading; const isLoading = uploadsQuery.isLoading;
const handleViewDetails = useCallback((upload: UploadVideo) => { const columns = useUploadListColumns(setVideoUpload);
setDetailsUpload(upload);
}, []);
const handleViewUploadVideo = useCallback((upload: UploadVideo) => {
setVideoUpload(upload);
}, []);
const handleOpenUpload = useCallback(
(upload: UploadVideo) => {
if (upload.video_url) {
handleViewUploadVideo(upload);
}
},
[handleViewUploadVideo],
);
const columns = useUploadColumns({
onViewVideo: handleViewUploadVideo,
onViewDetails: handleViewDetails,
});
return ( return (
<> <>
<main className="space-y-5"> <main className="space-y-5">
<PageHeader <PageHeader
title="My Uploads" title="Uploads"
description="View and manage uploaded road inspection videos." description="Track uploaded road inspection videos, processing progress, and results."
icon={UploadCloud} icon={UploadCloud}
actions={ actions={
<Button onClick={() => setIsCreateOpen(true)} size="sm"> <Button onClick={() => setIsCreateOpen(true)} size="sm">
@@ -66,25 +41,6 @@ export default function UploadPage() {
} }
/> />
<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}
onViewVideo={handleViewUploadVideo}
onViewDetails={handleViewDetails}
/>
) : (
<UploadTable <UploadTable
columns={columns} columns={columns}
uploads={uploads} uploads={uploads}
@@ -94,19 +50,10 @@ export default function UploadPage() {
total={total} total={total}
onPageChange={setSkip} onPageChange={setSkip}
onLimitChange={setLimit} onLimitChange={setLimit}
onRowClick={handleOpenUpload}
/> />
)}
</main> </main>
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} /> <CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
<UploadDetailsDialog
upload={detailsUpload}
open={Boolean(detailsUpload)}
onOpenChange={(open) => {
if (!open) setDetailsUpload(null);
}}
/>
<UploadVideoDialog <UploadVideoDialog
upload={videoUpload} upload={videoUpload}
open={Boolean(videoUpload)} open={Boolean(videoUpload)}

View File

@@ -2,7 +2,7 @@ import {
Layers, Layers,
Milestone, Milestone,
Package, Package,
Plus, UploadCloud,
Settings, Settings,
ShieldCheck, ShieldCheck,
Ticket, Ticket,
@@ -28,7 +28,7 @@ export const menuItems: MenuItem[] = [
{ {
title: 'Upload', title: 'Upload',
path: ROUTES.UPLOAD, path: ROUTES.UPLOAD,
icon: Plus, icon: UploadCloud,
permission: PERMISSIONS.VIDEO.UPLOAD, permission: PERMISSIONS.VIDEO.UPLOAD,
}, },
{ {
@@ -130,7 +130,9 @@ export function getFirstAccessibleMenuPath(
hasPermission: (permission?: string) => boolean, hasPermission: (permission?: string) => boolean,
fallback = ROUTES.ACCESS, fallback = ROUTES.ACCESS,
) { ) {
return findFirstMenuPath(filterMenuItems(menuItems, hasPermission)) ?? fallback; return (
findFirstMenuPath(filterMenuItems(menuItems, hasPermission)) ?? fallback
);
} }
export function getFirstAccessiblePathForPermissions( export function getFirstAccessiblePathForPermissions(

View File

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

View File

@@ -1,16 +1,20 @@
import axiosClient from '../axios/axios'; import axiosClient from '../axios/axios';
import { API_ROUTES } from '@/constants/apiRoutes'; import { API_ROUTES } from '@/constants/apiRoutes';
import { CompletedVideoResult, PaginationParams, UploadVideosResponse } from '@/types'; import {
CompletedVideoResult,
PaginationParams,
UploadListResponse,
} from '@/types';
/** /**
* Video Service * Video Service
*/ */
export const videoService = { export const videoService = {
getMyUploads: async ( getUploads: async (
params?: PaginationParams, params?: PaginationParams,
): Promise<UploadVideosResponse> => { ): Promise<UploadListResponse> => {
const response = await axiosClient.get<UploadVideosResponse>( const response = await axiosClient.get<UploadListResponse>(
API_ROUTES.VIDEOS.MY_UPLOADS, API_ROUTES.VIDEOS.UPLOADS,
{ {
params: { params: {
skip: params?.skip ?? 0, skip: params?.skip ?? 0,

View File

@@ -1,25 +1,25 @@
export interface UploadVideoStatus { export type UploadStatus = 'queued' | 'processing' | 'completed' | 'failed';
status: string; export type UploadResult = 'ticket_created' | 'analysis_failed' | null;
export interface UploadListItem {
id: string;
video_name: string;
thumbnail: string | null;
raw_video_url: string | null;
uploaded_by: { name: string | null; email: string | null };
project: string | null;
package: string | null;
segment: string | null;
chainage: string | null;
uploaded_at: string | null;
status: UploadStatus;
progress: number; progress: number;
result: UploadResult;
ticket_id: string | null;
error_message: string | null;
} }
export interface UploadVideo { export interface UploadListResponse {
video_id: string; items: UploadListItem[];
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;
video_url?: string | null;
processed_video_path?: string | null;
processed_video_url?: string | null;
status: UploadVideoStatus;
}
export interface UploadVideosResponse {
items: UploadVideo[];
total: number; total: number;
} }