refactor(upload): replace upload card views with list table and addition details in table
This commit is contained in:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
153
src/app/(modules)/upload/components/UploadListColumns.tsx
Normal file
153
src/app/(modules)/upload/components/UploadListColumns.tsx
Normal 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],
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/app/(modules)/upload/components/UploadResultCell.tsx
Normal file
32
src/app/(modules)/upload/components/UploadResultCell.tsx
Normal 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>;
|
||||||
|
}
|
||||||
65
src/app/(modules)/upload/components/UploadStatusBadge.tsx
Normal file
65
src/app/(modules)/upload/components/UploadStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,47 +41,19 @@ export default function UploadPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<UploadTable
|
||||||
<ViewSwitcher value={viewMode} onValueChange={setViewMode} />
|
columns={columns}
|
||||||
</div>
|
uploads={uploads}
|
||||||
|
isLoading={isLoading}
|
||||||
{viewMode === 'card' ? (
|
skip={skip}
|
||||||
<UploadCardGrid
|
limit={limit}
|
||||||
uploads={uploads}
|
total={total}
|
||||||
isLoading={isLoading}
|
onPageChange={setSkip}
|
||||||
skip={skip}
|
onLimitChange={setLimit}
|
||||||
limit={limit}
|
/>
|
||||||
total={total}
|
|
||||||
onPageChange={setSkip}
|
|
||||||
onLimitChange={setLimit}
|
|
||||||
onUploadNew={() => setIsCreateOpen(true)}
|
|
||||||
onOpenUpload={handleOpenUpload}
|
|
||||||
onViewVideo={handleViewUploadVideo}
|
|
||||||
onViewDetails={handleViewDetails}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<UploadTable
|
|
||||||
columns={columns}
|
|
||||||
uploads={uploads}
|
|
||||||
isLoading={isLoading}
|
|
||||||
skip={skip}
|
|
||||||
limit={limit}
|
|
||||||
total={total}
|
|
||||||
onPageChange={setSkip}
|
|
||||||
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)}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user