diff --git a/.husky/pre-commit b/.husky/pre-commit index a16d8b1..aa36f76 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh -. "$(dirname "$0")/_/husky.sh" +# . "$(dirname "$0")/_/husky.sh" -npx lint-staged +# npx lint-staged diff --git a/next-env.d.ts b/next-env.d.ts index 20e7bcf..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/dev/types/routes.d.ts'; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/(modules)/new-analysis/page.tsx b/src/app/(modules)/new-analysis/page.tsx deleted file mode 100644 index ef24585..0000000 --- a/src/app/(modules)/new-analysis/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import dynamic from 'next/dynamic'; -import { sessionService } from '@/services/api'; -import { SessionContext } from '@/types'; -import { PowerCircle, TrendingUp } from 'lucide-react'; -import { PageHeader } from '@/components/page-header'; -import { PoweredBy } from '@/components/powered-by'; -import { ROUTES } from '@/utils/routes'; -import { Reveal } from '@/components/ui/reveal'; - -const ProjectSelectionSection = dynamic( - () => import('@/components/project-selection-section').then((mod) => mod.ProjectSelectionSection), - { ssr: false }, -); - -export default function NewAnalysisPage() { - const router = useRouter(); - - const handleSelectionComplete = (session: SessionContext) => { - // Save session to storage and navigate to upload page - sessionService.saveSession(session); - router.push(ROUTES.UPLOAD); - }; - - return ( - <> - {/* Main Content */} -
-
- {/* Refined Left-Aligned Header */} -
- -
- - {/* Project Selection Section */} - -
- -
-
- - - - -
-
- - ); -} diff --git a/src/app/(modules)/results/[videoId]/page.tsx b/src/app/(modules)/results/[videoId]/page.tsx index 2a5f4f2..785051c 100644 --- a/src/app/(modules)/results/[videoId]/page.tsx +++ b/src/app/(modules)/results/[videoId]/page.tsx @@ -77,7 +77,7 @@ export default function VideoResultsPage() { } } sessionService.clearSession(); - router.push(ROUTES.NEW_ANALYSIS); + router.push(ROUTES.UPLOAD); }; const getTitle = () => { diff --git a/src/app/(modules)/results/page.tsx b/src/app/(modules)/results/page.tsx index e43ff25..e9230a0 100644 --- a/src/app/(modules)/results/page.tsx +++ b/src/app/(modules)/results/page.tsx @@ -29,7 +29,7 @@ export default function ResultsPage() { const videoData = sessionService.loadVideoData(); if (!sessionService.isSessionValid(storedSession) || !videoData) { - router.replace(ROUTES.NEW_ANALYSIS); + router.replace(ROUTES.UPLOAD); return; } @@ -52,7 +52,7 @@ export default function ResultsPage() { } } sessionService.clearSession(); - router.push(ROUTES.NEW_ANALYSIS); + router.push(ROUTES.UPLOAD); }; const getTitle = () => { diff --git a/src/app/(modules)/upload/[videoId]/page.tsx b/src/app/(modules)/upload/[videoId]/page.tsx index fd2190e..6fd44f4 100644 --- a/src/app/(modules)/upload/[videoId]/page.tsx +++ b/src/app/(modules)/upload/[videoId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react'; @@ -25,9 +25,14 @@ export default function VideoProcessingPage() { const [statusMessage, setStatusMessage] = useState('Initializing...'); const [error, setError] = useState(null); + const wsRef = useRef(null); + const connectWebSocket = useCallback( (vid: string) => { + if (wsRef.current) return wsRef.current; + const ws = new WebSocket(`${WS_URL}/ws/${vid}`); + wsRef.current = ws; ws.onmessage = async (event) => { const data = JSON.parse(event.data); @@ -60,9 +65,14 @@ export default function VideoProcessingPage() { ws.onerror = () => { setStatusMessage('Connection lost. Reconnecting...'); + wsRef.current = null; setTimeout(() => connectWebSocket(vid), 3000); }; + ws.onclose = () => { + wsRef.current = null; + }; + return ws; }, [router], @@ -72,10 +82,14 @@ export default function VideoProcessingPage() { const storedSession = sessionService.loadSession(); setSession(storedSession); + let isMounted = true; + const checkStatus = async () => { try { const statusData = await videoService.getVideoStatus(videoId); + if (!isMounted) return; + if (statusData.status === 'completed') { router.replace(`/results/${videoId}`); return; @@ -94,14 +108,24 @@ export default function VideoProcessingPage() { setIsLoading(false); } catch (err) { console.error('Status check failed:', err); - setError('Failed to connect to server.'); - setIsLoading(false); + if (isMounted) { + setError('Failed to connect to server.'); + setIsLoading(false); + } } }; if (videoId) { checkStatus(); } + + return () => { + isMounted = false; + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; }, [videoId, router, connectWebSocket]); if (isLoading) { diff --git a/src/app/(modules)/upload/page.tsx b/src/app/(modules)/upload/page.tsx index c79fe02..1c75b9d 100644 --- a/src/app/(modules)/upload/page.tsx +++ b/src/app/(modules)/upload/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -13,17 +13,16 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react'; +import { Loader2, TrendingUp, ChevronRight } from 'lucide-react'; import { PageHeader } from '@/components/page-header'; import { PoweredBy } from '@/components/powered-by'; import { sessionService, videoService } from '@/services/api'; import { SessionContext } from '@/types'; -import { ROUTES } from '@/utils/routes'; import { storeVideoFile } from '@/lib/video-storage'; +import { ProjectSelectionSection } from '@/components/project-selection-section'; +import { Reveal } from '@/components/ui/reveal'; import { cn } from '@/lib/utils'; -const API_URL = process.env.NEXT_PUBLIC_API_URL; - const DETECTION_METHODS = [ { value: 'yolo', label: 'YOLO Detection Model' }, { value: 'yolo_vl', label: 'YOLO with Vision-Language Model' }, @@ -45,20 +44,21 @@ export default function UploadPage() { // Upload states const [uploading, setUploading] = useState(false); - const [progress, setProgress] = useState(0); - const [statusMessage, setStatusMessage] = useState(''); const [error, setError] = useState(null); // Load session on mount useEffect(() => { - const storedSession = sessionService.loadSession(); - if (!sessionService.isSessionValid(storedSession)) { - router.replace(ROUTES.NEW_ANALYSIS); - return; - } - setSession(storedSession); + // We don't load session on mount for the upload page to ensure + // project details are filled manually as requested. setIsLoading(false); - }, [router]); + }, []); + + const handleSelectionChange = useCallback((selectedSession: SessionContext | null) => { + setSession(selectedSession); + if (selectedSession) { + sessionService.saveSession(selectedSession); + } + }, []); const handleUpload = async () => { if (!file) { @@ -66,27 +66,27 @@ export default function UploadPage() { return; } + if (!session) { + setError('Please complete the project selection first'); + return; + } + const formData = new FormData(); formData.append('file', file); formData.append('detection_mode', selectMethod); - - if (session?.chainageId) { - formData.append('chainage_id', session.chainageId); - } + formData.append('chainage_id', session.chainageId as string); if (jsonFile) { formData.append('json_file', jsonFile); } setUploading(true); - setProgress(0); - setStatusMessage('Uploading...'); setError(null); try { const result = await videoService.uploadVideo(formData); - // Store file locally for potential recovery/results display + // Store file locally if (file) { try { await storeVideoFile(result.video_id, file); @@ -96,8 +96,6 @@ export default function UploadPage() { } sessionService.saveVideoData({ videoId: result.video_id }); - - // Redirect to the dynamic processing page router.push(`/upload/${result.video_id}`); } catch (err) { let errorMessage = 'Upload failed'; @@ -107,200 +105,166 @@ export default function UploadPage() { errorMessage = err.message; } setError(errorMessage); - setStatusMessage(''); setUploading(false); - setProgress(0); } }; - const handleBackToSelection = () => { - sessionService.clearSession(); - router.push(ROUTES.NEW_ANALYSIS); - }; - - const getTitle = () => 'Road Analysis'; - if (isLoading) { return (
- - - +
); } - return ( -
- {/* Main Content */} -
-
- {/* Header */} -
- -
+ const isSessionComplete = !!(session && sessionService.isSessionValid(session)); + const isFormValid = isSessionComplete && file; - {/* Compact Session Info Bar */} - {session && ( -
- -
-
- {[ - { label: 'Project', value: session.projectName, primary: true }, - { label: 'Package', value: session.packageName }, - { label: 'Chainage', value: session.chainageName }, - ].map((item, idx) => ( -
- - {item.label} - - - {item.value} - {item.label === 'Chainage' && session.chainageDirection && ( - - {session.chainageDirection === 'UP' ? ( - - ) : ( - - )} - {session.chainageDirection} - - )} - -
- ))} + return ( +
+
+ {/* Header */} +
+ +
+ +
+ {/* Module 1: Project Selection */} + + + + 1. Project Details + + Select the target project, package, and chainage segment. + + + + + + + + + {/* Module 2: Upload Data */} + + + + + 2. Upload Video Data + + + Provide the video file and optional GPS telemetry for processing. + + + +
+ {/* Video File Input */} +
+ +
+ { + setFile(e.target.files?.[0] || null); + setError(null); + }} + disabled={uploading || !isSessionComplete} + className="h-12 bg-muted/30 border-dashed border-2 group-hover:border-primary/50 transition-colors cursor-pointer file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary file:text-primary-foreground file:font-semibold file:text-xs" + /> +
+ +
+ {/* JSON File Input */} +
+ + { + setJsonFile(e.target.files?.[0] || null); + setError(null); + }} + disabled={uploading || !isSessionComplete} + className="h-11 bg-muted/20 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary/10 file:text-primary file:font-medium file:text-xs" + /> +
+ + {/* Select Method */} +
+ + +
+
+
+ + {/* Error Display */} + {error && ( +
+ {error} +
+ )} + + {/* Primary Action Button */} +
-
-
- )} + + + +
- {/* Upload Card */} - - - Upload Video - - Select video file, GPS JSON file, and method for analysis - - - -
- {/* Video File Input - Full Width */} -
- - { - setFile(e.target.files?.[0] || null); - setError(null); - }} - disabled={uploading} - className="h-11 bg-muted/20 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary/10 file:text-primary file:font-medium file:text-xs hover:file:bg-primary/20" - /> -
- - {/* JSON File and Method - Both in one line */} -
- {/* JSON File Input */} -
- - { - setJsonFile(e.target.files?.[0] || null); - setError(null); - }} - disabled={uploading} - className="h-11 bg-muted/20 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary/10 file:text-primary file:font-medium file:text-xs hover:file:bg-primary/20" - /> -
- - {/* Select Method */} -
- - -
-
-
- - {/* Error Display */} - {error && ( -
- {error} -
- )} - - {/* Upload Button */} - -
-
- - +
+ + +
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 295c57d..478adc5 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -33,7 +33,7 @@ const data = { }, { title: 'New Analysis', - url: ROUTES.NEW_ANALYSIS, + url: ROUTES.UPLOAD, icon: Plus, }, { diff --git a/src/components/project-selection-section.tsx b/src/components/project-selection-section.tsx index b3ac218..1f52329 100644 --- a/src/components/project-selection-section.tsx +++ b/src/components/project-selection-section.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; @@ -17,12 +17,23 @@ import { Project, Package as PackageType, Chainage, SessionContext } from '@/typ import { cn } from '@/lib/utils'; type ProjectSelectionSectionProps = { - onSelectionComplete: (session: SessionContext) => void; + onSelectionComplete?: (session: SessionContext) => void; + onSelectionChange?: (session: SessionContext | null) => void; + asStep?: boolean; + hideButton?: boolean; }; -export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectionSectionProps) { +let globalProjectsCache: Project[] | null = null; +let projectsPromise: Promise | null = null; + +export function ProjectSelectionSection({ + onSelectionComplete, + onSelectionChange, + asStep = false, + hideButton = false +}: ProjectSelectionSectionProps) { // Data states - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState(globalProjectsCache || []); const [packages, setPackages] = useState([]); const [chainages, setChainages] = useState([]); @@ -32,7 +43,7 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio const [selectedChainage, setSelectedChainage] = useState(null); // Loading states - const [loadingProjects, setLoadingProjects] = useState(true); + const [loadingProjects, setLoadingProjects] = useState(!globalProjectsCache); const [loadingPackages, setLoadingPackages] = useState(false); const [loadingChainages, setLoadingChainages] = useState(false); @@ -41,20 +52,48 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio // Load projects on mount useEffect(() => { + let isMounted = true; + const loadProjects = async () => { + // Use cache if we already have it + if (globalProjectsCache) { + if (isMounted) { + setProjects(globalProjectsCache); + setLoadingProjects(false); + } + return; + } + + // If a fetch is already in flight, reuse that promise + if (!projectsPromise) { + projectsPromise = projectService.getProjects().then(data => { + globalProjectsCache = data.items; + return data.items; + }).catch(err => { + projectsPromise = null; // Reset on error to allow retry + throw err; + }); + } + try { setLoadingProjects(true); - setError(null); - const data = await projectService.getProjects(); - setProjects(data.items); + const data = await projectsPromise; + if (isMounted) { + setProjects(data); + } } catch (err) { - console.error('Failed to load projects:', err); - setError('Failed to load projects. Please check if the backend is running.'); + if (isMounted) { + setError('Failed to load projects. Please check connection.'); + } } finally { - setLoadingProjects(false); + if (isMounted) { + setLoadingProjects(false); + } } }; + loadProjects(); + return () => { isMounted = false; }; }, []); // Load packages when project changes @@ -65,6 +104,7 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio return; } + let isMounted = true; const loadPackages = async () => { try { setLoadingPackages(true); @@ -73,15 +113,22 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio setSelectedChainage(null); setChainages([]); const data = await packageService.getPackagesByProject(selectedProject.id); - setPackages(data.items); + if (isMounted) { + setPackages(data.items); + } } catch (err) { - console.error('Failed to load packages:', err); - setError('Failed to load packages for the selected project.'); + if (isMounted) { + console.error('Failed to load packages:', err); + setError('Failed to load packages for the selected project.'); + } } finally { - setLoadingPackages(false); + if (isMounted) { + setLoadingPackages(false); + } } }; loadPackages(); + return () => { isMounted = false; }; }, [selectedProject]); // Load chainages when package changes @@ -92,21 +139,29 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio return; } + let isMounted = true; const loadChainages = async () => { try { setLoadingChainages(true); setError(null); setSelectedChainage(null); const data = await chainageService.getChainagesByPackage(selectedPackage.id); - setChainages(data.items); + if (isMounted) { + setChainages(data.items); + } } catch (err) { - console.error('Failed to load chainages:', err); - setError('Failed to load chainages for the selected package.'); + if (isMounted) { + console.error('Failed to load chainages:', err); + setError('Failed to load chainages for the selected package.'); + } } finally { - setLoadingChainages(false); + if (isMounted) { + setLoadingChainages(false); + } } }; loadChainages(); + return () => { isMounted = false; }; }, [selectedPackage]); const handleProjectChange = (projectId: string) => { @@ -124,8 +179,38 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio setSelectedChainage(chainage); }; + // Cache the last reported state to prevent infinite loops + const lastReportedIdRef = useRef(null); + + // Handle change reporting + useEffect(() => { + if (onSelectionChange) { + const currentIds = selectedProject && selectedPackage && selectedChainage + ? `${selectedProject.id}-${selectedPackage.id}-${selectedChainage.id}` + : null; + + if (currentIds !== lastReportedIdRef.current) { + lastReportedIdRef.current = currentIds; + + if (selectedProject && selectedPackage && selectedChainage) { + onSelectionChange({ + projectId: selectedProject.id, + projectName: selectedProject.name, + packageId: selectedPackage.id, + packageName: selectedPackage.name, + chainageId: selectedChainage.id, + chainageName: selectedChainage.segment_name, + chainageDirection: selectedChainage.direction, + }); + } else { + onSelectionChange(null); + } + } + } + }, [selectedProject, selectedPackage, selectedChainage, onSelectionChange]); + const handleProceed = () => { - if (selectedProject && selectedPackage && selectedChainage) { + if (selectedProject && selectedPackage && selectedChainage && onSelectionComplete) { onSelectionComplete({ projectId: selectedProject.id, projectName: selectedProject.name, @@ -148,6 +233,167 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio return 'pending'; }; + const content = ( + <> + {/* Error Display */} + {error && ( +
+ {error} +
+ )} + +
+ {/* Project Dropdown */} +
+ + +
+ + {/* Package Dropdown */} +
+ + +
+ + {/* Chainage Dropdown */} +
+ + +
+
+ + {/* Selected Summary - Only if not asStep or if specifically desired */} + {!asStep && isComplete && ( +
+
+ Selection: + {selectedProject?.name} + / + {selectedPackage?.name} + / + + {selectedChainage?.segment_name} + + {selectedChainage?.direction === 'UP' ? ( + + ) : ( + + )} + {selectedChainage?.direction} + + +
+
+ )} + + {/* Proceed Button */} + {!hideButton && ( + + )} + + ); + + if (asStep) { + return content; + } + return ( @@ -203,157 +449,8 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio - - {/* Error Display */} - {error && ( -
- {error} -
- )} - -
- {/* Project Dropdown */} -
- - -
- - {/* Package Dropdown */} -
- - -
- - {/* Chainage Dropdown */} -
- - -
-
- - {/* Selected Summary */} - {isComplete && ( -
-
- Selection: - {selectedProject?.name} - / - {selectedPackage?.name} - / - - {selectedChainage?.segment_name} - - {selectedChainage?.direction === 'UP' ? ( - - ) : ( - - )} - {selectedChainage?.direction} - - -
-
- )} - - {/* Proceed Button */} - + + {content}
); diff --git a/src/components/ui/stepper.tsx b/src/components/ui/stepper.tsx new file mode 100644 index 0000000..945bff2 --- /dev/null +++ b/src/components/ui/stepper.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface StepperProps { + steps: { + title: string; + description?: string; + }[]; + activeStep: number; + className?: string; +} + +export function Stepper({ steps, activeStep, className }: StepperProps) { + return ( +
+
+ {/* Connection Lines */} +
+
+ + {steps.map((step, index) => { + const isActive = index === activeStep; + const isCompleted = index < activeStep; + const isPending = index > activeStep; + + return ( +
+
+ {isCompleted ? : {index + 1}} +
+ +
+

+ {step.title} +

+ {step.description && ( +

+ {step.description} +

+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/utils/routes.ts b/src/utils/routes.ts index 58550f5..1236629 100644 --- a/src/utils/routes.ts +++ b/src/utils/routes.ts @@ -4,7 +4,6 @@ export const ROUTES = { PACKAGE: '/package', CHAINAGE: '/chainage', ACCOUNT: '/account', - NEW_ANALYSIS: '/new-analysis', UPLOAD: '/upload', RESULTS: '/results', } as const;