refactor: remove unused packages

This commit is contained in:
2026-03-17 15:33:39 +05:30
parent e41236108a
commit 8ddd2df981
101 changed files with 735 additions and 8135 deletions

618
src/app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,618 @@
"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 { SidebarNavigation } from "@/components/sidebar-navigation";
import { GradientStatsCard } from "@/components/dashboard/gradient-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">
{/* Sidebar Navigation */}
<SidebarNavigation />
{/* Main Content - offset by sidebar width */}
<main className="ml-20 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"
>
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8 text-white relative z-10"
>
<path
d="M50 20L85 80H15L50 20Z"
stroke="currentColor"
strokeWidth="6"
strokeLinejoin="round"
/>
<path
d="M40 80L50 55L60 80"
stroke="currentColor"
strokeWidth="6"
/>
</svg>
</PageHeader>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="bg-white/70 backdrop-blur-md shadow-none rounded-md hover:bg-white/50 border-none transition-all duration-300 gap-2 font-semibold">
<Filter className="h-4 w-4" />
<span>Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0 border-none shadow-2xl rounded-md overflow-hidden" align="end">
<div className="bg-linear-to-b from-[#225999] to-[#56A5FF] p-4 text-white">
<h3 className="font-bold text-sm flex items-center gap-2">
<Filter className="h-4 w-4" />
Filter Analysis
</h3>
<p className="text-xs text-blue-100 mt-1 opacity-80">Refine your view by project, package, or location</p>
</div>
<div className="p-1 bg-white">
<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">
<GradientStatsCard
title="Total Road Damage"
subtitle="Combined damage detections"
value={stats.totalRoadDamage}
icon={Activity}
gradient="purple"
/>
<GradientStatsCard
title="Potholes"
subtitle="Surface depressions"
value={stats.totalPothole}
icon={AlertCircle}
gradient="green"
/>
<GradientStatsCard
title="Defected Signboards"
subtitle="Damaged traffic signs"
value={stats.totalDefectedSignboard}
icon={AlertTriangle}
gradient="blue"
/>
<GradientStatsCard
title="Road Cracks"
subtitle="Surface fissures"
value={stats.totalRoadCrack}
icon={Zap}
gradient="orange"
/>
<GradientStatsCard
title="Damaged Markings"
subtitle="Worn road lines"
value={stats.totalDamagedRoadMarking}
icon={PencilLine}
gradient="indigo"
/>
<GradientStatsCard
title="Good Signboards"
subtitle="Informational markers"
value={stats.totalGoodSignboard}
icon={CheckCircle2}
gradient="emerald"
/>
</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 className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden">
<CardHeader className="pb-2 border-none">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="w-10 h-10 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center ">
<BarChart3 className="h-5 w-5 text-white" />
</div>
<span className="heading-blue-gradient 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 className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="w-10 h-10 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center ">
<MapPinIcon className="h-5 w-5 text-white" />
</div>
<span className="heading-blue-gradient 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>
);
}

234
src/app/globals.css Normal file
View File

@@ -0,0 +1,234 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
/* Primary - Indigo */
--primary: oklch(0.55 0.24 264);
--primary-foreground: oklch(0.98 0.01 264);
/* Background & Surfaces */
--background: hsla(210, 40%, 98%, 0.2);
--foreground: oklch(0.15 0.02 280);
--dialog: white;
--card: white;
--card-foreground: oklch(0.15 0.02 280);
--popover: white;
--popover-foreground: oklch(0.15 0.02 280);
/* Secondary */
--secondary: oklch(0.95 0.02 264);
--secondary-foreground: oklch(0.25 0.05 264);
/* Muted */
--muted: oklch(0.94 0.02 280);
--muted-foreground: oklch(0.45 0.03 280);
/* Accent - Cyan */
--accent: oklch(0.92 0.04 200);
--accent-foreground: oklch(0.25 0.08 200);
/* Destructive */
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(0.98 0.01 25);
/* Borders & Inputs */
--border: hsla(210, 100%, 85%, 1);
--input: oklch(0.92 0.02 280);
--ring: hsla(210, 100%, 85%, 1);
/* Card Glow */
--card-glow: hsla(210, 100%, 85%, 0.3);
--card-hover-border: hsla(210, 100%, 75%, 0.8);
/* Chart Colors - Vibrant Palette */
--chart-1: oklch(0.55 0.24 264);
--chart-2: oklch(0.65 0.2 200);
--chart-3: oklch(0.6 0.18 160);
--chart-4: oklch(0.7 0.2 85);
--chart-5: oklch(0.6 0.22 320);
--radius: 0.75rem;
/* Sidebar */
--sidebar: #ffffff;
--sidebar-foreground: #64748b;
--sidebar-primary: #2563eb;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f1f5f9;
--sidebar-accent-foreground: #0f172a;
--sidebar-border: #e2e8f0;
--sidebar-ring: #3b82f6;
}
.dark {
/* Primary - Light Indigo */
--primary: oklch(0.7 0.2 264);
--primary-foreground: oklch(0.15 0.02 264);
--dialog: white;
/* Background & Surfaces */
--background: hsla(280, 20%, 12%, 0.3);
--foreground: oklch(0.95 0.01 280);
--card: hsla(280, 20%, 16%, 0.4);
--card-foreground: oklch(0.95 0.01 280);
--popover: hsla(280, 20%, 16%, 0.7);
--popover-foreground: oklch(0.95 0.01 280);
/* Secondary */
--secondary: oklch(0.22 0.03 264);
--secondary-foreground: oklch(0.9 0.02 264);
/* Muted */
--muted: oklch(0.2 0.02 280);
--muted-foreground: oklch(0.65 0.02 280);
/* Accent */
--accent: oklch(0.22 0.04 200);
--accent-foreground: oklch(0.85 0.08 200);
/* Destructive */
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.9 0.08 25);
/* Borders & Inputs */
--border: oklch(0.25 0.02 280);
--input: oklch(0.22 0.02 280);
--ring: oklch(0.7 0.2 264);
/* Chart Colors */
--chart-1: oklch(0.65 0.22 264);
--chart-2: oklch(0.7 0.18 200);
--chart-3: oklch(0.65 0.16 160);
--chart-4: oklch(0.75 0.18 85);
--chart-5: oklch(0.65 0.2 320);
/* Sidebar */
--sidebar: oklch(0.14 0.02 280);
--sidebar-foreground: oklch(0.95 0.01 280);
--sidebar-primary: oklch(0.65 0.22 264);
--sidebar-primary-foreground: oklch(0.95 0.01 264);
--sidebar-accent: oklch(0.22 0.03 264);
--sidebar-accent-foreground: oklch(0.9 0.02 264);
--sidebar-border: oklch(0.25 0.02 280);
--sidebar-ring: oklch(0.7 0.2 264);
}
@theme inline {
--font-sans: "Geist", "Geist Fallback", system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-dialog: var(--dialog);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
/* Base styles */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply text-foreground;
background-image: url("/background.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
min-height: 100vh;
background-color: transparent !important;
}
.bg-mesh-gradient {
background: transparent !important;
}
[data-slot="card"] {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
}
/*
Definitive Layout Stability Reset
Prevents Radix UI / Shadcn from "sliding" content when scroll-locking occurs
*/
:root {
--removed-body-scroll-bar-size: 0px;
}
html {
/* scrollbar-gutter: stable; */
scroll-behavior: smooth;
}
body[data-scroll-locked] {
padding-right: 0 !important;
margin-right: 0 !important;
overflow: hidden !important;
}
/* Fix for fixed position elements like sidebars/headers */
[data-radix-scroll-area-viewport] {
scrollbar-width: none;
-ms-overflow-style: none;
}
.heading-blue-gradient {
@apply bg-linear-to-r from-blue-400 to-blue-600 bg-clip-text text-transparent;
}
.powered-blue-gradient {
@apply bg-linear-to-b from-[#225999] to-[#56A5FF] bg-clip-text text-transparent;
}
.btn-blue-gradient {
@apply text-white bg-linear-to-r from-blue-400 to-blue-600 hover:bg-linear-to-bl focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium transition-all duration-200;
}
.bg-blue-gradient {
background: linear-gradient(271.19deg, #86B8F1 1.02%, #3895FF 191.34%);
}
.badge-blue {
@apply flex items-center justify-center bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400 text-sm font-bold rounded-full border border-blue-200/50;
}

53
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,53 @@
import type React from "react"
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { Analytics } from "@vercel/analytics/next"
import "./globals.css"
import "leaflet/dist/leaflet.css"
const _geist = Geist({ subsets: ["latin"] })
const _geistMono = Geist_Mono({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "Pothole Detection System",
description: "AI-powered pothole detection and tracking system",
generator: "v0.app",
icons: {
icon: [
{
url: "/icon-light-32x32.png",
media: "(prefers-color-scheme: light)",
},
{
url: "/icon-dark-32x32.png",
media: "(prefers-color-scheme: dark)",
},
{
url: "/icon.svg",
type: "image/svg+xml",
},
],
apple: "/apple-icon.png",
},
}
import { Toaster } from "@/components/ui/sonner"
import { TooltipProvider } from "@/components/ui/tooltip"
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" data-scroll-behavior="smooth">
<body className={`font-sans antialiased`} suppressHydrationWarning>
<TooltipProvider>
{children}
<Toaster />
</TooltipProvider>
<Analytics />
</body>
</html>
)
}

610
src/app/location/page.tsx Normal file
View File

@@ -0,0 +1,610 @@
"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 { SidebarNavigation } from "@/components/sidebar-navigation"
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 { 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 = [
{
key: "segment_name",
header: "Segment Name",
render: (location: Location) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{location.segment_name}</div>
)
},
{
key: "package_id",
header: "Package",
render: (location: Location) => getPackageName(location.package_id)
},
{
key: "project",
header: "Project",
render: (location: Location) => {
const pkg = allPackages.find(p => p.id === location.package_id)
const project = projects.find(p => p.id === pkg?.project_id)
return project?.name || "—"
}
},
{
key: "chainage",
header: "Chainage (km)",
render: (location: Location) => {
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 "—"
}
},
{
key: "start_gps",
header: "Start GPS",
render: (location: Location) => (
<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">
{location.start_lat.toFixed(4)}, {location.start_lng.toFixed(4)}
</span>
)
},
{
key: "end_gps",
header: "End GPS",
render: (location: Location) => (
<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">
{location.end_lat.toFixed(4)}, {location.end_lng.toFixed(4)}
</span>
)
},
]
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-20 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="All 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
className="max-w-2xl"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-blue-500" />
{isEditing ? 'Edit Location' : 'Create New Location'}
</DialogTitle>
<DialogDescription>
{isEditing ? 'Update disclosure details for your road infrastructure segment' : 'Select project & package, then fill in the location details'}
</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">
{/* Step 1: Select Project */}
{!isEditing && (
<div className="p-3 rounded-xl bg-blue-50/80 dark:bg-blue-900/40 border border-blue-200 dark:border-blue-800 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<div className="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center">
<span className="text-white text-[10px] font-bold">1</span>
</div>
<p className="text-xs font-bold text-blue-700 dark:text-blue-400">Select Project</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-white dark:bg-gray-800 border-blue-200 dark:border-blue-800 focus:border-blue-400">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
<span className="text-gray-400">Loading projects...</span>
</div>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
<span className="font-medium">{project.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Step 2: Select Package */}
{!isEditing && (
<div className={`p-3 rounded-xl bg-blue-50/80 dark:bg-blue-900/40 border border-blue-200 dark:border-blue-800 shadow-sm ${selectedProjectId ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
<div className="flex items-center gap-2 mb-2">
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${selectedProjectId ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
<span className="text-white text-[10px] font-bold">2</span>
</div>
<p className="text-xs font-bold text-blue-700 dark:text-blue-400">Select Package</p>
</div>
<Select value={selectedPackageId} onValueChange={setSelectedPackageId} disabled={!selectedProjectId}>
<SelectTrigger className="h-11 bg-white dark:bg-gray-800 border-blue-200 dark:border-blue-800 focus:border-blue-400">
{loadingPackages ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
<span className="text-gray-400">Loading packages...</span>
</div>
) : (
<SelectValue placeholder={selectedProjectId ? "Choose a package" : "Select project first"} />
)}
</SelectTrigger>
<SelectContent>
{packages.map(pkg => (
<SelectItem key={pkg.id} value={pkg.id}>
<span className="font-medium">{pkg.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</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">
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${selectedPackageId ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
<span className="text-white text-[10px] font-bold">3</span>
</div>
<p className="text-xs font-bold text-gray-700 dark:text-gray-300">Location Information</p>
</div>
)}
{/* Segment Name & Chainage Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="segment" className="text-xs font-semibold flex items-center gap-1">
Segment Name <span className="text-red-500">*</span>
</Label>
<Input
id="segment"
value={segmentName}
onChange={(e) => setSegmentName(e.target.value)}
placeholder="Location to Location"
className="h-9 text-sm"
required
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label htmlFor="ch-start" className="text-[10px] font-semibold flex items-center gap-1">
Start (km)
</Label>
<Input
id="ch-start"
type="number"
step="any"
min="0"
value={chainageStartKm}
onChange={(e) => setChainageStartKm(e.target.value)}
placeholder="0"
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ch-end" className="text-[10px] font-semibold flex items-center gap-1">
End (km)
</Label>
<Input
id="ch-end"
type="number"
step="any"
min="0"
value={chainageEndKm}
onChange={(e) => setChainageEndKm(e.target.value)}
placeholder="0"
className="h-9 text-sm"
/>
</div>
</div>
</div>
{/* GPS Coordinates Grid */}
<div className="grid grid-cols-2 gap-3">
{/* Start Point */}
<div className="p-3 rounded-xl bg-blue-50/50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-900/50">
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
<MapPin className="h-3 w-3" /> Start Point
</p>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2">
<Label htmlFor="s-lat" className="text-[10px] text-gray-500 w-8">Lat</Label>
<Input
id="s-lat"
type="number"
step="any"
value={startLat}
onChange={(e) => setStartLat(e.target.value)}
placeholder="Lat"
className="h-8 text-[11px]"
required
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="s-lng" className="text-[10px] text-gray-500 w-8">Lng</Label>
<Input
id="s-lng"
type="number"
step="any"
value={startLng}
onChange={(e) => setStartLng(e.target.value)}
placeholder="Lng"
className="h-8 text-[11px]"
required
/>
</div>
</div>
</div>
{/* End Point */}
<div className="p-3 rounded-xl bg-blue-50/50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-900/50">
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
<MapPin className="h-3 w-3" /> End Point
</p>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2">
<Label htmlFor="e-lat" className="text-[10px] text-gray-500 w-8">Lat</Label>
<Input
id="e-lat"
type="number"
step="any"
value={endLat}
onChange={(e) => setEndLat(e.target.value)}
placeholder="Lat"
className="h-8 text-[11px]"
required
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="e-lng" className="text-[10px] text-gray-500 w-8">Lng</Label>
<Input
id="e-lng"
type="number"
step="any"
value={endLng}
onChange={(e) => setEndLng(e.target.value)}
placeholder="Lng"
className="h-8 text-[11px]"
required
/>
</div>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !isFormComplete}
className="flex-1 btn-blue-gradient"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{isEditing ? 'Updating...' : 'Creating...'}
</span>
) : (
<span className="flex items-center gap-2">
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
{isEditing ? 'Update Location' : 'Create Location'}
</span>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,55 @@
"use client"
import { useRouter } from "next/navigation"
import dynamic from "next/dynamic"
import { SidebarNavigation } from "@/components/sidebar-navigation"
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 text-gray-900 dark:text-gray-100">
{/* Sidebar Navigation */}
<SidebarNavigation />
{/* Main Content */}
<main className="ml-20 min-h-screen relative overflow-hidden">
<div className="container mx-auto px-6 py-10 max-w-340 relative z-10">
{/* 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>
)
}

392
src/app/package/page.tsx Normal file
View File

@@ -0,0 +1,392 @@
"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 { SidebarNavigation } from "@/components/sidebar-navigation"
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 { 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 = [
{
key: "name",
header: "Package Name",
render: (pkg: PackageType) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{pkg.name}</div>
)
},
{
key: "project_id",
header: "Project",
render: (pkg: PackageType) => getProjectName(pkg.project_id)
},
{
key: "project_state",
header: "Project State",
render: (pkg: PackageType) => {
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>
)
}
},
{
key: "region",
header: "Region",
},
]
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-20 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="All 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>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-blue-500" />
{isEditing ? 'Edit Package' : 'Create New Package'}
</DialogTitle>
<DialogDescription>
{isEditing ? 'Update disclosure details for your road infrastructure package' : 'Select a project and fill in the package details'}
</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="p-4 rounded-xl bg-blue-50/80 dark:bg-blue-900/40 border border-blue-200 dark:border-blue-800 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center">
<span className="text-white text-xs font-bold">1</span>
</div>
<p className="text-sm font-bold text-blue-700 dark:text-blue-400">Select Project</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-white dark:bg-gray-800 border-blue-200 dark:border-blue-800 focus:border-blue-400">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
<span className="text-gray-400">Loading projects...</span>
</div>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
<span className="font-medium">{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">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${selectedProjectId ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
<span className="text-white text-xs font-bold">2</span>
</div>
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">Package Information</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="pkg-name" className="text-sm font-semibold flex items-center gap-1">
Package Name <span className="text-red-500">*</span>
</Label>
<Input
id="pkg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Package Name"
className="h-10 text-sm"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="region" className="text-sm font-semibold flex items-center gap-1">
<Globe className="h-3.5 w-3.5 text-gray-400" />
Region <span className="text-gray-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="Region"
className="h-10 text-sm focus-visible:ring-blue-500"
/>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim() || !selectedProjectId}
className="flex-1 btn-blue-gradient text-white"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{isEditing ? 'Updating...' : 'Creating...'}
</span>
) : (
<span className="flex items-center gap-2">
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <Package className="h-4 w-4" />}
{isEditing ? 'Update Package' : 'Create Package'}
</span>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

6
src/app/page.tsx Normal file
View File

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

412
src/app/project/page.tsx Normal file
View File

@@ -0,0 +1,412 @@
"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 { SidebarNavigation } from "@/components/sidebar-navigation"
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 { 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 = [
{
key: "name",
header: "Project Name",
render: (project: Project) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{project.name}</div>
)
},
{
key: "state",
header: "State",
render: (project: Project) => {
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>
)
}
},
{
key: "corridor_name",
header: "Corridor",
},
]
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-20 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="All 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>
<DialogTitle className="flex items-center gap-2">
<FolderPlus className="h-5 w-5 text-blue-500" />
{isEditing ? 'Edit Project' : 'Create New Project'}
</DialogTitle>
<DialogDescription>
{isEditing ? 'Update disclosure details for your road infrastructure project' : 'Fill in the details for your new road infrastructure 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-1.5">
<Label htmlFor="name" className="text-sm font-semibold flex items-center gap-1">
Project Name <span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project Name"
className="h-10 text-sm"
required
/>
</div>
{/* State & Corridor Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="state" className="text-sm font-semibold">
State <span className="text-gray-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="state"
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="State"
className="h-10 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="corridor" className="text-sm font-semibold flex items-center gap-1">
<Route className="h-3.5 w-3.5 text-gray-400" />
Corridor Name <span className="text-gray-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="corridor"
value={corridorName}
onChange={(e) => setCorridorName(e.target.value)}
placeholder="Corridor Name"
className="h-10 text-sm"
/>
</div>
</div>
{/* GPS Coordinates Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Start Point */}
<div className="p-3 rounded-xl bg-blue-50/80 dark:bg-blue-900/40 border border-blue-200 dark:border-blue-800 shadow-sm">
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
<MapPin className="h-3 w-3" /> Start Point
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="start-lat" className="text-[10px] text-gray-500">Lat</Label>
<Input
id="start-lat"
type="number"
step="any"
value={startLat}
onChange={(e) => setStartLat(e.target.value)}
placeholder="Lat"
className="h-8 text-[11px]"
/>
</div>
<div className="space-y-1">
<Label htmlFor="start-lng" className="text-[10px] text-gray-500">Lng</Label>
<Input
id="start-lng"
type="number"
step="any"
value={startLng}
onChange={(e) => setStartLng(e.target.value)}
placeholder="Lng"
className="h-8 text-[11px]"
/>
</div>
</div>
</div>
{/* End Point */}
<div className="p-3 rounded-xl bg-blue-50/80 dark:bg-blue-900/40 border border-blue-200 dark:border-blue-800 shadow-sm">
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
<MapPin className="h-3 w-3" /> End Point
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="end-lat" className="text-[10px] text-gray-500">Lat</Label>
<Input
id="end-lat"
type="number"
step="any"
value={endLat}
onChange={(e) => setEndLat(e.target.value)}
placeholder="Lat"
className="h-8 text-[11px]"
/>
</div>
<div className="space-y-1">
<Label htmlFor="end-lng" className="text-[10px] text-gray-500">Lng</Label>
<Input
id="end-lng"
type="number"
step="any"
value={endLng}
onChange={(e) => setEndLng(e.target.value)}
placeholder="Lng"
className="h-8 text-[11px]"
/>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex-1 bg-linear-to-r from-blue-500 via-indigo-500 to-blue-700 hover:bg-blue-600 text-white"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{isEditing ? 'Updating...' : 'Creating...'}
</span>
) : (
<span className="flex items-center gap-2">
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <FolderPlus className="h-4 w-4" />}
{isEditing ? 'Update Project' : 'Create Project'}
</span>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,177 @@
"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 { SidebarNavigation } from "@/components/sidebar-navigation"
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"
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">
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
<p className="text-gray-500">Loading detection results...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl shadow-lg text-center">
<p className="text-red-500 font-medium">{error}</p>
<div className="flex gap-4 mt-4">
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">Back to Upload</Button>
<Button onClick={handleNewAnalysis} className="bg-linear-to-r from-indigo-500 to-purple-600 text-white">New Analysis</Button>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen max-w-340 mx-auto text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-20 min-h-screen">
<div className="container mx-auto px-6 py-8 max-w-full">
<div className="mb-8">
<PageHeader
title={getTitle()}
description={`Video ID: ${videoId}`}
icon={TrendingUp}
/>
</div>
{session && (
<div className="mb-6">
<div className="flex items-center justify-between rounded-md px-4 py-3 bg-white/60 backdrop-blur-sm">
<div className="flex items-center gap-12">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Project</span>
<span className="text-base font-bold text-gray-900 dark:text-white leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Package</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Location</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.locationName}
</span>
</div>
</div>
<Button onClick={handleNewAnalysis} variant="outline" size="sm" className="btn-blue-gradient">
Start New
</Button>
</div>
</div>
)}
{detectionData && (
<VideoPlayerSection
data={detectionData}
videoId={videoId}
videoFile={videoFile}
detectionType={detectionType}
projectId={session?.projectId || undefined}
/>
)}
<PoweredBy />
</div>
</main>
</div>
)
}

160
src/app/results/page.tsx Normal file
View File

@@ -0,0 +1,160 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Loader2, ArrowLeft, MapPin, Package, FolderKanban, RotateCcw, TrendingUp } from "lucide-react"
import VideoPlayerSection from "@/components/video-player-section"
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { PageHeader } from "@/components/page-header"
import {
type SessionContext,
loadSession,
loadVideoData,
isSessionValid,
clearSession
} from "@/lib/api"
import { getVideoFile, clearVideoFile } from "@/lib/video-storage"
import { DetectionData, DetectionType } from "@/lib/types"
const API_URL = process.env.NEXT_PUBLIC_API_URL
import { ROUTES } from "@/utils/routes"
export default function ResultsPage() {
const router = useRouter()
const [session, setSession] = useState<SessionContext | null>(null)
const [detectionData, setDetectionData] = useState<DetectionData | null>(null)
const [detectionType, setDetectionType] = useState<DetectionType>("pothole-detection")
const [videoId, setVideoId] = useState<string | null>(null)
const [videoFile, setVideoFile] = useState<File | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = 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 handleBackToUpload = () => {
router.push(ROUTES.UPLOAD)
}
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">
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
<p className="text-gray-500">Loading detection results...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl shadow-lg">
<p className="text-red-500">{error}</p>
<Button onClick={handleNewAnalysis} className="bg-gradient-to-r from-indigo-500 to-purple-600 text-white">Start New Analysis</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
{/* Sidebar Navigation */}
<SidebarNavigation />
{/* Main Content */}
<main className="ml-20 min-h-screen">
<div className="container mx-auto px-6 py-8 max-w-full">
{/* Refined Header */}
<div className="mb-8">
<PageHeader
title={getTitle()}
description="View your AI-powered road analysis results"
icon={TrendingUp}
/>
</div>
{/* Session Info Bar */}
{session && (
<div className="mb-6">
<div className="flex items-center justify-between p-4 rounded-xl bg-card border border-[var(--border)] shadow-sm">
<div className="flex items-center gap-12">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Project</span>
<span className="text-base font-bold text-gray-900 dark:text-white leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Package</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Location</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.locationName}
</span>
</div>
</div>
</div>
</div>
)}
{/* Video Player Section */}
{detectionData && videoId && (
<div>
<VideoPlayerSection
data={detectionData}
videoId={videoId}
videoFile={videoFile}
detectionType={detectionType}
projectId={session?.projectId || undefined}
/>
</div>
)}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,234 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useRouter, useParams } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Loader2, TrendingUp } from "lucide-react"
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { PageHeader } from "@/components/page-header"
import { PoweredBy } from "@/components/powered-by"
import {
type SessionContext,
loadSession,
} from "@/lib/api"
import { getVideoFile } from "@/lib/video-storage"
import { storeVideoFile } from "@/lib/video-storage"
import { ROUTES } from "@/utils/routes"
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 [detectionType, setDetectionType] = useState<string>("pothole-detection")
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">
<div className="p-8 rounded-2xl bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
</div>
</div>
)
}
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-20 min-h-screen relative overflow-hidden flex flex-col">
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 relative z-10 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-4">
<div className="rounded-md px-4 py-3 bg-white/60 backdrop-blur-sm">
<div className="flex items-center gap-12">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Project</span>
<span className="text-base font-bold text-gray-900 dark:text-white leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Package</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Location</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.locationName}
</span>
</div>
</div>
</div>
</div>
)}
<Card className="rounded-md overflow-hidden bg-white/60 backdrop-blur-xl 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-4xl space-y-8">
{/* Visual Spinner Area */}
<div className="relative">
<div className="h-24 w-24 rounded-full bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center border-4 border-blue-100 dark:border-blue-800/30">
<Loader2 className="h-10 w-10 text-blue-500 animate-spin" />
</div>
</div>
<div className="text-center space-y-2">
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
Processing Video
</h2>
<p className="text-sm font-medium text-gray-400 dark:text-gray-500 font-mono tracking-wider">
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-gray-500 dark:text-gray-400 text-xs uppercase tracking-widest">Processing Progress</span>
<span className="font-black text-blue-600 dark:text-blue-400 text-base">{progress}%</span>
</div>
<div className="h-3 rounded-full bg-blue-100 dark:bg-gray-800 overflow-hidden p-0.5 border border-blue-50 dark:border-gray-700">
<div
className="h-full bg-gradient-to-r from-blue-500 to-indigo-600 rounded-full transition-all duration-1000 ease-in-out shadow-[0_0_15px_rgba(59,130,246,0.5)]"
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-center">
<p className="text-sm font-bold text-gray-500 dark:text-gray-400 animate-pulse">
{statusMessage}
</p>
</div>
</div>
{error && (
<div className="w-full p-4 rounded-xl bg-red-50/50 dark:bg-red-950/20 border border-red-200/50 dark:border-red-800/50 text-center">
<p className="text-red-600 dark:text-red-400 text-sm font-medium">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-3 text-xs font-bold text-red-700 dark:text-red-300 underline underline-offset-4"
>
Retry Connection
</button>
</div>
)}
<div className="pt-8 text-center">
{/* <p className="text-xs font-medium text-gray-400 dark:text-gray-500/60 max-w-sm mx-auto leading-relaxed">
You can safely refresh this page — progress will resume automatically.
</p> */}
</div>
</div>
</Card>
<PoweredBy />
</div>
</main>
</div>
)
}

371
src/app/upload/page.tsx Normal file
View File

@@ -0,0 +1,371 @@
"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 { SidebarNavigation } from "@/components/sidebar-navigation"
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"
const API_URL = process.env.NEXT_PUBLIC_API_URL
const WS_URL = API_URL?.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://")
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">
<div className="p-8 rounded-2xl bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
</div>
</div>
)
}
return (
<div className="min-h-screen text-gray-900 dark:text-gray-100">
{/* Sidebar Navigation */}
<SidebarNavigation />
{/* Main Content */}
<main className="ml-20 min-h-screen relative overflow-hidden flex flex-col">
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 relative z-10 flex flex-col">
{/* Refined 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-4">
<div className="rounded-md px-4 py-3 bg-white/60 backdrop-blur-sm ">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-12">
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Project</span>
<span className="text-base font-bold text-gray-900 dark:text-white leading-tight">
{session.projectName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Package</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.packageName}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Location</span>
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
{session.locationName}
</span>
</div>
</div>
<Button
size="sm"
onClick={handleBackToSelection}
className="btn-blue-gradient"
>
Change Selection
</Button>
</div>
</div>
</div>
)}
{/* Upload Card */}
<Card className="bg-white/60 backdrop-blur-md rounded-xl overflow-hidden flex-1">
<CardHeader className="pb-4 ">
<CardTitle className="text-xl font-bold">
<span className="heading-gradient">
Upload Video
</span>
</CardTitle>
<CardDescription className="text-sm">
Select video file, detection type, vehicle speed, and method for analysis
</CardDescription>
</CardHeader>
<CardContent className="pt-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 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-10 bg-gray-50 dark:bg-gray-800 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-blue-100 dark:file:bg-blue-900/50 file:text-blue-600 dark:file:text-blue-400 file:font-medium file:text-xs hover:file:bg-blue-200"
/>
</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-10 bg-gray-50 dark:bg-gray-800 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-blue-100 dark:file:bg-blue-900/50 file:text-blue-600 dark:file:text-blue-400 file:font-medium file:text-xs hover:file:bg-blue-200"
/>
</div>
</div>
{/* Detection, Speed, and Method Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* 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-10 bg-gray-50 dark:bg-gray-800">
<SelectValue placeholder="Select detection type" />
</SelectTrigger>
<SelectContent>
{DETECTION_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<span className="font-medium">{type.label}</span>
</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-10 bg-gray-50 dark:bg-gray-800"
/>
</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-10 bg-gray-50 dark:bg-gray-800">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
{DETECTION_METHODS.map((method) => (
<SelectItem key={method.value} value={method.value}>
<span className="font-medium">{method.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<div className="w-4 h-4 rounded-full bg-red-100 dark:bg-red-900/50 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-[10px] font-bold text-red-500">!</span>
</div>
<p className="text-xs text-red-600 dark:text-red-400 leading-relaxed whitespace-pre-line">{error}</p>
</div>
)}
{/* Upload Button */}
<Button
onClick={handleUpload}
disabled={!file || uploading}
className={`w-full h-12 text-sm font-semibold transition-all rounded-full ${file && !uploading
? 'btn-blue-gradient'
: ''
}`}
size="lg"
>
{uploading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
</span>
) : (
"Upload and Process"
)}
</Button>
</CardContent>
</Card>
<PoweredBy />
</div>
</main>
</div>
)
}