From a18fff4d6f81bcef3386a0a00c12fd2f6eb8f2d4 Mon Sep 17 00:00:00 2001 From: "santasri.pachhal" Date: Thu, 18 Jun 2026 21:22:44 +0530 Subject: [PATCH] feat: add My Uploads listing with card and table views --- next-env.d.ts | 2 +- .../package/hooks/usePackageQueries.ts | 3 +- .../(modules)/package/queries/packageKeys.ts | 1 + .../(modules)/project/queries/projectKeys.ts | 1 + .../segment/hooks/useSegmentQueries.ts | 6 +- .../upload/components/CardPagination.tsx | 79 +++++++ .../upload/components/CreateUploadDialog.tsx | 172 ++++++++++++++ .../upload/components/LocationSection.tsx | 22 +- .../components/ProcessedVideoDialog.tsx | 53 +++++ .../upload/components/UploadActionsMenu.tsx | 53 +++++ .../upload/components/UploadCardGrid.tsx | 107 +++++++++ .../upload/components/UploadColumns.tsx | 77 +++++++ .../upload/components/UploadDetailsDialog.tsx | 79 +++++++ .../components/UploadSettingsSection.tsx | 7 +- .../upload/components/UploadTable.tsx | 50 ++++ .../upload/components/UploadThumbnail.tsx | 33 +++ .../upload/components/ViewSwitcher.tsx | 46 ++++ .../upload/components/uploadUtils.ts | 29 +++ .../upload/hooks/useUploadFilters.ts | 27 +++ .../upload/hooks/useUploadQueries.ts | 54 +++++ src/app/(modules)/upload/page.tsx | 217 +++++++++--------- src/components/data-table/index.tsx | 4 + src/components/form/SelectPopover.tsx | 9 +- src/components/ui/popover.tsx | 12 +- src/constants/apiRoutes.ts | 1 + src/services/api/video.service.ts | 17 +- src/types/index.ts | 1 + src/types/upload.ts | 25 ++ 28 files changed, 1060 insertions(+), 127 deletions(-) create mode 100644 src/app/(modules)/upload/components/CardPagination.tsx create mode 100644 src/app/(modules)/upload/components/CreateUploadDialog.tsx create mode 100644 src/app/(modules)/upload/components/ProcessedVideoDialog.tsx create mode 100644 src/app/(modules)/upload/components/UploadActionsMenu.tsx create mode 100644 src/app/(modules)/upload/components/UploadCardGrid.tsx create mode 100644 src/app/(modules)/upload/components/UploadColumns.tsx create mode 100644 src/app/(modules)/upload/components/UploadDetailsDialog.tsx create mode 100644 src/app/(modules)/upload/components/UploadTable.tsx create mode 100644 src/app/(modules)/upload/components/UploadThumbnail.tsx create mode 100644 src/app/(modules)/upload/components/ViewSwitcher.tsx create mode 100644 src/app/(modules)/upload/components/uploadUtils.ts create mode 100644 src/app/(modules)/upload/hooks/useUploadFilters.ts create mode 100644 src/app/(modules)/upload/hooks/useUploadQueries.ts create mode 100644 src/types/upload.ts diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/(modules)/package/hooks/usePackageQueries.ts b/src/app/(modules)/package/hooks/usePackageQueries.ts index 70a02ce..74e5a3a 100644 --- a/src/app/(modules)/package/hooks/usePackageQueries.ts +++ b/src/app/(modules)/package/hooks/usePackageQueries.ts @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import { packageService, projectService } from '@/services/api'; import type { Package, PaginationParams } from '@/types'; +import { projectKeys } from '../../project/queries/projectKeys'; import { packageKeys } from '../queries/packageKeys'; interface UsePackagesQueryParams { @@ -28,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) { export function useProjectOptionsQuery() { return useQuery({ - queryKey: ['projects', 'options'], + queryKey: projectKeys.options(), queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), }); } diff --git a/src/app/(modules)/package/queries/packageKeys.ts b/src/app/(modules)/package/queries/packageKeys.ts index f9729aa..0528360 100644 --- a/src/app/(modules)/package/queries/packageKeys.ts +++ b/src/app/(modules)/package/queries/packageKeys.ts @@ -4,6 +4,7 @@ export const packageKeys = { all: ['packages'] as const, lists: () => [...packageKeys.all, 'list'] as const, list: (params: PaginationParams) => [...packageKeys.lists(), params] as const, + options: () => [...packageKeys.all, 'options'] as const, details: () => [...packageKeys.all, 'detail'] as const, detail: (id: string) => [...packageKeys.details(), id] as const, }; diff --git a/src/app/(modules)/project/queries/projectKeys.ts b/src/app/(modules)/project/queries/projectKeys.ts index 39a030b..8b7954b 100644 --- a/src/app/(modules)/project/queries/projectKeys.ts +++ b/src/app/(modules)/project/queries/projectKeys.ts @@ -4,6 +4,7 @@ export const projectKeys = { all: ['projects'] as const, lists: () => [...projectKeys.all, 'list'] as const, list: (params: PaginationParams) => [...projectKeys.lists(), params] as const, + options: () => [...projectKeys.all, 'options'] as const, details: () => [...projectKeys.all, 'detail'] as const, detail: (id: string) => [...projectKeys.details(), id] as const, }; diff --git a/src/app/(modules)/segment/hooks/useSegmentQueries.ts b/src/app/(modules)/segment/hooks/useSegmentQueries.ts index 0a9a364..d99eb78 100644 --- a/src/app/(modules)/segment/hooks/useSegmentQueries.ts +++ b/src/app/(modules)/segment/hooks/useSegmentQueries.ts @@ -11,6 +11,8 @@ import { } from '@/services/api'; import type { Chainage, PaginationParams } from '@/types'; +import { packageKeys } from '../../package/queries/packageKeys'; +import { projectKeys } from '../../project/queries/projectKeys'; import { segmentKeys } from '../queries/segmentKeys'; interface UseSegmentsQueryParams { @@ -32,14 +34,14 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) { export function useProjectOptionsQuery() { return useQuery({ - queryKey: ['projects', 'options'], + queryKey: projectKeys.options(), queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }), }); } export function useAllPackageOptionsQuery() { return useQuery({ - queryKey: ['packages', 'options'], + queryKey: packageKeys.options(), queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }), }); } diff --git a/src/app/(modules)/upload/components/CardPagination.tsx b/src/app/(modules)/upload/components/CardPagination.tsx new file mode 100644 index 0000000..f46e514 --- /dev/null +++ b/src/app/(modules)/upload/components/CardPagination.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const pageSizes = [10, 20, 40, 50, 100]; + +interface CardPaginationProps { + skip: number; + limit: number; + total: number; + count: number; + onPageChange: (skip: number) => void; + onLimitChange: (limit: number) => void; +} + +export function CardPagination({ + skip, + limit, + total, + count, + onPageChange, + onLimitChange, +}: CardPaginationProps) { + return ( +
+
+ Showing {count} of {total} +
+ Rows per page + +
+
+ +
+ + +
+
+ ); +} diff --git a/src/app/(modules)/upload/components/CreateUploadDialog.tsx b/src/app/(modules)/upload/components/CreateUploadDialog.tsx new file mode 100644 index 0000000..59a6764 --- /dev/null +++ b/src/app/(modules)/upload/components/CreateUploadDialog.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Loader2, Play } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Separator } from '@/components/ui/separator'; +import type { SessionContext } from '@/types'; + +import { useCreateUploadMutation } from '../hooks/useUploadQueries'; +import { InputDataSection } from './InputDataSection'; +import { LocationSection } from './LocationSection'; +import { UploadSettingsSection } from './UploadSettingsSection'; + +interface CreateUploadDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateUploadDialog({ + open, + onOpenChange, +}: CreateUploadDialogProps) { + const [session, setSession] = useState(null); + const [videoFile, setVideoFile] = useState(null); + const [gpsFile, setGpsFile] = useState(null); + const [detectionMode, setDetectionMode] = useState('yolo'); + const [error, setError] = useState(null); + const dropdownPortalRef = useRef(null); + const createUploadMutation = useCreateUploadMutation(); + const isSubmitting = createUploadMutation.isPending; + + const resetForm = () => { + setSession(null); + setVideoFile(null); + setGpsFile(null); + setDetectionMode('yolo'); + setError(null); + createUploadMutation.reset(); + }; + + const canSubmit = Boolean( + session?.chainageId && videoFile && gpsFile && detectionMode, + ); + + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) { + resetForm(); + } + }; + + const handleSubmit = async () => { + if (!session?.chainageId || !videoFile || !gpsFile) { + setError('Complete location and input data before uploading.'); + return; + } + + const formData = new FormData(); + formData.append('file', videoFile); + formData.append('detection_mode', detectionMode); + formData.append('chainage_id', session.chainageId); + formData.append('json_file', gpsFile); + + setError(null); + + try { + await createUploadMutation.mutateAsync(formData); + handleOpenChange(false); + } catch (err) { + if (err instanceof TypeError && err.message === 'Failed to fetch') { + setError( + 'Cannot connect to server. Please check if backend is running.', + ); + return; + } + setError(err instanceof Error ? err.message : 'Upload failed'); + } + }; + + return ( + + event.preventDefault()} + > + + + Upload Video + + + Configure location and upload road inspection data. + + + +
+ + + { + setVideoFile(file); + setError(null); + }} + onGpsFileChange={(file) => { + setGpsFile(file); + setError(null); + }} + disabled={isSubmitting} + /> + + + {error ? ( +
+ {error} +
+ ) : null} +
+ + + + + + +
+ +
+ ); +} diff --git a/src/app/(modules)/upload/components/LocationSection.tsx b/src/app/(modules)/upload/components/LocationSection.tsx index f46b252..c54bb73 100644 --- a/src/app/(modules)/upload/components/LocationSection.tsx +++ b/src/app/(modules)/upload/components/LocationSection.tsx @@ -1,24 +1,36 @@ 'use client'; +import type { RefObject } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { FormField } from '@/components/form'; import { PackageSelect } from '@/components/lookups/PackageSelect'; import { ProjectSelect } from '@/components/lookups/ProjectSelect'; import { SegmentSelect } from '@/components/lookups/SegmentSelect'; -import { packageService, projectService, chainageService } from '@/services/api'; -import type { Chainage, Package as PackageType, Project, SessionContext } from '@/types'; +import { + packageService, + projectService, + chainageService, +} from '@/services/api'; +import type { + Chainage, + Package as PackageType, + Project, + SessionContext, +} from '@/types'; interface LocationSectionProps { value: SessionContext | null; onChange: (session: SessionContext | null) => void; disabled?: boolean; + portalContainer?: RefObject; } export function LocationSection({ value, onChange, disabled = false, + portalContainer, }: LocationSectionProps) { const [projectId, setProjectId] = useState(value?.projectId ?? ''); const [packageId, setPackageId] = useState(value?.packageId ?? ''); @@ -106,7 +118,7 @@ export function LocationSection({

Location

- Select the project hierarchy for this analysis job. + Select the project hierarchy for this upload.

@@ -116,6 +128,7 @@ export function LocationSection({ value={projectId} onValueChange={handleProjectChange} disabled={disabled} + portalContainer={portalContainer} /> @@ -126,6 +139,7 @@ export function LocationSection({ projectId={projectId} enabled={Boolean(projectId)} disabled={disabled || !projectId} + portalContainer={portalContainer} /> @@ -136,10 +150,10 @@ export function LocationSection({ packageId={packageId} enabled={Boolean(packageId)} disabled={disabled || !packageId} + portalContainer={portalContainer} /> - ); } diff --git a/src/app/(modules)/upload/components/ProcessedVideoDialog.tsx b/src/app/(modules)/upload/components/ProcessedVideoDialog.tsx new file mode 100644 index 0000000..107ca72 --- /dev/null +++ b/src/app/(modules)/upload/components/ProcessedVideoDialog.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Loader2 } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults'; +import type { UploadVideo } from '@/types'; + +interface ProcessedVideoDialogProps { + upload: UploadVideo | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ProcessedVideoDialog({ + upload, + open, + onOpenChange, +}: ProcessedVideoDialogProps) { + const { videoUrl, isLoading, isError } = useAnnotatedVideoQuery( + open ? upload?.processed_video_url ?? undefined : undefined, + ); + + return ( + + + + {upload?.filename ?? 'Processed Video'} + Processed video preview + + +
+ {isLoading ? ( + + ) : isError || !videoUrl ? ( +

+ Processed video is not available. +

+ ) : ( +
+
+
+ ); +} + diff --git a/src/app/(modules)/upload/components/UploadActionsMenu.tsx b/src/app/(modules)/upload/components/UploadActionsMenu.tsx new file mode 100644 index 0000000..89f25df --- /dev/null +++ b/src/app/(modules)/upload/components/UploadActionsMenu.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Eye, MoreVertical, PlayCircle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { UploadVideo } from '@/types'; + +interface UploadActionsMenuProps { + upload: UploadVideo; + onViewDetails: (upload: UploadVideo) => void; + onViewProcessedVideo: (upload: UploadVideo) => void; +} + +export function UploadActionsMenu({ + upload, + onViewDetails, + onViewProcessedVideo, +}: UploadActionsMenuProps) { + return ( + + + + + + onViewDetails(upload)}> + + View Details + + {upload.processed_video_url ? ( + onViewProcessedVideo(upload)}> + + View Processed Video + + ) : null} + + + ); +} diff --git a/src/app/(modules)/upload/components/UploadCardGrid.tsx b/src/app/(modules)/upload/components/UploadCardGrid.tsx new file mode 100644 index 0000000..ebbc280 --- /dev/null +++ b/src/app/(modules)/upload/components/UploadCardGrid.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { FileVideo } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import type { UploadVideo } from '@/types'; + +import { CardPagination } from './CardPagination'; +import { UploadActionsMenu } from './UploadActionsMenu'; +import { UploadThumbnail } from './UploadThumbnail'; +import { formatUploadedAt } from './uploadUtils'; + +interface UploadCardGridProps { + uploads: UploadVideo[]; + isLoading: boolean; + skip: number; + limit: number; + total: number; + onPageChange: (skip: number) => void; + onLimitChange: (limit: number) => void; + onUploadNew: () => void; + onOpenUpload: (upload: UploadVideo) => void; + onViewDetails: (upload: UploadVideo) => void; + onViewProcessedVideo: (upload: UploadVideo) => void; +} + +export function UploadCardGrid({ + uploads, + isLoading, + skip, + limit, + total, + onPageChange, + onLimitChange, + onUploadNew, + onOpenUpload, + onViewDetails, + onViewProcessedVideo, +}: UploadCardGridProps) { + const skeletonItems = Array.from({ length: Math.min(limit, 20) }); + + if (!isLoading && uploads.length === 0) { + return ( + + +
+ +
+
+

No uploads found

+

+ Upload your first road inspection video. +

+
+ +
+
+ ); + } + + return ( +
+
+ {isLoading + ? skeletonItems.map((_, index) => ( + + )) + : uploads.map((upload) => ( + onOpenUpload(upload)} + > + + +
+
+

+ {upload.filename} +

+

+ Uploaded {formatUploadedAt(upload.uploaded_at)} +

+
+ +
+
+
+ ))} +
+ + +
+ ); +} diff --git a/src/app/(modules)/upload/components/UploadColumns.tsx b/src/app/(modules)/upload/components/UploadColumns.tsx new file mode 100644 index 0000000..cd15ecc --- /dev/null +++ b/src/app/(modules)/upload/components/UploadColumns.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useMemo } from 'react'; +import type { ColumnDef } from '@tanstack/react-table'; + +import { UploadActionsMenu } from './UploadActionsMenu'; +import { UploadThumbnail } from './UploadThumbnail'; +import { formatUploadedAt, getUploadStatus } from './uploadUtils'; +import { Badge } from '@/components/ui/badge'; +import type { UploadVideo } from '@/types'; + +interface UseUploadColumnsProps { + onViewDetails: (upload: UploadVideo) => void; + onViewProcessedVideo: (upload: UploadVideo) => void; +} + +export function useUploadColumns({ + onViewDetails, + onViewProcessedVideo, +}: UseUploadColumnsProps) { + return useMemo[]>( + () => [ + { + id: 'thumbnail', + header: 'Thumbnail', + size: 140, + minSize: 120, + enableSorting: false, + meta: { disableTruncate: true }, + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'filename', + header: 'Filename', + cell: ({ row }) => ( + {row.original.filename} + ), + }, + { + id: 'status', + header: 'Status', + size: 130, + cell: ({ row }) => ( + + {getUploadStatus(row.original)} + + ), + }, + { + accessorKey: 'uploaded_at', + header: 'Uploaded At', + cell: ({ row }) => formatUploadedAt(row.original.uploaded_at), + }, + { + id: 'actions', + header: () => Actions, + size: 56, + minSize: 56, + enableSorting: false, + enableHiding: false, + meta: { disableTruncate: true }, + cell: ({ row }) => ( +
+ +
+ ), + }, + ], + [onViewDetails, onViewProcessedVideo], + ); +} diff --git a/src/app/(modules)/upload/components/UploadDetailsDialog.tsx b/src/app/(modules)/upload/components/UploadDetailsDialog.tsx new file mode 100644 index 0000000..f67e51d --- /dev/null +++ b/src/app/(modules)/upload/components/UploadDetailsDialog.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { UploadVideo } from '@/types'; + +import { formatUploadedAt, getUploadStatus } from './uploadUtils'; + +interface UploadDetailsDialogProps { + upload: UploadVideo | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function UploadDetailsDialog({ + upload, + open, + onOpenChange, +}: UploadDetailsDialogProps) { + const title = upload?.filename ?? 'Upload Details'; + + return ( + + + + + {upload?.filename ? ( + + + + + {title} + + + + {title} + + + + ) : ( + title + )} + + Uploaded video details + + + {upload ? ( +
+
Status
+
+ {getUploadStatus(upload)} +
+
Uploaded At
+
{formatUploadedAt(upload.uploaded_at)}
+
Uploaded By
+
{upload.uploaded_by_email ?? '-'}
+
Video ID
+
{upload.video_id}
+
+ ) : null} +
+
+ ); +} diff --git a/src/app/(modules)/upload/components/UploadSettingsSection.tsx b/src/app/(modules)/upload/components/UploadSettingsSection.tsx index 47afe12..a076234 100644 --- a/src/app/(modules)/upload/components/UploadSettingsSection.tsx +++ b/src/app/(modules)/upload/components/UploadSettingsSection.tsx @@ -1,5 +1,7 @@ 'use client'; +import type { RefObject } from 'react'; + import { FormField, SelectPopover } from '@/components/form'; const options = [ @@ -14,12 +16,14 @@ interface UploadSettingsSectionProps { value: string; onValueChange: (value: string) => void; disabled?: boolean; + portalContainer?: RefObject; } export function UploadSettingsSection({ value, onValueChange, disabled = false, + portalContainer, }: UploadSettingsSectionProps) { return (
@@ -33,12 +37,13 @@ export function UploadSettingsSection({
- + []; + uploads: UploadVideo[]; + isLoading: boolean; + skip: number; + limit: number; + total: number; + onPageChange: (skip: number) => void; + onLimitChange: (limit: number) => void; + onRowClick: (upload: UploadVideo) => void; +} + +export function UploadTable({ + columns, + uploads, + isLoading, + skip, + limit, + total, + onPageChange, + onLimitChange, + onRowClick, +}: UploadTableProps) { + return ( + + ); +} diff --git a/src/app/(modules)/upload/components/UploadThumbnail.tsx b/src/app/(modules)/upload/components/UploadThumbnail.tsx new file mode 100644 index 0000000..2b4608e --- /dev/null +++ b/src/app/(modules)/upload/components/UploadThumbnail.tsx @@ -0,0 +1,33 @@ +import { ImageIcon } from 'lucide-react'; + +import type { UploadVideo } from '@/types'; + +import { resolveMediaUrl } from './uploadUtils'; + +interface UploadThumbnailProps { + upload: UploadVideo; + className?: string; +} + +export function UploadThumbnail({ upload, className }: UploadThumbnailProps) { + const thumbnailUrl = resolveMediaUrl(upload.thumbnail); + + return ( +
+
+ {thumbnailUrl ? ( + {upload.filename} + ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/(modules)/upload/components/ViewSwitcher.tsx b/src/app/(modules)/upload/components/ViewSwitcher.tsx new file mode 100644 index 0000000..3e277df --- /dev/null +++ b/src/app/(modules)/upload/components/ViewSwitcher.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Grid2X2, Table2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +import type { UploadViewMode } from '../hooks/useUploadFilters'; + +interface ViewSwitcherProps { + value: UploadViewMode; + onValueChange: (value: UploadViewMode) => void; +} + +export function ViewSwitcher({ value, onValueChange }: ViewSwitcherProps) { + const items: Array<{ + value: UploadViewMode; + label: string; + icon: React.ReactNode; + }> = [ + { value: 'card', label: 'Card View', icon: }, + { value: 'table', label: 'Table View', icon: }, + ]; + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + diff --git a/src/app/(modules)/upload/components/uploadUtils.ts b/src/app/(modules)/upload/components/uploadUtils.ts new file mode 100644 index 0000000..3106fa6 --- /dev/null +++ b/src/app/(modules)/upload/components/uploadUtils.ts @@ -0,0 +1,29 @@ +import { ENV_CONSTANT } from '@/constants/secrect.constant'; +import type { UploadVideo } from '@/types'; + +export function getUploadStatus(upload: UploadVideo) { + return upload.status?.status || 'unknown'; +} + +export function formatUploadedAt(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + + return new Intl.DateTimeFormat('en-IN', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(date); +} + +export function resolveMediaUrl(url?: string | null) { + if (!url) return null; + if (/^https?:\/\//i.test(url)) return url; + + const baseUrl = ENV_CONSTANT.BASE_API_URL?.replace(/\/$/, ''); + const path = url.startsWith('/') ? url : `/${url}`; + return baseUrl ? `${baseUrl}${path}` : path; +} + diff --git a/src/app/(modules)/upload/hooks/useUploadFilters.ts b/src/app/(modules)/upload/hooks/useUploadFilters.ts new file mode 100644 index 0000000..6f89585 --- /dev/null +++ b/src/app/(modules)/upload/hooks/useUploadFilters.ts @@ -0,0 +1,27 @@ +'use client'; + +import { useState } from 'react'; + +export type UploadViewMode = 'card' | 'table'; + +const DEFAULT_LIMIT = 20; + +export function useUploadFilters() { + const [skip, setSkip] = useState(0); + const [limit, setLimitValue] = useState(DEFAULT_LIMIT); + const [viewMode, setViewMode] = useState('card'); + + const setLimit = (nextLimit: number) => { + setLimitValue(nextLimit); + setSkip(0); + }; + + return { + skip, + setSkip, + limit, + setLimit, + viewMode, + setViewMode, + }; +} diff --git a/src/app/(modules)/upload/hooks/useUploadQueries.ts b/src/app/(modules)/upload/hooks/useUploadQueries.ts new file mode 100644 index 0000000..e4876ee --- /dev/null +++ b/src/app/(modules)/upload/hooks/useUploadQueries.ts @@ -0,0 +1,54 @@ +'use client'; + +import { useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { videoService } from '@/services/api'; +import type { PaginationParams } from '@/types'; + +export const uploadKeys = { + all: ['uploads'] as const, + lists: () => [...uploadKeys.all, 'list'] as const, + list: (params: PaginationParams) => [...uploadKeys.lists(), params] as const, +}; + +interface UseMyUploadsQueryParams { + skip: number; + limit: number; +} + +function buildUploadListParams({ + skip, + limit, +}: UseMyUploadsQueryParams): PaginationParams { + return { + skip, + limit, + }; +} + +export function useMyUploadsQuery(params: UseMyUploadsQueryParams) { + const { skip, limit } = params; + const listParams = useMemo( + () => buildUploadListParams({ skip, limit }), + [limit, skip], + ); + + return useQuery({ + queryKey: uploadKeys.list(listParams), + queryFn: () => videoService.getMyUploads(listParams), + }); +} + +export function useCreateUploadMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (formData: FormData) => videoService.uploadVideo(formData), + onSuccess: () => { + toast.success('Upload created'); + queryClient.invalidateQueries({ queryKey: uploadKeys.all }); + }, + }); +} diff --git a/src/app/(modules)/upload/page.tsx b/src/app/(modules)/upload/page.tsx index 6d76c5e..11ac0e1 100644 --- a/src/app/(modules)/upload/page.tsx +++ b/src/app/(modules)/upload/page.tsx @@ -1,134 +1,123 @@ 'use client'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { Loader2, TrendingUp, UploadCloud } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { Plus, UploadCloud } from 'lucide-react'; import { PageHeader } from '@/components/page-header'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; -import { videoService } from '@/services/api'; -import type { SessionContext } from '@/types'; -import { ROUTES } from '@/utils/routes'; +import type { UploadVideo } from '@/types'; -import { InputDataSection } from './components/InputDataSection'; -import { LocationSection } from './components/LocationSection'; -import { UploadSettingsSection } from './components/UploadSettingsSection'; +import { CreateUploadDialog } from './components/CreateUploadDialog'; +import { ProcessedVideoDialog } from './components/ProcessedVideoDialog'; +import { UploadCardGrid } from './components/UploadCardGrid'; +import { useUploadColumns } from './components/UploadColumns'; +import { UploadDetailsDialog } from './components/UploadDetailsDialog'; +import { UploadTable } from './components/UploadTable'; +import { ViewSwitcher } from './components/ViewSwitcher'; +import { useUploadFilters } from './hooks/useUploadFilters'; +import { useMyUploadsQuery } from './hooks/useUploadQueries'; export default function UploadPage() { - const router = useRouter(); - const [session, setSession] = useState(null); - const [videoFile, setVideoFile] = useState(null); - const [gpsFile, setGpsFile] = useState(null); - const [analysisMethod, setAnalysisMethod] = useState('yolo'); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - - const isLocationComplete = Boolean( - session?.projectId && session.packageId && session.chainageId, - ); - const canSubmit = Boolean( - isLocationComplete && videoFile && gpsFile && analysisMethod, + const { skip, setSkip, limit, setLimit, viewMode, setViewMode } = + useUploadFilters(); + const uploadsQuery = useMyUploadsQuery({ skip, limit }); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [detailsUpload, setDetailsUpload] = useState(null); + const [processedUpload, setProcessedUpload] = useState( + null, ); - const handleSubmit = async () => { - if (!session?.chainageId || !videoFile || !gpsFile) { - setError('Complete location and input data before upload.'); - return; - } + const uploads = uploadsQuery.data?.items ?? []; + const total = uploadsQuery.data?.total ?? 0; + const isLoading = uploadsQuery.isLoading; - const formData = new FormData(); - formData.append('file', videoFile); - formData.append('detection_mode', analysisMethod); - formData.append('chainage_id', session.chainageId); - formData.append('json_file', gpsFile); + const handleViewDetails = useCallback((upload: UploadVideo) => { + setDetailsUpload(upload); + }, []); - setIsSubmitting(true); - setError(null); + const handleViewProcessedVideo = useCallback((upload: UploadVideo) => { + setProcessedUpload(upload); + }, []); - try { - await videoService.uploadVideo(formData); - router.push(ROUTES.TICKET); - } catch (err) { - setIsSubmitting(false); - if (err instanceof TypeError && err.message === 'Failed to fetch') { - setError('Cannot connect to server. Please check if backend is running.'); + const handleOpenUpload = useCallback( + (upload: UploadVideo) => { + if (upload.processed_video_url) { + handleViewProcessedVideo(upload); return; } - setError(err instanceof Error ? err.message : 'Upload failed'); - } - }; + handleViewDetails(upload); + }, + [handleViewDetails, handleViewProcessedVideo], + ); + + const columns = useUploadColumns({ + onViewDetails: handleViewDetails, + onViewProcessedVideo: handleViewProcessedVideo, + }); return ( -
- - - - - - - - - { - setVideoFile(file); - setError(null); - }} - onGpsFileChange={(file) => { - setGpsFile(file); - setError(null); - }} - disabled={isSubmitting} - /> - - - - - - {error ? ( -
- {error} -
- ) : null} - -
- -
-
-
-
+ } + /> + +
+ +
+ + {viewMode === 'card' ? ( + setIsCreateOpen(true)} + onOpenUpload={handleOpenUpload} + onViewDetails={handleViewDetails} + onViewProcessedVideo={handleViewProcessedVideo} + /> + ) : ( + + )} + + + + { + if (!open) setDetailsUpload(null); + }} + /> + { + if (!open) setProcessedUpload(null); + }} + /> + ); } diff --git a/src/components/data-table/index.tsx b/src/components/data-table/index.tsx index d51379f..0ffe9bb 100644 --- a/src/components/data-table/index.tsx +++ b/src/components/data-table/index.tsx @@ -73,6 +73,7 @@ export interface DataTableProps { sorting?: SortingState; onSortingChange?: (sorting: SortingState) => void; enableColumnControls?: boolean; + onRowClick?: (row: TData) => void; /** Minimum width (px) for every column unless overridden in the column def. */ minColumnSize?: number; } @@ -111,6 +112,7 @@ function DataTableContent({ emptyTitle = 'No results found.', emptyDescription = 'Try adjusting your filters or search terms.', pagination, + onRowClick, sorting: controlledSorting, onSortingChange, enableColumnControls = true, @@ -281,6 +283,8 @@ function DataTableContent({ onRowClick?.(row.original)} + className={cn(onRowClick && 'cursor-pointer')} > {row.getVisibleCells().map((cell) => { const truncate = shouldTruncateCell(cell.column); diff --git a/src/components/form/SelectPopover.tsx b/src/components/form/SelectPopover.tsx index a97eb8d..52ac531 100644 --- a/src/components/form/SelectPopover.tsx +++ b/src/components/form/SelectPopover.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { RefObject } from 'react'; import { useMemo, useState } from 'react'; import { Check, ChevronDown } from 'lucide-react'; @@ -27,6 +28,7 @@ interface SelectPopoverProps { emptyMessage?: string; disabled?: boolean; align?: 'start' | 'center' | 'end'; + portalContainer?: RefObject; } export function SelectPopover({ @@ -38,6 +40,7 @@ export function SelectPopover({ emptyMessage = 'No options found.', disabled = false, align = 'start', + portalContainer, }: SelectPopoverProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); @@ -70,7 +73,11 @@ export function SelectPopover({ - +
) { +}: React.ComponentProps & { + container?: + | React.ComponentProps['container'] + | React.RefObject; +}) { + const portalContainer = + container && 'current' in container ? container.current : container; + return ( - + `/biz/api/v1/results/${id}/completed`, }, } as const; diff --git a/src/services/api/video.service.ts b/src/services/api/video.service.ts index 1a86bd0..eca55b0 100644 --- a/src/services/api/video.service.ts +++ b/src/services/api/video.service.ts @@ -1,11 +1,26 @@ import axiosClient from '../axios/axios'; import { API_ROUTES } from '@/constants/apiRoutes'; -import { CompletedVideoResult } from '@/types'; +import { CompletedVideoResult, PaginationParams, UploadVideosResponse } from '@/types'; /** * Video Service */ export const videoService = { + getMyUploads: async ( + params?: PaginationParams, + ): Promise => { + const response = await axiosClient.get( + API_ROUTES.VIDEOS.MY_UPLOADS, + { + params: { + skip: params?.skip ?? 0, + limit: params?.limit ?? 20, + }, + }, + ); + return response.data; + }, + /** * Upload a video for processing */ diff --git a/src/types/index.ts b/src/types/index.ts index d53e76b..4a22a13 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,7 @@ export * from './chainage'; export * from './detection'; export * from './analysis'; export * from './session'; +export * from './upload'; export * from './auth.type'; export * from './permission'; export * from './role'; diff --git a/src/types/upload.ts b/src/types/upload.ts new file mode 100644 index 0000000..7c489e9 --- /dev/null +++ b/src/types/upload.ts @@ -0,0 +1,25 @@ +export interface UploadVideoStatus { + status: string; + progress: number; +} + +export interface UploadVideo { + video_id: string; + filename: string; + chainage_id: string; + detection_mode: string; + speed_kmh?: number | null; + thumbnail?: string | null; + uploaded_at: string; + uploaded_by_user_id?: number | null; + uploaded_by_email?: string | null; + processed_video_path?: string | null; + processed_video_url?: string | null; + status: UploadVideoStatus; +} + +export interface UploadVideosResponse { + items: UploadVideo[]; + total: number; +} +