refactor: refactor the color schema

This commit is contained in:
2026-03-17 18:26:53 +05:30
parent 8ddd2df981
commit caf527f584
59 changed files with 3617 additions and 2642 deletions

View File

@@ -0,0 +1,590 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertCircle,
Activity,
MapPin as MapPinIcon,
BarChart3,
AlertTriangle,
Zap,
PencilLine,
CheckCircle2,
} from "lucide-react";
import { StatsCard } from "@/components/dashboard/stats-card";
import { CompactProjectSelector } from "@/components/dashboard/compact-project-selector";
import { FilterSelector } from "@/components/dashboard/filter-selector";
import { DetectionDonutChart } from "@/components/dashboard/detection-donut-chart";
import { LocationBarChart } from "@/components/dashboard/location-bar-chart";
import { DashboardMap } from "@/components/dashboard/dashboard-map";
import { PageHeader } from "@/components/page-header";
import { fetchProjects, type Project } from "@/lib/api";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Filter } from "lucide-react";
import { DashboardSkeleton } from "@/components/dashboard/dashboard-skeleton"
import { PoweredBy } from "@/components/powered-by";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
interface ProjectSummary {
project: {
id: string;
name: string;
corridor_name: string | null;
state: string | null;
};
packages: {
[key: string]: {
package_id: string;
region: string | null;
locations: {
[key: string]: {
location_id: string;
chainage: string | null;
detection_count: number;
detections: Array<{
id: number;
type: string;
class: string;
confidence: number;
latitude: number;
longitude: number;
}>;
};
};
};
};
}
interface DetectionStats {
totalDefectedSignboard: number;
totalPothole: number;
totalRoadCrack: number;
totalDamagedRoadMarking: number;
totalGoodSignboard: number;
totalRoadDamage: number;
locationData: Array<{
name: string;
defected_sign_board: number;
pothole: number;
road_crack: number;
damaged_road_marking: number;
good_sign_board: number;
total: number;
}>;
}
function calculateStats(summary: ProjectSummary | null): DetectionStats {
if (!summary) {
return {
totalDefectedSignboard: 0,
totalPothole: 0,
totalRoadCrack: 0,
totalDamagedRoadMarking: 0,
totalGoodSignboard: 0,
totalRoadDamage: 0,
locationData: [],
};
}
let totalDefectedSignboard = 0;
let totalPothole = 0;
let totalRoadCrack = 0;
let totalDamagedRoadMarking = 0;
let totalGoodSignboard = 0;
let totalRoadDamage = 0;
const locationData: DetectionStats["locationData"] = [];
for (const pkg of Object.values(summary.packages || {})) {
for (const [locName, loc] of Object.entries(pkg.locations || {})) {
let locDefectedSignboard = 0;
let locPothole = 0;
let locRoadCrack = 0;
let locDamagedRoadMarking = 0;
let locGoodSignboard = 0;
for (const detection of loc.detections || []) {
const type = detection.type.toLowerCase();
if (type === "defected_sign_board") {
locDefectedSignboard++;
totalDefectedSignboard++;
} else if (type === "pothole") {
locPothole++;
totalPothole++;
} else if (type === "road_crack") {
locRoadCrack++;
totalRoadCrack++;
} else if (type === "damaged_road_marking") {
locDamagedRoadMarking++;
totalDamagedRoadMarking++;
} else if (type === "good_sign_board") {
locGoodSignboard++;
totalGoodSignboard++;
}
}
const locTotalDamage =
locDefectedSignboard +
locPothole +
locRoadCrack +
locDamagedRoadMarking;
totalRoadDamage +=
locDefectedSignboard > 0 ||
locPothole > 0 ||
locRoadCrack > 0 ||
locDamagedRoadMarking > 0
? 1
: 0; // This logic might need refinement based on "total_road_damage" definition
if (
locDefectedSignboard > 0 ||
locPothole > 0 ||
locRoadCrack > 0 ||
locDamagedRoadMarking > 0 ||
locGoodSignboard > 0
) {
const shortName =
locName.length > 20 ? locName.substring(0, 20) + "..." : locName;
locationData.push({
name: shortName,
defected_sign_board: locDefectedSignboard,
pothole: locPothole,
road_crack: locRoadCrack,
damaged_road_marking: locDamagedRoadMarking,
good_sign_board: locGoodSignboard,
total:
locDefectedSignboard +
locPothole +
locRoadCrack +
locDamagedRoadMarking +
locGoodSignboard,
});
}
}
}
// Recalculate totalRoadDamage based on backends unique count
// But since we are aggregating from locations, we just sum them up or use a simpler metric
// The user's JSON shows "total_road_damage": 9 which is sum of 2+6+0+1
totalRoadDamage =
totalDefectedSignboard +
totalPothole +
totalRoadCrack +
totalDamagedRoadMarking;
return {
totalDefectedSignboard,
totalPothole,
totalRoadCrack,
totalDamagedRoadMarking,
totalGoodSignboard,
totalRoadDamage,
locationData,
};
}
export default function DashboardPage() {
const [isLoading, setIsLoading] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(
null,
);
const [stats, setStats] = useState<DetectionStats>({
totalDefectedSignboard: 0,
totalPothole: 0,
totalRoadCrack: 0,
totalDamagedRoadMarking: 0,
totalGoodSignboard: 0,
totalRoadDamage: 0,
locationData: [],
});
const [error, setError] = useState<string | null>(null);
// Load projects on mount
useEffect(() => {
const loadProjects = async () => {
try {
setIsLoading(true);
setError(null);
const projectsData = await fetchProjects();
setProjects(projectsData);
if (projectsData.length > 0) {
setSelectedProjectId(projectsData[0].id);
}
} catch (err) {
console.error("Failed to load projects:", err);
setError(
"Failed to load projects. Please check if the backend is running.",
);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 1500);
}
};
loadProjects();
}, []);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
// Extract packages from project summary
const packages = projectSummary
? Object.keys(projectSummary.packages || {}).map((pkgName) => ({
id: pkgName,
name: pkgName,
}))
: [];
// Extract locations from selected package
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(
null,
);
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(
null,
);
const locations =
projectSummary && selectedPackageId && selectedPackageId !== "all"
? Object.keys(
projectSummary.packages[selectedPackageId]?.locations || {},
).map((locName) => ({
id: locName,
name: locName,
}))
: [];
// Load project summary when project changes
useEffect(() => {
if (!selectedProjectId) {
setProjectSummary(null);
return;
}
const loadProjectSummary = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(
`${API_URL}/summary/projects/${selectedProjectId}`,
{
headers: {
"Content-Type": "application/json",
"ngrok-skip-browser-warning": "true",
},
},
);
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
const summary: ProjectSummary = await response.json();
setProjectSummary(summary);
} catch (err) {
console.error("Failed to load project summary:", err);
setError("Failed to load project summary.");
setProjectSummary(null);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 1500);
}
};
loadProjectSummary();
}, [selectedProjectId]);
// Reset package and location when project changes
useEffect(() => {
setSelectedPackageId(null);
setSelectedLocationId(null);
}, [selectedProjectId]);
// Reset location when package changes
useEffect(() => {
setSelectedLocationId(null);
}, [selectedPackageId]);
// Filter stats based on selections
useEffect(() => {
if (!projectSummary) {
setStats({
totalDefectedSignboard: 0,
totalPothole: 0,
totalRoadCrack: 0,
totalDamagedRoadMarking: 0,
totalGoodSignboard: 0,
totalRoadDamage: 0,
locationData: [],
});
return;
}
let totalDefectedSignboard = 0;
let totalPothole = 0;
let totalRoadCrack = 0;
let totalDamagedRoadMarking = 0;
let totalGoodSignboard = 0;
const locationData: DetectionStats["locationData"] = [];
const packagesToProcess =
selectedPackageId && selectedPackageId !== "all"
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
: projectSummary.packages || {};
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
const locationsToProcess =
selectedLocationId && selectedLocationId !== "all"
? { [selectedLocationId]: pkg.locations[selectedLocationId] }
: pkg.locations || {};
for (const [locName, loc] of Object.entries(locationsToProcess)) {
if (!loc) continue;
let locDefectedSignboard = 0;
let locPothole = 0;
let locRoadCrack = 0;
let locDamagedRoadMarking = 0;
let locGoodSignboard = 0;
for (const detection of loc.detections || []) {
const type = detection.type.toLowerCase();
if (type === "defected_sign_board") {
locDefectedSignboard++;
totalDefectedSignboard++;
} else if (type === "pothole") {
locPothole++;
totalPothole++;
} else if (type === "road_crack") {
locRoadCrack++;
totalRoadCrack++;
} else if (type === "damaged_road_marking") {
locDamagedRoadMarking++;
totalDamagedRoadMarking++;
} else if (type === "good_sign_board") {
locGoodSignboard++;
totalGoodSignboard++;
}
}
if (
locDefectedSignboard > 0 ||
locPothole > 0 ||
locRoadCrack > 0 ||
locDamagedRoadMarking > 0 ||
locGoodSignboard > 0
) {
const shortName =
locName.length > 20 ? locName.substring(0, 20) + "..." : locName;
locationData.push({
name: shortName,
defected_sign_board: locDefectedSignboard,
pothole: locPothole,
road_crack: locRoadCrack,
damaged_road_marking: locDamagedRoadMarking,
good_sign_board: locGoodSignboard,
total:
locDefectedSignboard +
locPothole +
locRoadCrack +
locDamagedRoadMarking +
locGoodSignboard,
});
}
}
}
setStats({
totalDefectedSignboard,
totalPothole,
totalRoadCrack,
totalDamagedRoadMarking,
totalGoodSignboard,
totalRoadDamage:
totalDefectedSignboard +
totalPothole +
totalRoadCrack +
totalDamagedRoadMarking,
locationData,
});
}, [projectSummary, selectedPackageId, selectedLocationId]);
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
{/* Main Content */}
<main className="min-h-screen">
<div className="p-4 px-8 max-w-340 mx-auto">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<PageHeader
title="Dashboard"
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
icon={BarChart3}
/>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2 font-semibold">
<Filter className="h-4 w-4" />
<span>Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
<div className="p-4 border-b bg-muted/30">
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
<Filter className="h-4 w-4 text-primary" />
Filter Analysis
</h3>
<p className="text-xs mt-1 text-muted-foreground">Refine your view by project, package, or location</p>
</div>
<div>
<FilterSelector
projects={projects}
selectedProjectId={selectedProjectId}
selectedPackageId={selectedPackageId}
selectedLocationId={selectedLocationId}
onProjectChange={setSelectedProjectId}
onPackageChange={setSelectedPackageId}
onLocationChange={setSelectedLocationId}
packages={packages}
locations={locations}
isLoading={isLoading}
/>
</div>
</PopoverContent>
</Popover>
</div>
{/* Error Display */}
{error && (
<div className="mb-4 flex items-center gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{error}</p>
</div>
)}
{isLoading && !projectSummary ? (
<DashboardSkeleton />
) : (
<>
{/* Stats Cards - Top Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<StatsCard
title="Total Road Damage"
subtitle="Combined damage detections"
value={stats.totalRoadDamage}
icon={Activity}
/>
<StatsCard
title="Potholes"
subtitle="Surface depressions"
value={stats.totalPothole}
icon={AlertCircle}
/>
<StatsCard
title="Defected Signboards"
subtitle="Damaged traffic signs"
value={stats.totalDefectedSignboard}
icon={AlertTriangle}
/>
<StatsCard
title="Road Cracks"
subtitle="Surface fissures"
value={stats.totalRoadCrack}
icon={Zap}
/>
<StatsCard
title="Damaged Markings"
subtitle="Worn road lines"
value={stats.totalDamagedRoadMarking}
icon={PencilLine}
/>
<StatsCard
title="Good Signboards"
subtitle="Informational markers"
value={stats.totalGoodSignboard}
icon={CheckCircle2}
/>
</div>
{/* Charts Row - Side by Side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
{/* Left Chart - Detection Distribution */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="p-2 rounded-md bg-secondary text-secondary-foreground">
<BarChart3 className="h-5 w-5" />
</div>
<span className="text-xl">
Detection Distribution
</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<DetectionDonutChart
defectedSignboard={stats.totalDefectedSignboard}
pothole={stats.totalPothole}
roadCrack={stats.totalRoadCrack}
damagedRoadMarking={stats.totalDamagedRoadMarking}
goodSignboard={stats.totalGoodSignboard}
/>
</CardContent>
</Card>
{/* Right Chart - Location Bar Chart */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="p-2 rounded-md bg-secondary text-secondary-foreground">
<MapPinIcon className="h-5 w-5" />
</div>
<span className="text-xl">Detections by Location</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<LocationBarChart
data={stats.locationData.map((loc) => ({
name: loc.name,
defected_sign_board: loc.defected_sign_board,
pothole: loc.pothole,
road_crack: loc.road_crack,
damaged_road_marking: loc.damaged_road_marking,
good_sign_board: loc.good_sign_board,
total: loc.total,
}))}
/>
</CardContent>
</Card>
</div>
{/* Map - Full Width Below Charts */}
<div>
<DashboardMap
selectedProjectId={selectedProjectId}
selectedPackageId={selectedPackageId}
selectedLocationId={selectedLocationId}
projectSummary={projectSummary}
/>
</div>
</>
)}
<PoweredBy />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { AppSidebar } from "@/components/app-sidebar";
import { ModeToggle } from "@/components/mode-toogle";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import React from "react";
const ModulesLayout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<SidebarProvider>
<AppSidebar />
<main className="flex flex-1 flex-col p-4 pt-0 w-full h-screen overflow-hidden">
<div className="flex gap-3 items-center mt-2 sticky top-0 bg-background z-50 py-2">
<SidebarTrigger />
<ModeToggle />
</div>
<div className="flex-1 py-2 overflow-auto">{children}</div>
</main>
</SidebarProvider>
);
};
export default ModulesLayout;

View File

@@ -0,0 +1,612 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Loader2, CheckCircle2, MapPin, Navigation, Milestone } from "lucide-react"
import { DataTable } from "@/components/data-table"
import { PageHeader } from "@/components/page-header"
import { PoweredBy } from "@/components/powered-by"
import {
fetchProjects,
fetchPackagesByProject,
fetchAllLocations,
fetchAllPackages,
createLocation,
updateLocation,
deleteLocation,
type Project,
type Package as PackageType,
type Location,
type LocationCreate,
type LocationUpdate
} from "@/lib/api"
import { ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
export default function LocationPage() {
const [locations, setLocations] = useState<Location[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [packages, setPackages] = useState<PackageType[]>([])
const [allPackages, setAllPackages] = useState<PackageType[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadingProjects, setLoadingProjects] = useState(false)
const [loadingPackages, setLoadingPackages] = useState(false)
// Pagination state
const [skip, setSkip] = useState(0)
const [limit, setLimit] = useState(10)
// Editing state
const [isEditing, setIsEditing] = useState(false)
const [currentLocation, setCurrentLocation] = useState<Location | null>(null)
// Form fields
const [selectedProjectId, setSelectedProjectId] = useState("")
const [selectedPackageId, setSelectedPackageId] = useState("")
const [segmentName, setSegmentName] = useState("")
const [chainageStartKm, setChainageStartKm] = useState("")
const [chainageEndKm, setChainageEndKm] = useState("")
const [startLat, setStartLat] = useState("")
const [startLng, setStartLng] = useState("")
const [endLat, setEndLat] = useState("")
const [endLng, setEndLng] = useState("")
// Load locations and projects
const loadLocations = async (currentSkip = skip, currentLimit = limit) => {
try {
setIsLoading(true)
setError(null)
const data = await fetchAllLocations({ skip: currentSkip, limit: currentLimit })
setLocations(data)
} catch (err) {
setError("Failed to load locations. Please check if the backend is running.")
} finally {
setIsLoading(false)
}
}
const loadProjects = async () => {
try {
setLoadingProjects(true)
const data = await fetchProjects({ skip: 0, limit: 1000 })
setProjects(data)
} catch (err) {
setError("Failed to load projects.")
} finally {
setLoadingProjects(false)
}
}
const loadAllPackages = async () => {
try {
const data = await fetchAllPackages({ skip: 0, limit: 1000 })
setAllPackages(data)
} catch (err) {
console.error("Failed to load all packages")
}
}
useEffect(() => {
loadLocations(skip, limit)
loadProjects()
loadAllPackages()
}, [skip, limit])
// Load packages when project changes
useEffect(() => {
if (!selectedProjectId) {
setPackages([])
if (!isEditing) setSelectedPackageId("")
return
}
const loadPackagesForProject = async () => {
try {
setLoadingPackages(true)
if (!isEditing) setSelectedPackageId("")
const data = await fetchPackagesByProject(selectedProjectId, { skip: 0, limit: 1000 })
setPackages(data)
} catch (err) {
setError("Failed to load packages for the selected project.")
} finally {
setLoadingPackages(false)
}
}
loadPackagesForProject()
}, [selectedProjectId, isEditing])
const resetForm = () => {
setSelectedProjectId("")
setSelectedPackageId("")
setSegmentName("")
setChainageStartKm("")
setChainageEndKm("")
setStartLat("")
setStartLng("")
setEndLat("")
setEndLng("")
setError(null)
setIsEditing(false)
setCurrentLocation(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedPackageId && !isEditing) {
setError("Please select a project and package first")
return
}
if (!segmentName.trim()) {
setError("Segment name is required")
return
}
if (!startLat || !startLng || !endLat || !endLng) {
setError("All GPS coordinates are required for locations")
return
}
setIsSubmitting(true)
setError(null)
try {
if (isEditing && currentLocation) {
const data: LocationUpdate = {
segment_name: segmentName.trim(),
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : null,
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : null,
start_lat: parseFloat(startLat),
start_lng: parseFloat(startLng),
end_lat: parseFloat(endLat),
end_lng: parseFloat(endLng),
}
await updateLocation(currentLocation.id, data)
toast.success("Location updated successfully!")
} else {
const data: LocationCreate = {
package_id: selectedPackageId,
segment_name: segmentName.trim(),
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : null,
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : null,
start_lat: parseFloat(startLat),
start_lng: parseFloat(startLng),
end_lat: parseFloat(endLat),
end_lng: parseFloat(endLng),
}
await createLocation(data)
toast.success("Location created successfully!")
}
// Refresh locations list
await loadLocations()
// Close modal and reset form immediately
setIsModalOpen(false)
resetForm()
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} location`)
} finally {
setIsSubmitting(false)
}
}
const handleEdit = (location: Location) => {
setIsEditing(true)
setCurrentLocation(location)
// Find project for this package
const pkg = allPackages.find(p => p.id === location.package_id)
if (pkg) {
setSelectedProjectId(pkg.project_id)
setSelectedPackageId(location.package_id)
}
setSegmentName(location.segment_name || "")
setChainageStartKm(location.chainage_start_km?.toString() || "")
setChainageEndKm(location.chainage_end_km?.toString() || "")
setStartLat(location.start_lat.toString())
setStartLng(location.start_lng.toString())
setEndLat(location.end_lat.toString())
setEndLng(location.end_lng.toString())
setIsModalOpen(true)
}
const handleDelete = async (location: Location) => {
if (!confirm(`Are you sure you want to delete location "${location.segment_name}"?`)) return
try {
setIsLoading(true)
await deleteLocation(location.id)
toast.success("Location deleted successfully!")
await loadLocations()
} catch (err) {
setError("Failed to delete location")
} finally {
setIsLoading(false)
}
}
const getPackageName = (packageId: string) => {
return allPackages.find(p => p.id === packageId)?.name || packageId
}
const isFormComplete = selectedPackageId && segmentName.trim() && startLat && startLng && endLat && endLng
const columns: ColumnDef<Location>[] = [
{
accessorKey: "segment_name",
header: "Segment Name",
cell: ({ row }) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.segment_name}</div>
)
},
{
accessorKey: "package_id",
header: "Package",
cell: ({ row }) => getPackageName(row.original.package_id)
},
{
accessorKey: "project",
header: "Project",
cell: ({ row }) => {
const location = row.original
const pkg = allPackages.find(p => p.id === location.package_id)
const project = projects.find(p => p.id === pkg?.project_id)
return project?.name || "—"
}
},
{
accessorKey: "chainage",
header: "Chainage (km)",
cell: ({ row }) => {
const location = row.original
if (location.chainage_start_km !== null && location.chainage_end_km !== null) {
return (
<span className="px-2 py-0.5 rounded-full bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 font-semibold border border-amber-100 dark:border-amber-800 whitespace-nowrap">
{location.chainage_start_km} - {location.chainage_end_km}
</span>
)
}
return "—"
}
},
{
accessorKey: "start_gps",
header: "Start GPS",
cell: ({ row }) => (
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 whitespace-nowrap">
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
</span>
)
},
{
accessorKey: "end_gps",
header: "End GPS",
cell: ({ row }) => (
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 whitespace-nowrap">
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
</span>
)
},
]
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
<main className="min-h-screen relative overflow-hidden">
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
{/* Refined Header */}
<div className="mb-8">
<PageHeader
title="Location Management"
description="Manage road segment locations"
icon={MapPin}
/>
</div>
{/* Error Message */}
{error && !isModalOpen && (
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
</div>
)}
{/* Data Table */}
<div>
<DataTable
title="Locations"
data={locations}
columns={columns}
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
onEdit={handleEdit}
onDelete={handleDelete}
addButtonText="Add New Location"
isLoading={isLoading}
pagination={{
skip,
limit,
onPageChange: setSkip,
onLimitChange: (newLimit) => {
setLimit(newLimit);
setSkip(0); // Reset skip when limit changes
}
}}
/>
</div>
<PoweredBy />
</div>
</main>
{/* Modal Dialog */}
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
<DialogContent
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
<MapPin className="h-5 w-5" />
</div>
{isEditing ? 'Edit Location Details' : 'Create New Location'}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditing ? 'Update the technical specifications for your road segment location.' : 'Select a project & package, then provide the essential location data.'}
</DialogDescription>
</DialogHeader>
{/* Error Message in Modal */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{!isEditing && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Step 1: Select Project */}
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="w-5 h-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold uppercase">
01
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Select Project</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground text-xs">Loading...</span>
</div>
) : (
<SelectValue placeholder="Choose a project..." />
)}
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.id} value={project.id} className="py-2.5">
<span className="font-semibold">{project.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Step 2: Select Package */}
<div className={`space-y-3 ${selectedProjectId ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
<div className="flex items-center gap-2.5">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedProjectId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
02
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Select Package</p>
</div>
<Select value={selectedPackageId} onValueChange={setSelectedPackageId} disabled={!selectedProjectId}>
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
{loadingPackages ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground text-xs">Loading...</span>
</div>
) : (
<SelectValue placeholder={selectedProjectId ? "Choose a package..." : "Select project first"} />
)}
</SelectTrigger>
<SelectContent>
{packages.map(pkg => (
<SelectItem key={pkg.id} value={pkg.id} className="py-2.5">
<span className="font-semibold">{pkg.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Step 3: Location Details */}
<div className={`space-y-4 ${selectedPackageId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
{!isEditing && (
<div className="flex items-center gap-2.5">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedPackageId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
03
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Location Information</p>
</div>
)}
{/* Segment Name & Chainage Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label htmlFor="segment" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
Segment Name <span className="text-destructive">*</span>
</Label>
<Input
id="segment"
value={segmentName}
onChange={(e) => setSegmentName(e.target.value)}
placeholder="e.g. Mumbai to Pune"
className="h-11 bg-muted/20 border-border/60"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ch-start" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
Start (km)
</Label>
<Input
id="ch-start"
type="number"
step="any"
min="0"
value={chainageStartKm}
onChange={(e) => setChainageStartKm(e.target.value)}
placeholder="0.0"
className="h-11 bg-muted/20 border-border/60 text-sm font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ch-end" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
End (km)
</Label>
<Input
id="ch-end"
type="number"
step="any"
min="0"
value={chainageEndKm}
onChange={(e) => setChainageEndKm(e.target.value)}
placeholder="1.0"
className="h-11 bg-muted/20 border-border/60 text-sm font-mono"
/>
</div>
</div>
</div>
{/* GPS Coordinates Grid */}
<div className="grid grid-cols-2 gap-8 pt-4">
{/* Start Point */}
<div className="space-y-4">
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 opacity-60" /> START POINT
</p>
<div className="grid grid-cols-1 gap-3">
<div className="flex items-center gap-3">
<Label htmlFor="s-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lat</Label>
<Input
id="s-lat"
type="number"
step="any"
value={startLat}
onChange={(e) => setStartLat(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
required
/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="s-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lng</Label>
<Input
id="s-lng"
type="number"
step="any"
value={startLng}
onChange={(e) => setStartLng(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
required
/>
</div>
</div>
</div>
{/* End Point */}
<div className="space-y-4">
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 opacity-60" /> END POINT
</p>
<div className="grid grid-cols-1 gap-3">
<div className="flex items-center gap-3">
<Label htmlFor="e-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lat</Label>
<Input
id="e-lat"
type="number"
step="any"
value={endLat}
onChange={(e) => setEndLat(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
required
/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="e-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lng</Label>
<Input
id="e-lng"
type="number"
step="any"
value={endLng}
onChange={(e) => setEndLng(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
required
/>
</div>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-4 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs border-border/80 hover:bg-muted/50"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !isFormComplete}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
</div>
) : (
<div className="flex items-center gap-2">
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
<span>{isEditing ? 'Update Location' : 'Create Location'}</span>
</div>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import { useRouter } from "next/navigation"
import dynamic from "next/dynamic"
import { type SessionContext, saveSession } from "@/lib/api"
import { TrendingUp } from "lucide-react"
import { PageHeader } from "@/components/page-header"
import { PoweredBy } from "@/components/powered-by"
import { ROUTES } from "@/utils/routes"
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
saveSession(session)
router.push(ROUTES.UPLOAD)
}
return (
<div className="min-h-screen">
{/* Main Content */}
<main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-340">
{/* Refined Left-Aligned Header */}
<div className="mb-8">
<PageHeader
title="VisionRoad Detection System"
description="Select project details to begin your AI-powered road infrastructure analysis"
icon={TrendingUp}
/>
</div>
{/* Project Selection Section */}
<div>
<ProjectSelectionSection onSelectionComplete={handleSelectionComplete} />
</div>
<PoweredBy />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,394 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Loader2, CheckCircle2, Package, FolderKanban, Globe } from "lucide-react"
import { DataTable } from "@/components/data-table"
import { PageHeader } from "@/components/page-header"
import {
fetchProjects,
fetchAllPackages,
createPackage,
updatePackage,
deletePackage,
type Project,
type Package as PackageType,
type PackageCreate,
type PackageUpdate
} from "@/lib/api"
import { ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { PoweredBy } from "@/components/powered-by"
export default function PackagePage() {
const [packages, setPackages] = useState<PackageType[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadingProjects, setLoadingProjects] = useState(false)
// Pagination state
const [skip, setSkip] = useState(0)
const [limit, setLimit] = useState(10)
// Editing state
const [isEditing, setIsEditing] = useState(false)
const [currentPackage, setCurrentPackage] = useState<PackageType | null>(null)
// Form fields
const [selectedProjectId, setSelectedProjectId] = useState("")
const [name, setName] = useState("")
const [region, setRegion] = useState("")
// Load packages and projects
const loadPackages = async (currentSkip = skip, currentLimit = limit) => {
try {
setIsLoading(true)
setError(null)
const data = await fetchAllPackages({ skip: currentSkip, limit: currentLimit })
setPackages(data)
} catch (err) {
setError("Failed to load packages. Please check if the backend is running.")
} finally {
setIsLoading(false)
}
}
const loadProjects = async () => {
try {
setLoadingProjects(true)
const data = await fetchProjects({ skip: 0, limit: 1000 }) // Load all projects for selector
setProjects(data)
} catch (err) {
setError("Failed to load projects.")
} finally {
setLoadingProjects(false)
}
}
useEffect(() => {
loadPackages(skip, limit)
loadProjects()
}, [skip, limit])
const resetForm = () => {
setSelectedProjectId("")
setName("")
setRegion("")
setError(null)
setIsEditing(false)
setCurrentPackage(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedProjectId) {
setError("Please select a project first")
return
}
if (!name.trim()) {
setError("Package name is required")
return
}
setIsSubmitting(true)
setError(null)
try {
if (isEditing && currentPackage) {
const data: PackageUpdate = {
name: name.trim(),
region: region.trim() || null,
}
await updatePackage(currentPackage.id, data)
toast.success("Package updated successfully!")
} else {
const data: PackageCreate = {
project_id: selectedProjectId,
name: name.trim(),
region: region.trim() || null,
}
await createPackage(data)
toast.success("Package created successfully!")
}
// Refresh packages list
await loadPackages()
// Close modal and reset form immediately
setIsModalOpen(false)
resetForm()
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} package`)
} finally {
setIsSubmitting(false)
}
}
const handleEdit = (pkg: PackageType) => {
setIsEditing(true)
setCurrentPackage(pkg)
setSelectedProjectId(pkg.project_id)
setName(pkg.name || "")
setRegion(pkg.region || "")
setIsModalOpen(true)
}
const handleDelete = async (pkg: PackageType) => {
if (!confirm(`Are you sure you want to delete package "${pkg.name}"?`)) return
try {
setIsLoading(true)
await deletePackage(pkg.id)
toast.success("Package deleted successfully!")
await loadPackages()
} catch (err) {
setError("Failed to delete package")
} finally {
setIsLoading(false)
}
}
const getProjectName = (projectId: string) => {
return projects.find(p => p.id === projectId)?.name || projectId
}
const columns: ColumnDef<PackageType>[] = [
{
accessorKey: "name",
header: "Package Name",
cell: ({ row }) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.name}</div>
)
},
{
accessorKey: "project_id",
header: "Project",
cell: ({ row }) => getProjectName(row.original.project_id)
},
{
accessorKey: "project_state",
header: "Project State",
cell: ({ row }) => {
const pkg = row.original
const project = projects.find(p => p.id === pkg.project_id)
if (!project?.state) return <span className="text-gray-400"></span>
return (
<div className="flex flex-wrap gap-1">
{project.state.split(',').map((item, idx) => (
<span
key={idx}
className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-semibold border border-blue-100 dark:border-blue-800"
>
{item.trim()}
</span>
))}
</div>
)
}
},
{
accessorKey: "region",
header: "Region",
},
]
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
<main className="min-h-screen relative overflow-hidden">
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
{/* Refined Header */}
<div className="mb-8">
<PageHeader
title="Package Management"
description="Manage project packages"
icon={Package}
/>
</div>
{/* Error Message */}
{error && !isModalOpen && (
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
</div>
)}
{/* Data Table */}
<div>
<DataTable
title="Packages"
data={packages}
columns={columns}
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
onEdit={handleEdit}
onDelete={handleDelete}
addButtonText="Add New Package"
isLoading={isLoading}
pagination={{
skip,
limit,
onPageChange: setSkip,
onLimitChange: (newLimit) => {
setLimit(newLimit);
setSkip(0); // Reset skip when limit changes
}
}}
/>
</div>
<PoweredBy />
</div>
</main>
{/* Modal Dialog */}
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
<Package className="h-5 w-5" />
</div>
{isEditing ? 'Edit Package Details' : 'Create New Package'}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditing ? 'Update the technical specifications for your road infrastructure package.' : 'Select a project and provide the essential data to establish a new package.'}
</DialogDescription>
</DialogHeader>
{/* Error Message in Modal */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-8">
{/* Step 1: Select Project */}
{!isEditing && (
<div className="space-y-3">
<div className="flex items-center gap-2.5">
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold uppercase">
01
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Select Parent Project</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground text-sm">Loading...</span>
</div>
) : (
<SelectValue placeholder="Choose a project..." />
)}
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.id} value={project.id} className="py-3">
<span className="font-semibold">{project.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Step 2: Package Info */}
<div className={`space-y-4 ${selectedProjectId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
{!isEditing && (
<div className="flex items-center gap-2.5">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedProjectId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
02
</div>
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Package Information</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label htmlFor="pkg-name" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
Package Name <span className="text-destructive">*</span>
</Label>
<Input
id="pkg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Package 01"
className="h-11 bg-muted/20 border-border/60"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="region" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<Globe className="h-3.5 w-3.5 opacity-60" />
Region <span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
</Label>
<Input
id="region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="e.g. North Zone"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-4 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs border-border/80 hover:bg-muted/50"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim() || !selectedProjectId}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
</div>
) : (
<div className="flex items-center gap-2">
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <Package className="h-4 w-4" />}
<span>{isEditing ? 'Update Package' : 'Create Package'}</span>
</div>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation"
import { ROUTES } from "@/utils/routes"
export default function HomePage() {
redirect(ROUTES.DASHBOARD)
}

View File

@@ -0,0 +1,414 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Loader2, CheckCircle2, FolderPlus, MapPin, Building2, Route, X } from "lucide-react"
import { DataTable } from "@/components/data-table"
import { PageHeader } from "@/components/page-header"
import { createProject, fetchProjects, updateProject, deleteProject, type ProjectCreate, type Project, type ProjectUpdate } from "@/lib/api"
import { ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { PoweredBy } from "@/components/powered-by"
export default function ProjectPage() {
const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Pagination state
const [skip, setSkip] = useState(0)
const [limit, setLimit] = useState(10)
// Editing state
const [isEditing, setIsEditing] = useState(false)
const [currentProject, setCurrentProject] = useState<Project | null>(null)
// Form fields
const [name, setName] = useState("")
const [state, setState] = useState("")
const [corridorName, setCorridorName] = useState("")
const [startLat, setStartLat] = useState("")
const [startLng, setStartLng] = useState("")
const [endLat, setEndLat] = useState("")
const [endLng, setEndLng] = useState("")
// Load projects
const loadProjects = async (currentSkip = skip, currentLimit = limit) => {
try {
setIsLoading(true)
setError(null)
const data = await fetchProjects({ skip: currentSkip, limit: currentLimit })
setProjects(data)
} catch (err) {
setError("Failed to load projects. Please check if the backend is running.")
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadProjects(skip, limit)
}, [skip, limit])
const resetForm = () => {
setName("")
setState("")
setCorridorName("")
setStartLat("")
setStartLng("")
setEndLat("")
setEndLng("")
setError(null)
setIsEditing(false)
setCurrentProject(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError("Project name is required")
return
}
setIsSubmitting(true)
setError(null)
try {
if (isEditing && currentProject) {
const data: ProjectUpdate = {
name: name.trim(),
state: state.trim() || null,
corridor_name: corridorName.trim() || null,
start_lat: startLat ? parseFloat(startLat) : null,
start_lng: startLng ? parseFloat(startLng) : null,
end_lat: endLat ? parseFloat(endLat) : null,
end_lng: endLng ? parseFloat(endLng) : null,
}
await updateProject(currentProject.id, data)
toast.success("Project updated successfully!")
} else {
const data: ProjectCreate = {
name: name.trim(),
state: state.trim() || null,
corridor_name: corridorName.trim() || null,
start_lat: startLat ? parseFloat(startLat) : null,
start_lng: startLng ? parseFloat(startLng) : null,
end_lat: endLat ? parseFloat(endLat) : null,
end_lng: endLng ? parseFloat(endLng) : null,
}
await createProject(data)
toast.success("Project created successfully!")
}
// Refresh projects list
await loadProjects()
// Close modal and reset form immediately
setIsModalOpen(false)
resetForm()
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${isEditing ? 'update' : 'create'} project`)
} finally {
setIsSubmitting(false)
}
}
const handleEdit = (project: Project) => {
setIsEditing(true)
setCurrentProject(project)
setName(project.name || "")
setState(project.state || "")
setCorridorName(project.corridor_name || "")
setStartLat(project.start_lat?.toString() || "")
setStartLng(project.start_lng?.toString() || "")
setEndLat(project.end_lat?.toString() || "")
setEndLng(project.end_lng?.toString() || "")
setIsModalOpen(true)
}
const handleDelete = async (project: Project) => {
if (!confirm(`Are you sure you want to delete project "${project.name}"?`)) return
try {
setIsLoading(true)
await deleteProject(project.id)
toast.success("Project deleted successfully!")
await loadProjects()
} catch (err) {
setError("Failed to delete project")
} finally {
setIsLoading(false)
}
}
const columns: ColumnDef<Project>[] = [
{
accessorKey: "name",
header: "Project Name",
cell: ({ row }) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.name}</div>
)
},
{
accessorKey: "state",
header: "State",
cell: ({ row }) => {
const project = row.original
if (!project.state) return <span className="text-gray-400"></span>
return (
<div className="flex flex-wrap gap-1.5">
{project.state.split(',').map((item, idx) => (
<span
key={idx}
className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-semibold border border-blue-100 dark:border-blue-800"
>
{item.trim()}
</span>
))}
</div>
)
}
},
{
accessorKey: "corridor_name",
header: "Corridor",
},
]
return (
<div className="min-h-screen">
<main className="min-h-screen relative overflow-hidden">
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
{/* Refined Header */}
<div className="mb-8">
<PageHeader
title="Project Management"
description="Manage road infrastructure projects"
icon={FolderPlus}
/>
</div>
{/* Error Message */}
{error && !isModalOpen && (
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
</div>
)}
{/* Data Table */}
<div>
<DataTable
title="Projects"
data={projects}
columns={columns}
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
onEdit={handleEdit}
onDelete={handleDelete}
addButtonText="Add New Project"
isLoading={isLoading}
pagination={{
skip,
limit,
onPageChange: setSkip,
onLimitChange: (newLimit) => {
setLimit(newLimit);
setSkip(0); // Reset skip when limit changes
}
}}
/>
</div>
<PoweredBy />
</div>
</main>
{/* Modal Dialog */}
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
<DialogContent
className="max-w-2xl"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
<FolderPlus className="h-5 w-5" />
</div>
{isEditing ? 'Edit Project Details' : 'Create New Project'}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditing ? 'Update the technical specifications for your road infrastructure project.' : 'Provide the essential road data to establish a new analysis project.'}
</DialogDescription>
</DialogHeader>
{/* Error Message in Modal */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 break-all">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="name" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
Project Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter a descriptive project name..."
className="h-11 bg-muted/20 border-border/60"
required
/>
</div>
{/* State & Corridor Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-2">
<Label htmlFor="state" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
State <span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
</Label>
<Input
id="state"
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="e.g. Maharashtra"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="corridor" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<Route className="h-3.5 w-3.5 opacity-60" />
Corridor Name <span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
</Label>
<Input
id="corridor"
value={corridorName}
onChange={(e) => setCorridorName(e.target.value)}
placeholder="e.g. Mumbai-Goa Highway"
className="h-11 bg-muted/20 border-border/60"
/>
</div>
</div>
{/* GPS Coordinates Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
{/* Start Point */}
<div className="space-y-4">
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 opacity-60" /> START POINT
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lat</Label>
<Input
id="start-lat"
type="number"
step="any"
value={startLat}
onChange={(e) => setStartLat(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="start-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lng</Label>
<Input
id="start-lng"
type="number"
step="any"
value={startLng}
onChange={(e) => setStartLng(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
</div>
</div>
{/* End Point */}
<div className="space-y-4">
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 opacity-60" /> END POINT
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="end-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lat</Label>
<Input
id="end-lat"
type="number"
step="any"
value={endLat}
onChange={(e) => setEndLat(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lng</Label>
<Input
id="end-lng"
type="number"
step="any"
value={endLng}
onChange={(e) => setEndLng(e.target.value)}
placeholder="0.0000"
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
/>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-4 pt-2">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs border-border/80 hover:bg-muted/50"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
</div>
) : (
<div className="flex items-center gap-2">
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <FolderPlus className="h-4 w-4" />}
<span>{isEditing ? 'Update Project' : 'Create Project'}</span>
</div>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,178 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter, useParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Loader2, TrendingUp } from "lucide-react"
import VideoPlayerSection from "@/components/video-player-section"
import { PageHeader } from "@/components/page-header"
import { PoweredBy } from "@/components/powered-by"
import {
type SessionContext,
loadSession,
clearSession
} from "@/lib/api"
import { getVideoFile, clearVideoFile } from "@/lib/video-storage"
import { DetectionData, DetectionType } from "@/lib/types"
import { ROUTES } from "@/utils/routes"
import { Card } from "@/components/ui/card"
const API_URL = process.env.NEXT_PUBLIC_API_URL
export default function VideoResultsPage() {
const router = useRouter()
const { videoId } = useParams() as { videoId: string }
const [session, setSession] = useState<SessionContext | null>(null)
const [detectionData, setDetectionData] = useState<DetectionData | null>(null)
const [detectionType, setDetectionType] = useState<DetectionType>("pothole-detection")
const [videoFile, setVideoFile] = useState<File | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const storedSession = loadSession()
setSession(storedSession)
const fetchResults = async () => {
try {
// Fetch detection data from backend
const response = await fetch(`${API_URL}/results/${videoId}`, {
headers: { "ngrok-skip-browser-warning": "true" }
})
if (!response.ok) {
if (response.status === 404) {
throw new Error("Results not found for this video.")
}
throw new Error(`Failed to load results: ${response.status}`)
}
const data = await response.json()
setDetectionData(data as any)
// Try to infer detection type from results if possible
if (data.summary?.unique_signboards !== undefined && data.summary?.unique_signboards > 0) {
setDetectionType("sign-board-detection")
} else if (data.summary?.unique_potholes !== undefined && data.summary?.unique_potholes > 0) {
setDetectionType("pothole-detection")
}
// Retrieve video file from IndexedDB
const storedVideoFile = await getVideoFile(videoId)
if (storedVideoFile) {
setVideoFile(storedVideoFile)
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load results")
} finally {
setIsLoading(false)
}
}
if (videoId) {
fetchResults()
}
}, [videoId])
const handleNewAnalysis = async () => {
if (videoId) {
try {
await clearVideoFile(videoId)
} catch (err) {
console.error("Failed to clear video file:", err)
}
}
clearSession()
router.push(ROUTES.NEW_ANALYSIS)
}
const getTitle = () => {
if (detectionType === "pothole-detection") return "Pothole Detection Results"
if (detectionType === "sign-board-detection") return "Signboard Detection Results"
return "Pothole & Signboard Detection Results"
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading detection results...</p>
</Card>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<p className="text-destructive font-medium">{error}</p>
<div className="flex gap-4">
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">Back to Upload</Button>
<Button onClick={handleNewAnalysis}>New Analysis</Button>
</div>
</Card>
</div>
)
}
return (
<div className="min-h-screen">
<main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
<div className="mb-8">
<PageHeader
title={getTitle()}
description={`Video ID: ${videoId}`}
icon={TrendingUp}
/>
</div>
{session && (
<div className="mb-6">
<Card className="p-0 border shadow-sm overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
<span className="text-base font-bold leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.locationName}
</span>
</div>
</div>
<Button onClick={handleNewAnalysis} variant="outline" size="sm" className="font-semibold px-6 shrink-0 h-9">
Start New Analysis
</Button>
</div>
</Card>
</div>
)}
{detectionData && (
<VideoPlayerSection
data={detectionData}
videoId={videoId}
videoFile={videoFile}
detectionType={detectionType}
projectId={session?.projectId || undefined}
/>
)}
<PoweredBy />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,150 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Loader2, TrendingUp } from "lucide-react"
import VideoPlayerSection from "@/components/video-player-section"
import { PageHeader } from "@/components/page-header"
import { PoweredBy } from "@/components/powered-by"
import {
type SessionContext,
loadSession,
loadVideoData,
isSessionValid,
clearSession
} from "@/lib/api"
import { clearVideoFile } from "@/lib/video-storage"
import { DetectionData, DetectionType } from "@/lib/types"
import { ROUTES } from "@/utils/routes"
import { Card } from "@/components/ui/card"
export default function ResultsPage() {
const router = useRouter()
const [session, setSession] = useState<SessionContext | null>(null)
const [detectionData] = useState<DetectionData | null>(null)
const [detectionType] = useState<DetectionType>("pothole-detection")
const [videoId] = useState<string | null>(null)
const [videoFile] = useState<File | null>(null)
const [isLoading] = useState(true)
const [error] = useState<string | null>(null)
// Load session and video data on mount
useEffect(() => {
const storedSession = loadSession()
const videoData = loadVideoData()
if (!isSessionValid(storedSession) || !videoData) {
router.replace(ROUTES.NEW_ANALYSIS)
return
}
// If we have a videoId, redirect to the dynamic results page
if (videoData.videoId) {
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`)
return
}
setSession(storedSession)
}, [router])
const handleNewAnalysis = async () => {
// Clear video from IndexedDB
if (videoId) {
try {
await clearVideoFile(videoId)
} catch (err) {
console.error("Failed to clear video file:", err)
}
}
clearSession()
router.push(ROUTES.NEW_ANALYSIS)
}
const getTitle = () => {
if (detectionType === "pothole-detection") return "Pothole Detection Results"
if (detectionType === "sign-board-detection") return "Signboard Detection Results"
return "Pothole & Signboard Detection Results"
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading results...</p>
</Card>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<p className="text-destructive font-medium">{error}</p>
<Button onClick={handleNewAnalysis}>Start New Analysis</Button>
</Card>
</div>
)
}
return (
<div className="min-h-screen">
<main className="min-h-screen">
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
<div className="mb-8">
<PageHeader
title={getTitle()}
description="View your AI-powered road analysis results"
icon={TrendingUp}
/>
</div>
{session && (
<div className="mb-6">
<Card className="p-0 border shadow-sm overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
<span className="text-base font-bold leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.locationName}
</span>
</div>
</div>
<Button onClick={handleNewAnalysis} variant="outline" size="sm" className="font-semibold px-6 shrink-0 h-9">
Start New Analysis
</Button>
</div>
</Card>
</div>
)}
{detectionData && videoId && (
<VideoPlayerSection
data={detectionData}
videoId={videoId}
videoFile={videoFile}
detectionType={detectionType}
projectId={session?.projectId || undefined}
/>
)}
<PoweredBy />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,223 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useRouter, useParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Loader2, TrendingUp } from "lucide-react"
import { PageHeader } from "@/components/page-header"
import { PoweredBy } from "@/components/powered-by"
import {
type SessionContext,
loadSession,
} from "@/lib/api"
import { ROUTES } from "@/utils/routes"
import { Button } from "@/components/ui/button"
const API_URL = process.env.NEXT_PUBLIC_API_URL
const WS_URL = API_URL?.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://")
export default function VideoProcessingPage() {
const router = useRouter()
const { videoId } = useParams() as { videoId: string }
const [session, setSession] = useState<SessionContext | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Processing states
const [progress, setProgress] = useState(0)
const [statusMessage, setStatusMessage] = useState("Initializing...")
const [error, setError] = useState<string | null>(null)
const connectWebSocket = useCallback((vid: string) => {
const ws = new WebSocket(`${WS_URL}/ws/${vid}`)
ws.onmessage = async (event) => {
const data = JSON.parse(event.data)
if (data.type === "progress" || data.progress !== undefined) {
setProgress(data.progress || 0)
let message = data.message || "Processing..."
if (data.unique_potholes !== undefined) {
message += ` | Unique: ${data.unique_potholes} | Total: ${data.total_detections || 0}`
} else if (data.unique_signboards !== undefined) {
message += ` | Unique: ${data.unique_signboards} | Total: ${data.total_detections || 0}`
}
setStatusMessage(message)
}
if (data.type === "complete" || data.status === "completed") {
setStatusMessage("Processing completed! Finalizing...")
ws.close()
// Navigate to results
setTimeout(() => router.push(`/results/${vid}`), 1000)
}
if (data.type === "error") {
setError("Error: " + data.message)
setStatusMessage("")
ws.close()
}
}
ws.onerror = () => {
setStatusMessage("Connection lost. Reconnecting...")
setTimeout(() => connectWebSocket(vid), 3000)
}
return ws
}, [router])
useEffect(() => {
const storedSession = loadSession()
setSession(storedSession)
const checkStatus = async () => {
try {
const response = await fetch(`${API_URL}/status/${videoId}`, {
headers: { "ngrok-skip-browser-warning": "true" }
})
if (response.status === 404) {
router.replace(ROUTES.UPLOAD)
return
}
if (!response.ok) {
throw new Error("Failed to fetch status")
}
const statusData = await response.json()
if (statusData.status === "completed") {
router.replace(`/results/${videoId}`)
return
}
if (statusData.status === "error") {
setError(statusData.message || "An error occurred during processing.")
setIsLoading(false)
return
}
// If processing, start WebSocket
setProgress(statusData.progress || 0)
setStatusMessage(statusData.message || "Resuming processing...")
connectWebSocket(videoId)
setIsLoading(false)
} catch (err) {
console.error("Status check failed:", err)
setError("Failed to connect to server.")
setIsLoading(false)
}
}
if (videoId) {
checkStatus()
}
}, [videoId, router, connectWebSocket])
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</Card>
</div>
)
}
return (
<div className="min-h-screen">
<main className="min-h-screen flex flex-col">
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 flex flex-col">
<div className="mb-6">
<PageHeader
title="Processing Analysis"
description={`Real-time analysis progress for video ID: ${videoId}`}
icon={TrendingUp}
/>
</div>
{session && (
<div className="mb-6">
<Card className="p-0 border shadow-sm overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center py-4 px-6 gap-6 bg-card">
<div className="flex flex-wrap items-center gap-x-12 gap-y-4 flex-1">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
<span className="text-base font-bold leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.locationName}
</span>
</div>
</div>
</div>
</Card>
</div>
)}
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight">
Processing Analysis
</h2>
<p className="text-sm font-mono text-muted-foreground tracking-widest">
ID: {videoId}
</p>
</div>
<div className="w-full space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="font-bold text-muted-foreground text-xs uppercase tracking-widest">Progress</span>
<span className="font-bold text-primary text-lg">{progress}%</span>
</div>
<div className="h-4 rounded-full bg-secondary overflow-hidden border">
<div
className="h-full bg-primary transition-all duration-1000 ease-in-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-center pt-2">
<div className="inline-flex items-center gap-3 px-4 py-2 rounded-full border bg-secondary/30">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<p className="text-sm font-semibold">
{statusMessage}
</p>
</div>
</div>
</div>
{error && (
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
<p className="text-destructive font-medium">{error}</p>
<Button
variant="outline"
size="sm"
onClick={() => window.location.reload()}
>
Retry Connection
</Button>
</div>
)}
</div>
</Card>
<PoweredBy />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,352 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Loader2, TrendingUp } from "lucide-react"
import { PageHeader } from "@/components/page-header"
import { PoweredBy } from "@/components/powered-by"
import {
type SessionContext,
loadSession,
isSessionValid,
saveVideoData,
clearSession
} from "@/lib/api"
import { ROUTES } from "@/utils/routes"
import { storeVideoFile } from "@/lib/video-storage"
import { cn } from "@/lib/utils"
const API_URL = process.env.NEXT_PUBLIC_API_URL
const DETECTION_TYPES = [
{ value: "pothole-detection", label: "Pothole Detection" },
{ value: "sign-board-detection", label: "Signboard Detection" },
{ value: "pot-sign-detection", label: "Pothole & Signboard Detection" },
] as const
const DETECTION_METHODS = [
{ value: "yolo", label: "YOLO Detection Model" },
{ value: "yolo_vl", label: "YOLO with Vision-Language Model" },
{ value: "sam3", label: "OpenAI SAM 3 Segmentation Model" },
{ value: "yoloe", label: "YOLOE Open-Vocabulary Detection" },
{ value: "yoloe_trained_vl", label: "YOLOE With Vision Language Model" },
] as const
type DetectionType = "pothole-detection" | "sign-board-detection" | "pot-sign-detection"
export default function UploadPage() {
const router = useRouter()
const [session, setSession] = useState<SessionContext | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Form states
const [file, setFile] = useState<File | null>(null)
const [jsonFile, setJsonFile] = useState<File | null>(null)
const [speed, setSpeed] = useState(30)
const [detectionType, setDetectionType] = useState<DetectionType>("pothole-detection")
const [selectMethod, setSelectMethod] = useState("yolo_vl")
// Upload states
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [statusMessage, setStatusMessage] = useState("")
const [error, setError] = useState<string | null>(null)
// Load session on mount
useEffect(() => {
const storedSession = loadSession()
if (!isSessionValid(storedSession)) {
router.replace(ROUTES.NEW_ANALYSIS)
return
}
setSession(storedSession)
setIsLoading(false)
}, [router])
const handleUpload = async () => {
if (!file) {
setError("Please select a video file")
return
}
const formData = new FormData()
formData.append("file", file)
formData.append("detection_type", detectionType)
formData.append("speed_kmh", speed.toString())
formData.append("detection_mode", selectMethod)
if (jsonFile) {
formData.append("json_file", jsonFile)
}
setUploading(true)
setProgress(0)
setStatusMessage("Uploading...")
setError(null)
try {
const response = await fetch(`${API_URL}/upload`, {
method: "POST",
headers: { "ngrok-skip-browser-warning": "true" },
body: formData
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Upload failed (${response.status}): ${errorText}`)
}
const result = await response.json()
// Store file locally for potential recovery/results display
if (file) {
try {
await storeVideoFile(result.video_id, file)
} catch (err) {
console.error("Failed to store video file:", err)
}
}
saveVideoData({ videoId: result.video_id, detectionType })
// Redirect to the dynamic processing page
router.push(`/upload/${result.video_id}`)
} catch (err) {
let errorMessage = "Upload failed"
if (err instanceof TypeError && err.message === "Failed to fetch") {
errorMessage = "Cannot connect to server. Please check if backend is running."
} else if (err instanceof Error) {
errorMessage = err.message
}
setError(errorMessage)
setStatusMessage("")
setUploading(false)
setProgress(0)
}
}
const handleBackToSelection = () => {
clearSession()
router.push(ROUTES.NEW_ANALYSIS)
}
const getTitle = () => {
if (detectionType === "pothole-detection") return "Pothole Detection"
if (detectionType === "sign-board-detection") return "Signboard Detection"
return "Pothole & Signboard Detection"
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</Card>
</div>
)
}
return (
<div className="min-h-screen">
{/* Main Content */}
<main className="min-h-screen flex flex-col">
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 flex flex-col">
{/* Header */}
<div className="mb-6">
<PageHeader
title={getTitle()}
description="Upload video file and fill in required details to start the road analysis"
icon={TrendingUp}
/>
</div>
{/* Compact Session Info Bar */}
{session && (
<div className="mb-6">
<Card className="p-0 border shadow-sm overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
<span className="text-base font-bold leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col border-l pl-12 border-border/60">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
<span className="text-sm font-semibold text-muted-foreground leading-tight">
{session.locationName}
</span>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleBackToSelection}
className="font-semibold px-6 shrink-0 h-9"
>
Change Selection
</Button>
</div>
</Card>
</div>
)}
{/* Upload Card */}
<Card className="flex-1">
<CardHeader className="pb-4">
<CardTitle className="text-xl font-bold">
Upload Video
</CardTitle>
<CardDescription className="text-sm">
Select video file, detection type, vehicle speed, and method for analysis
</CardDescription>
</CardHeader>
<CardContent className="pt-4 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
{/* Video File Input */}
<div className="space-y-2">
<Label htmlFor="video-file" className="text-sm font-semibold">
Video File
</Label>
<Input
id="video-file"
type="file"
accept="video/*"
onChange={(e) => {
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"
/>
</div>
{/* JSON File Input */}
<div className="space-y-2">
<Label htmlFor="json-file" className="text-sm font-semibold">
GPS JSON File
</Label>
<Input
id="json-file"
type="file"
accept=".json,application/json"
onChange={(e) => {
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"
/>
</div>
</div>
{/* Detection, Speed, and Method Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Detection Type */}
<div className="space-y-2">
<Label htmlFor="detection-type" className="text-sm font-semibold">
Detection Type
</Label>
<Select
value={detectionType}
onValueChange={(v) => setDetectionType(v as DetectionType)}
disabled={uploading}
>
<SelectTrigger id="detection-type" className="h-11">
<SelectValue placeholder="Select detection type" />
</SelectTrigger>
<SelectContent>
{DETECTION_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Speed Input */}
<div className="space-y-2">
<Label htmlFor="speed" className="text-sm font-semibold">
Vehicle Speed (km/h)
</Label>
<Input
id="speed"
type="number"
min={1}
max={200}
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
disabled={uploading}
className="h-11"
/>
</div>
{/* Select Method */}
<div className="space-y-2">
<Label htmlFor="select-method" className="text-sm font-semibold">
Select Method
</Label>
<Select
value={selectMethod}
onValueChange={setSelectMethod}
disabled={uploading}
>
<SelectTrigger id="select-method" className="h-11">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
{DETECTION_METHODS.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Error Display */}
{error && (
<div className="p-4 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm leading-relaxed whitespace-pre-line">
{error}
</div>
)}
{/* Upload Button */}
<Button
onClick={handleUpload}
disabled={!file || uploading}
className="w-full h-14 text-base font-bold uppercase tracking-wide"
size="lg"
>
{uploading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Processing...</span>
</div>
) : (
"Upload and Process"
)}
</Button>
</CardContent>
</Card>
<PoweredBy />
</div>
</main>
</div>
)
}