Compare commits
2 Commits
d8fa02dc5d
...
a18fff4d6f
| Author | SHA1 | Date | |
|---|---|---|---|
| a18fff4d6f | |||
| dff9e2222f |
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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
|
||||
// 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 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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
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({
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">Location</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the project hierarchy for this analysis job.
|
||||
Select the project hierarchy for this upload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +128,7 @@ export function LocationSection({
|
||||
value={projectId}
|
||||
onValueChange={handleProjectChange}
|
||||
disabled={disabled}
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -126,6 +139,7 @@ export function LocationSection({
|
||||
projectId={projectId}
|
||||
enabled={Boolean(projectId)}
|
||||
disabled={disabled || !projectId}
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -136,10 +150,10 @@ export function LocationSection({
|
||||
packageId={packageId}
|
||||
enabled={Boolean(packageId)}
|
||||
disabled={disabled || !packageId}
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
</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';
|
||||
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
import { FormField, SelectPopover } from '@/components/form';
|
||||
|
||||
const options = [
|
||||
@@ -10,35 +12,38 @@ const options = [
|
||||
{ value: 'combined', label: 'Road Defect Detection with vl' },
|
||||
] as const;
|
||||
|
||||
interface AnalysisSettingsSectionProps {
|
||||
interface UploadSettingsSectionProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
portalContainer?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function AnalysisSettingsSection({
|
||||
export function UploadSettingsSection({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
}: AnalysisSettingsSectionProps) {
|
||||
portalContainer,
|
||||
}: UploadSettingsSectionProps) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Analysis Settings
|
||||
Upload Settings
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the AI model workflow for this job.
|
||||
Select the detection workflow for this upload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<FormField id="analysis-method" label="Analysis Method">
|
||||
<FormField id="detection-mode" label="Detection Mode">
|
||||
<SelectPopover
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
portalContainer={portalContainer}
|
||||
placeholder="Select method"
|
||||
searchPlaceholder="Search methods"
|
||||
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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, Play, TrendingUp } 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 { AnalysisSettingsSection } from './components/AnalysisSettingsSection';
|
||||
import { InputDataSection } from './components/InputDataSection';
|
||||
import { LocationSection } from './components/LocationSection';
|
||||
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<SessionContext | null>(null);
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [gpsFile, setGpsFile] = useState<File | null>(null);
|
||||
const [analysisMethod, setAnalysisMethod] = useState('yolo');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<UploadVideo | null>(null);
|
||||
const [processedUpload, setProcessedUpload] = useState<UploadVideo | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!session?.chainageId || !videoFile || !gpsFile) {
|
||||
setError('Complete location and input data before starting analysis.');
|
||||
const uploads = uploadsQuery.data?.items ?? [];
|
||||
const total = uploadsQuery.data?.total ?? 0;
|
||||
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;
|
||||
}
|
||||
handleViewDetails(upload);
|
||||
},
|
||||
[handleViewDetails, handleViewProcessedVideo],
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', videoFile);
|
||||
formData.append('detection_mode', analysisMethod);
|
||||
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');
|
||||
}
|
||||
};
|
||||
const columns = useUploadColumns({
|
||||
onViewDetails: handleViewDetails,
|
||||
onViewProcessedVideo: handleViewProcessedVideo,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<>
|
||||
<main className="space-y-5">
|
||||
<PageHeader
|
||||
title="Create Analysis Job"
|
||||
description="Configure location, upload data, and start AI analysis."
|
||||
icon={TrendingUp}
|
||||
title="My Uploads"
|
||||
description="View and manage uploaded road inspection videos."
|
||||
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 />
|
||||
|
||||
<AnalysisSettingsSection
|
||||
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">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
className="w-full gap-2 md:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Starting analysis...
|
||||
</>
|
||||
<ViewSwitcher value={viewMode} onValueChange={setViewMode} />
|
||||
</div>
|
||||
|
||||
{viewMode === 'card' ? (
|
||||
<UploadCardGrid
|
||||
uploads={uploads}
|
||||
isLoading={isLoading}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
onUploadNew={() => setIsCreateOpen(true)}
|
||||
onOpenUpload={handleOpenUpload}
|
||||
onViewDetails={handleViewDetails}
|
||||
onViewProcessedVideo={handleViewProcessedVideo}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-4" />
|
||||
Start Analysis
|
||||
</>
|
||||
<UploadTable
|
||||
columns={columns}
|
||||
uploads={uploads}
|
||||
isLoading={isLoading}
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
onRowClick={handleOpenUpload}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||
<UploadDetailsDialog
|
||||
upload={detailsUpload}
|
||||
open={Boolean(detailsUpload)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailsUpload(null);
|
||||
}}
|
||||
/>
|
||||
<ProcessedVideoDialog
|
||||
upload={processedUpload}
|
||||
open={Boolean(processedUpload)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setProcessedUpload(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function BreadcrumbBasic() {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split('/').filter((segment) => segment !== '');
|
||||
|
||||
// Helper to format segment (e.g., "new-analysis" -> "New Analysis")
|
||||
// Helper to format segment (e.g., "upload-history" -> "Upload History")
|
||||
const formatSegment = (segment: string) => {
|
||||
return segment
|
||||
.split('-')
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface DataTableProps<TData, TValue> {
|
||||
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<TData, TValue>({
|
||||
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<TData, TValue>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
className={cn(onRowClick && 'cursor-pointer')}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const truncate = shouldTruncateCell(cell.column);
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
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({
|
||||
<ChevronDown className="size-4 opacity-60" />
|
||||
</Button>
|
||||
</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">
|
||||
<Input
|
||||
value={search}
|
||||
|
||||
@@ -21,10 +21,18 @@ function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
container,
|
||||
...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 (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Portal container={portalContainer}>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const menuItems: MenuItem[] = [
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'New Analysis',
|
||||
title: 'Upload',
|
||||
path: ROUTES.UPLOAD,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@ export const API_ROUTES = {
|
||||
},
|
||||
VIDEOS: {
|
||||
UPLOAD: '/biz/api/v1/upload',
|
||||
MY_UPLOADS: '/biz/api/v1/videos/me',
|
||||
RESULTS: (id: string) => `/biz/api/v1/results/${id}/completed`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -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<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
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
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