feat: add My Uploads listing with card and table views
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/types/routes.d.ts";
|
import "./.next/dev/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.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
|
|||||||
import { packageService, projectService } from '@/services/api';
|
import { packageService, projectService } from '@/services/api';
|
||||||
import type { Package, PaginationParams } from '@/types';
|
import type { Package, PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
import { projectKeys } from '../../project/queries/projectKeys';
|
||||||
import { packageKeys } from '../queries/packageKeys';
|
import { packageKeys } from '../queries/packageKeys';
|
||||||
|
|
||||||
interface UsePackagesQueryParams {
|
interface UsePackagesQueryParams {
|
||||||
@@ -28,7 +29,7 @@ export function usePackagesQuery({ skip, limit }: UsePackagesQueryParams) {
|
|||||||
|
|
||||||
export function useProjectOptionsQuery() {
|
export function useProjectOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['projects', 'options'],
|
queryKey: projectKeys.options(),
|
||||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const packageKeys = {
|
|||||||
all: ['packages'] as const,
|
all: ['packages'] as const,
|
||||||
lists: () => [...packageKeys.all, 'list'] as const,
|
lists: () => [...packageKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...packageKeys.lists(), params] as const,
|
||||||
|
options: () => [...packageKeys.all, 'options'] as const,
|
||||||
details: () => [...packageKeys.all, 'detail'] as const,
|
details: () => [...packageKeys.all, 'detail'] as const,
|
||||||
detail: (id: string) => [...packageKeys.details(), id] as const,
|
detail: (id: string) => [...packageKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const projectKeys = {
|
|||||||
all: ['projects'] as const,
|
all: ['projects'] as const,
|
||||||
lists: () => [...projectKeys.all, 'list'] as const,
|
lists: () => [...projectKeys.all, 'list'] as const,
|
||||||
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
|
list: (params: PaginationParams) => [...projectKeys.lists(), params] as const,
|
||||||
|
options: () => [...projectKeys.all, 'options'] as const,
|
||||||
details: () => [...projectKeys.all, 'detail'] as const,
|
details: () => [...projectKeys.all, 'detail'] as const,
|
||||||
detail: (id: string) => [...projectKeys.details(), id] as const,
|
detail: (id: string) => [...projectKeys.details(), id] as const,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@/services/api';
|
} from '@/services/api';
|
||||||
import type { Chainage, PaginationParams } from '@/types';
|
import type { Chainage, PaginationParams } from '@/types';
|
||||||
|
|
||||||
|
import { packageKeys } from '../../package/queries/packageKeys';
|
||||||
|
import { projectKeys } from '../../project/queries/projectKeys';
|
||||||
import { segmentKeys } from '../queries/segmentKeys';
|
import { segmentKeys } from '../queries/segmentKeys';
|
||||||
|
|
||||||
interface UseSegmentsQueryParams {
|
interface UseSegmentsQueryParams {
|
||||||
@@ -32,14 +34,14 @@ export function useSegmentsQuery({ skip, limit }: UseSegmentsQueryParams) {
|
|||||||
|
|
||||||
export function useProjectOptionsQuery() {
|
export function useProjectOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['projects', 'options'],
|
queryKey: projectKeys.options(),
|
||||||
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
queryFn: () => projectService.getProjects({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAllPackageOptionsQuery() {
|
export function useAllPackageOptionsQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['packages', 'options'],
|
queryKey: packageKeys.options(),
|
||||||
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
|
queryFn: () => packageService.getPackages({ skip: 0, limit: 1000 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/app/(modules)/upload/components/CardPagination.tsx
Normal file
79
src/app/(modules)/upload/components/CardPagination.tsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/app/(modules)/upload/components/CreateUploadDialog.tsx
Normal file
172
src/app/(modules)/upload/components/CreateUploadDialog.tsx
Normal file
@@ -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<SessionContext | null>(null);
|
||||||
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
|
const [gpsFile, setGpsFile] = useState<File | null>(null);
|
||||||
|
const [detectionMode, setDetectionMode] = useState('yolo');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dropdownPortalRef = useRef<HTMLDivElement | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-5xl"
|
||||||
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
|
||||||
|
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
|
||||||
|
Upload Video
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure location and upload road inspection data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
|
<LocationSection
|
||||||
|
value={session}
|
||||||
|
onChange={setSession}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InputDataSection
|
||||||
|
videoFile={videoFile}
|
||||||
|
gpsFile={gpsFile}
|
||||||
|
onVideoFileChange={(file) => {
|
||||||
|
setVideoFile(file);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onGpsFileChange={(file) => {
|
||||||
|
setGpsFile(file);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<UploadSettingsSection
|
||||||
|
value={detectionMode}
|
||||||
|
onValueChange={setDetectionMode}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
portalContainer={dropdownPortalRef}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 border-t px-6 py-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!canSubmit || isSubmitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Uploading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Upload
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
|
||||||
|
<div ref={dropdownPortalRef} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +1,36 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { FormField } from '@/components/form';
|
import { FormField } from '@/components/form';
|
||||||
import { PackageSelect } from '@/components/lookups/PackageSelect';
|
import { PackageSelect } from '@/components/lookups/PackageSelect';
|
||||||
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
import { ProjectSelect } from '@/components/lookups/ProjectSelect';
|
||||||
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
import { SegmentSelect } from '@/components/lookups/SegmentSelect';
|
||||||
import { packageService, projectService, chainageService } from '@/services/api';
|
import {
|
||||||
import type { Chainage, Package as PackageType, Project, SessionContext } from '@/types';
|
packageService,
|
||||||
|
projectService,
|
||||||
|
chainageService,
|
||||||
|
} from '@/services/api';
|
||||||
|
import type {
|
||||||
|
Chainage,
|
||||||
|
Package as PackageType,
|
||||||
|
Project,
|
||||||
|
SessionContext,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
interface LocationSectionProps {
|
interface LocationSectionProps {
|
||||||
value: SessionContext | null;
|
value: SessionContext | null;
|
||||||
onChange: (session: SessionContext | null) => void;
|
onChange: (session: SessionContext | null) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationSection({
|
export function LocationSection({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
portalContainer,
|
||||||
}: LocationSectionProps) {
|
}: LocationSectionProps) {
|
||||||
const [projectId, setProjectId] = useState(value?.projectId ?? '');
|
const [projectId, setProjectId] = useState(value?.projectId ?? '');
|
||||||
const [packageId, setPackageId] = useState(value?.packageId ?? '');
|
const [packageId, setPackageId] = useState(value?.packageId ?? '');
|
||||||
@@ -106,7 +118,7 @@ export function LocationSection({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">Location</h2>
|
<h2 className="text-sm font-semibold text-foreground">Location</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Select the project hierarchy for this analysis job.
|
Select the project hierarchy for this upload.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +128,7 @@ export function LocationSection({
|
|||||||
value={projectId}
|
value={projectId}
|
||||||
onValueChange={handleProjectChange}
|
onValueChange={handleProjectChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
portalContainer={portalContainer}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@@ -126,6 +139,7 @@ export function LocationSection({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
enabled={Boolean(projectId)}
|
enabled={Boolean(projectId)}
|
||||||
disabled={disabled || !projectId}
|
disabled={disabled || !projectId}
|
||||||
|
portalContainer={portalContainer}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
@@ -136,10 +150,10 @@ export function LocationSection({
|
|||||||
packageId={packageId}
|
packageId={packageId}
|
||||||
enabled={Boolean(packageId)}
|
enabled={Boolean(packageId)}
|
||||||
disabled={disabled || !packageId}
|
disabled={disabled || !packageId}
|
||||||
|
portalContainer={portalContainer}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/app/(modules)/upload/components/ProcessedVideoDialog.tsx
Normal file
53
src/app/(modules)/upload/components/ProcessedVideoDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{upload?.filename ?? 'Processed Video'}</DialogTitle>
|
||||||
|
<DialogDescription>Processed video preview</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex aspect-video items-center justify-center overflow-hidden rounded-md border bg-muted/30">
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
|
) : isError || !videoUrl ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Processed video is not available.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<video src={videoUrl} controls className="h-full w-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
53
src/app/(modules)/upload/components/UploadActionsMenu.tsx
Normal file
53
src/app/(modules)/upload/components/UploadActionsMenu.tsx
Normal file
@@ -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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
<span className="sr-only">Open actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onViewDetails(upload)}>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{upload.processed_video_url ? (
|
||||||
|
<DropdownMenuItem onClick={() => onViewProcessedVideo(upload)}>
|
||||||
|
<PlayCircle className="size-4" />
|
||||||
|
View Processed Video
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/(modules)/upload/components/UploadCardGrid.tsx
Normal file
107
src/app/(modules)/upload/components/UploadCardGrid.tsx
Normal file
@@ -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 (
|
||||||
|
<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}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onViewProcessedVideo={onViewProcessedVideo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardPagination
|
||||||
|
skip={skip}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
count={uploads.length}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
onLimitChange={onLimitChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/(modules)/upload/components/UploadColumns.tsx
Normal file
77
src/app/(modules)/upload/components/UploadColumns.tsx
Normal file
@@ -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<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}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onViewProcessedVideo={onViewProcessedVideo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onViewDetails, onViewProcessedVideo],
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
79
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
import { FormField, SelectPopover } from '@/components/form';
|
import { FormField, SelectPopover } from '@/components/form';
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
@@ -14,12 +16,14 @@ interface UploadSettingsSectionProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UploadSettingsSection({
|
export function UploadSettingsSection({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
portalContainer,
|
||||||
}: UploadSettingsSectionProps) {
|
}: UploadSettingsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -33,12 +37,13 @@ export function UploadSettingsSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<FormField id="detection-method" label="Detection Method">
|
<FormField id="detection-mode" label="Detection Mode">
|
||||||
<SelectPopover
|
<SelectPopover
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
portalContainer={portalContainer}
|
||||||
placeholder="Select method"
|
placeholder="Select method"
|
||||||
searchPlaceholder="Search methods"
|
searchPlaceholder="Search methods"
|
||||||
emptyMessage="No methods found."
|
emptyMessage="No methods found."
|
||||||
|
|||||||
50
src/app/(modules)/upload/components/UploadTable.tsx
Normal file
50
src/app/(modules)/upload/components/UploadTable.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import type { UploadVideo } from '@/types';
|
||||||
|
|
||||||
|
interface UploadTableProps {
|
||||||
|
columns: ColumnDef<UploadVideo>[];
|
||||||
|
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 (
|
||||||
|
<DataTable
|
||||||
|
data={uploads}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyTitle="No uploads found"
|
||||||
|
emptyDescription="Upload your first road inspection video."
|
||||||
|
pagination={{
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
totalItems: total,
|
||||||
|
onPageChange,
|
||||||
|
onLimitChange,
|
||||||
|
}}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
enableColumnControls={false}
|
||||||
|
minColumnSize={120}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/(modules)/upload/components/UploadThumbnail.tsx
Normal file
33
src/app/(modules)/upload/components/UploadThumbnail.tsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/(modules)/upload/components/ViewSwitcher.tsx
Normal file
46
src/app/(modules)/upload/components/ViewSwitcher.tsx
Normal file
@@ -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: <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
29
src/app/(modules)/upload/components/uploadUtils.ts
Normal file
29
src/app/(modules)/upload/components/uploadUtils.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
27
src/app/(modules)/upload/hooks/useUploadFilters.ts
Normal file
27
src/app/(modules)/upload/hooks/useUploadFilters.ts
Normal file
@@ -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<UploadViewMode>('card');
|
||||||
|
|
||||||
|
const setLimit = (nextLimit: number) => {
|
||||||
|
setLimitValue(nextLimit);
|
||||||
|
setSkip(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
skip,
|
||||||
|
setSkip,
|
||||||
|
limit,
|
||||||
|
setLimit,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/app/(modules)/upload/hooks/useUploadQueries.ts
Normal file
54
src/app/(modules)/upload/hooks/useUploadQueries.ts
Normal file
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,134 +1,123 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { Plus, UploadCloud } from 'lucide-react';
|
||||||
import { Loader2, TrendingUp, 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 { Card, CardContent } from '@/components/ui/card';
|
import type { UploadVideo } from '@/types';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { videoService } from '@/services/api';
|
|
||||||
import type { SessionContext } from '@/types';
|
|
||||||
import { ROUTES } from '@/utils/routes';
|
|
||||||
|
|
||||||
import { InputDataSection } from './components/InputDataSection';
|
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||||
import { LocationSection } from './components/LocationSection';
|
import { ProcessedVideoDialog } from './components/ProcessedVideoDialog';
|
||||||
import { UploadSettingsSection } from './components/UploadSettingsSection';
|
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() {
|
export default function UploadPage() {
|
||||||
const router = useRouter();
|
const { skip, setSkip, limit, setLimit, viewMode, setViewMode } =
|
||||||
const [session, setSession] = useState<SessionContext | null>(null);
|
useUploadFilters();
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
const uploadsQuery = useMyUploadsQuery({ skip, limit });
|
||||||
const [gpsFile, setGpsFile] = useState<File | null>(null);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [analysisMethod, setAnalysisMethod] = useState('yolo');
|
const [detailsUpload, setDetailsUpload] = useState<UploadVideo | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [processedUpload, setProcessedUpload] = useState<UploadVideo | null>(
|
||||||
const [error, setError] = useState<string | null>(null);
|
null,
|
||||||
|
|
||||||
const isLocationComplete = Boolean(
|
|
||||||
session?.projectId && session.packageId && session.chainageId,
|
|
||||||
);
|
|
||||||
const canSubmit = Boolean(
|
|
||||||
isLocationComplete && videoFile && gpsFile && analysisMethod,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const uploads = uploadsQuery.data?.items ?? [];
|
||||||
if (!session?.chainageId || !videoFile || !gpsFile) {
|
const total = uploadsQuery.data?.total ?? 0;
|
||||||
setError('Complete location and input data before upload.');
|
const isLoading = uploadsQuery.isLoading;
|
||||||
|
|
||||||
|
const handleViewDetails = useCallback((upload: UploadVideo) => {
|
||||||
|
setDetailsUpload(upload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewProcessedVideo = useCallback((upload: UploadVideo) => {
|
||||||
|
setProcessedUpload(upload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenUpload = useCallback(
|
||||||
|
(upload: UploadVideo) => {
|
||||||
|
if (upload.processed_video_url) {
|
||||||
|
handleViewProcessedVideo(upload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handleViewDetails(upload);
|
||||||
|
},
|
||||||
|
[handleViewDetails, handleViewProcessedVideo],
|
||||||
|
);
|
||||||
|
|
||||||
const formData = new FormData();
|
const columns = useUploadColumns({
|
||||||
formData.append('file', videoFile);
|
onViewDetails: handleViewDetails,
|
||||||
formData.append('detection_mode', analysisMethod);
|
onViewProcessedVideo: handleViewProcessedVideo,
|
||||||
formData.append('chainage_id', session.chainageId);
|
});
|
||||||
formData.append('json_file', gpsFile);
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
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.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<>
|
||||||
|
<main className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Upload"
|
title="My Uploads"
|
||||||
description="Configure location, upload data, and submit it for processing."
|
description="View and manage uploaded road inspection videos."
|
||||||
icon={TrendingUp}
|
icon={UploadCloud}
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => setIsCreateOpen(true)} size="sm">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Upload New
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="space-y-5 p-5">
|
|
||||||
<LocationSection
|
|
||||||
value={session}
|
|
||||||
onChange={setSession}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<InputDataSection
|
|
||||||
videoFile={videoFile}
|
|
||||||
gpsFile={gpsFile}
|
|
||||||
onVideoFileChange={(file) => {
|
|
||||||
setVideoFile(file);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
onGpsFileChange={(file) => {
|
|
||||||
setGpsFile(file);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<UploadSettingsSection
|
|
||||||
value={analysisMethod}
|
|
||||||
onValueChange={setAnalysisMethod}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<ViewSwitcher value={viewMode} onValueChange={setViewMode} />
|
||||||
type="button"
|
</div>
|
||||||
size="lg"
|
|
||||||
disabled={!canSubmit || isSubmitting}
|
{viewMode === 'card' ? (
|
||||||
onClick={handleSubmit}
|
<UploadCardGrid
|
||||||
className="w-full gap-2 md:w-auto"
|
uploads={uploads}
|
||||||
>
|
isLoading={isLoading}
|
||||||
{isSubmitting ? (
|
skip={skip}
|
||||||
<>
|
limit={limit}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
total={total}
|
||||||
Uploading...
|
onPageChange={setSkip}
|
||||||
</>
|
onLimitChange={setLimit}
|
||||||
|
onUploadNew={() => setIsCreateOpen(true)}
|
||||||
|
onOpenUpload={handleOpenUpload}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onViewProcessedVideo={handleViewProcessedVideo}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<UploadTable
|
||||||
<UploadCloud className="size-4" />
|
columns={columns}
|
||||||
Upload
|
uploads={uploads}
|
||||||
</>
|
isLoading={isLoading}
|
||||||
|
skip={skip}
|
||||||
|
limit={limit}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setSkip}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
onRowClick={handleOpenUpload}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</main>
|
||||||
</div>
|
|
||||||
</CardContent>
|
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||||
</Card>
|
<UploadDetailsDialog
|
||||||
</div>
|
upload={detailsUpload}
|
||||||
|
open={Boolean(detailsUpload)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDetailsUpload(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProcessedVideoDialog
|
||||||
|
upload={processedUpload}
|
||||||
|
open={Boolean(processedUpload)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setProcessedUpload(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface DataTableProps<TData, TValue> {
|
|||||||
sorting?: SortingState;
|
sorting?: SortingState;
|
||||||
onSortingChange?: (sorting: SortingState) => void;
|
onSortingChange?: (sorting: SortingState) => void;
|
||||||
enableColumnControls?: boolean;
|
enableColumnControls?: boolean;
|
||||||
|
onRowClick?: (row: TData) => void;
|
||||||
/** Minimum width (px) for every column unless overridden in the column def. */
|
/** Minimum width (px) for every column unless overridden in the column def. */
|
||||||
minColumnSize?: number;
|
minColumnSize?: number;
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,7 @@ function DataTableContent<TData, TValue>({
|
|||||||
emptyTitle = 'No results found.',
|
emptyTitle = 'No results found.',
|
||||||
emptyDescription = 'Try adjusting your filters or search terms.',
|
emptyDescription = 'Try adjusting your filters or search terms.',
|
||||||
pagination,
|
pagination,
|
||||||
|
onRowClick,
|
||||||
sorting: controlledSorting,
|
sorting: controlledSorting,
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
enableColumnControls = true,
|
enableColumnControls = true,
|
||||||
@@ -281,6 +283,8 @@ function DataTableContent<TData, TValue>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
onClick={() => onRowClick?.(row.original)}
|
||||||
|
className={cn(onRowClick && 'cursor-pointer')}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => {
|
{row.getVisibleCells().map((cell) => {
|
||||||
const truncate = shouldTruncateCell(cell.column);
|
const truncate = shouldTruncateCell(cell.column);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Check, ChevronDown } from 'lucide-react';
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ interface SelectPopoverProps {
|
|||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
align?: 'start' | 'center' | 'end';
|
align?: 'start' | 'center' | 'end';
|
||||||
|
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectPopover({
|
export function SelectPopover({
|
||||||
@@ -38,6 +40,7 @@ export function SelectPopover({
|
|||||||
emptyMessage = 'No options found.',
|
emptyMessage = 'No options found.',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
|
portalContainer,
|
||||||
}: SelectPopoverProps) {
|
}: SelectPopoverProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -70,7 +73,11 @@ export function SelectPopover({
|
|||||||
<ChevronDown className="size-4 opacity-60" />
|
<ChevronDown className="size-4 opacity-60" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align={align} className="w-72 p-0 shadow-none">
|
<PopoverContent
|
||||||
|
align={align}
|
||||||
|
container={portalContainer}
|
||||||
|
className="w-72 p-0 shadow-none"
|
||||||
|
>
|
||||||
<div className="border-b p-3">
|
<div className="border-b p-3">
|
||||||
<Input
|
<Input
|
||||||
value={search}
|
value={search}
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ function PopoverContent({
|
|||||||
className,
|
className,
|
||||||
align = 'center',
|
align = 'center',
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
|
container,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||||
|
container?:
|
||||||
|
| React.ComponentProps<typeof PopoverPrimitive.Portal>['container']
|
||||||
|
| React.RefObject<HTMLElement | null>;
|
||||||
|
}) {
|
||||||
|
const portalContainer =
|
||||||
|
container && 'current' in container ? container.current : container;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal container={portalContainer}>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
align={align}
|
align={align}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const API_ROUTES = {
|
|||||||
},
|
},
|
||||||
VIDEOS: {
|
VIDEOS: {
|
||||||
UPLOAD: '/biz/api/v1/upload',
|
UPLOAD: '/biz/api/v1/upload',
|
||||||
|
MY_UPLOADS: '/biz/api/v1/videos/me',
|
||||||
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
|
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import axiosClient from '../axios/axios';
|
import axiosClient from '../axios/axios';
|
||||||
import { API_ROUTES } from '@/constants/apiRoutes';
|
import { API_ROUTES } from '@/constants/apiRoutes';
|
||||||
import { CompletedVideoResult } from '@/types';
|
import { CompletedVideoResult, PaginationParams, UploadVideosResponse } from '@/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Video Service
|
* Video Service
|
||||||
*/
|
*/
|
||||||
export const videoService = {
|
export const videoService = {
|
||||||
|
getMyUploads: async (
|
||||||
|
params?: PaginationParams,
|
||||||
|
): Promise<UploadVideosResponse> => {
|
||||||
|
const response = await axiosClient.get<UploadVideosResponse>(
|
||||||
|
API_ROUTES.VIDEOS.MY_UPLOADS,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
skip: params?.skip ?? 0,
|
||||||
|
limit: params?.limit ?? 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a video for processing
|
* Upload a video for processing
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './chainage';
|
|||||||
export * from './detection';
|
export * from './detection';
|
||||||
export * from './analysis';
|
export * from './analysis';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
|
export * from './upload';
|
||||||
export * from './auth.type';
|
export * from './auth.type';
|
||||||
export * from './permission';
|
export * from './permission';
|
||||||
export * from './role';
|
export * from './role';
|
||||||
|
|||||||
25
src/types/upload.ts
Normal file
25
src/types/upload.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user