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 (
+
+ );
+}
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 (
+
+ );
+}
+
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 (
+
+ );
+}
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 ? (
+

+ ) : (
+
+
+
+ )}
+
+
+ );
+}
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;
+}
+