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

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';
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UploadVideo } from '@/types';
import type { UploadListItem } from '@/types';
interface UploadActionsMenuProps {
upload: UploadVideo;
onViewVideo: (upload: UploadVideo) => void;
onViewDetails: (upload: UploadVideo) => void;
upload: UploadListItem;
onViewVideo: (upload: UploadListItem) => void;
}
export function UploadActionsMenu({
upload,
onViewVideo,
onViewDetails,
}: UploadActionsMenuProps) {
return (
<DropdownMenu>
@@ -37,7 +44,7 @@ export function UploadActionsMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{upload.video_url ? (
{upload.raw_video_url ? (
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
@@ -48,14 +55,18 @@ export function UploadActionsMenu({
View Video
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onViewDetails(upload);
}}
>
<Eye className="size-4" />
View Details
<DropdownMenuItem>
<Eye /> View Details
</DropdownMenuItem>
<DropdownMenuItem>
<RotateCcw /> Retry Analysis
</DropdownMenuItem>
<DropdownMenuItem disabled={!upload.ticket_id}>
<Ticket /> Open Ticket
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2 /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</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 { DataTable } from '@/components/data-table';
import type { UploadVideo } from '@/types';
import type { UploadListItem } from '@/types';
interface UploadTableProps {
columns: ColumnDef<UploadVideo>[];
uploads: UploadVideo[];
columns: ColumnDef<UploadListItem>[];
uploads: UploadListItem[];
isLoading: boolean;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
onLimitChange: (limit: number) => void;
onRowClick: (upload: UploadVideo) => void;
}
export function UploadTable({
@@ -26,7 +25,6 @@ export function UploadTable({
total,
onPageChange,
onLimitChange,
onRowClick,
}: UploadTableProps) {
return (
<DataTable
@@ -42,7 +40,6 @@ export function UploadTable({
onPageChange,
onLimitChange,
}}
onRowClick={onRowClick}
enableColumnControls={false}
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,
DialogTitle,
} from '@/components/ui/dialog';
import type { UploadVideo } from '@/types';
import type { UploadListItem } from '@/types';
interface UploadVideoDialogProps {
upload: UploadVideo | null;
upload: UploadListItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
@@ -29,12 +29,12 @@ export function UploadVideoDialog({
Video Preview
</DialogTitle>
<DialogDescription className="truncate text-xs">
{upload?.filename ?? 'Uploaded video'}
{upload?.video_name ?? 'Uploaded video'}
</DialogDescription>
</DialogHeader>
<ProtectedVideoPlayer
url={open ? upload?.video_url : undefined}
url={open ? upload?.raw_video_url : undefined}
className="rounded-lg"
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;
}