refactor: remove unused packages
This commit is contained in:
618
src/app/dashboard/page.tsx
Normal file
618
src/app/dashboard/page.tsx
Normal 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
234
src/app/globals.css
Normal 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
53
src/app/layout.tsx
Normal 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
610
src/app/location/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
src/app/new-analysis/page.tsx
Normal file
55
src/app/new-analysis/page.tsx
Normal 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
392
src/app/package/page.tsx
Normal 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
6
src/app/page.tsx
Normal 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
412
src/app/project/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
src/app/results/[videoId]/page.tsx
Normal file
177
src/app/results/[videoId]/page.tsx
Normal 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
160
src/app/results/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
src/app/upload/[videoId]/page.tsx
Normal file
234
src/app/upload/[videoId]/page.tsx
Normal 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
371
src/app/upload/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/components/dashboard/compact-project-selector.tsx
Normal file
78
src/components/dashboard/compact-project-selector.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { FolderOpen, MapPin, Building2 } from "lucide-react"
|
||||
import { type Project } from "@/lib/api"
|
||||
|
||||
interface CompactProjectSelectorProps {
|
||||
projects: Project[]
|
||||
selectedProjectId: string | null
|
||||
onProjectChange: (projectId: string) => void
|
||||
selectedProject: Project | undefined
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function CompactProjectSelector({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
onProjectChange,
|
||||
selectedProject,
|
||||
isLoading = false
|
||||
}: CompactProjectSelectorProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 p-3 rounded-xl bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50">
|
||||
{/* Project Dropdown */}
|
||||
<div className="flex flex-col min-w-[120px]">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1">Project</span>
|
||||
<Select
|
||||
value={selectedProjectId || ""}
|
||||
onValueChange={onProjectChange}
|
||||
disabled={isLoading || projects.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-auto p-0 border-0 shadow-none focus:ring-0 text-sm font-bold text-gray-900 dark:text-white bg-transparent text-left">
|
||||
<SelectValue placeholder="Select project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Corridor Badge */}
|
||||
{selectedProject?.corridor_name && (
|
||||
<>
|
||||
<div className="h-8 w-px bg-gray-200 dark:bg-gray-700 mx-2" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1">Corridor</span>
|
||||
<span className="text-xs font-bold text-gray-600 dark:text-gray-400">
|
||||
{selectedProject.corridor_name}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* State Badge */}
|
||||
{selectedProject?.state && (
|
||||
<>
|
||||
<div className="h-8 w-px bg-gray-200 dark:bg-gray-700 mx-2" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1">State</span>
|
||||
<span className="text-xs font-bold text-gray-600 dark:text-gray-400">
|
||||
{selectedProject.state}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
src/components/dashboard/dashboard-map-content.tsx
Normal file
137
src/components/dashboard/dashboard-map-content.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from "react-leaflet"
|
||||
import { LatLngBounds, LatLng } from "leaflet"
|
||||
import "leaflet/dist/leaflet.css"
|
||||
import { type Detection } from "@/lib/api"
|
||||
|
||||
interface DashboardMapContentProps {
|
||||
detections: Detection[]
|
||||
}
|
||||
|
||||
// Component to auto-fit map bounds to show all markers
|
||||
function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
||||
const map = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [50, 50] })
|
||||
}
|
||||
}, [bounds, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function DashboardMapContent({ detections }: DashboardMapContentProps) {
|
||||
if (detections.length === 0) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted/20">
|
||||
<p className="text-muted-foreground">No detections to display</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate bounds to fit all markers
|
||||
const firstDetection = detections[0]
|
||||
const bounds = new LatLngBounds(
|
||||
new LatLng(firstDetection.latitude!, firstDetection.longitude!),
|
||||
new LatLng(firstDetection.latitude!, firstDetection.longitude!)
|
||||
)
|
||||
|
||||
detections.forEach(d => {
|
||||
if (d.latitude && d.longitude) {
|
||||
bounds.extend(new LatLng(d.latitude, d.longitude))
|
||||
}
|
||||
})
|
||||
|
||||
// Center point
|
||||
const center: [number, number] = [
|
||||
(bounds.getNorth() + bounds.getSouth()) / 2,
|
||||
(bounds.getEast() + bounds.getWest()) / 2
|
||||
]
|
||||
|
||||
// Get marker color based on detection type
|
||||
const getMarkerColor = (type: string) => {
|
||||
const t = type.toLowerCase()
|
||||
if (t === "pothole") {
|
||||
return { fill: "#ef4444", stroke: "#b91c1c" } // Red
|
||||
} else if (t === "defected_sign_board") {
|
||||
return { fill: "#3b82f6", stroke: "#1d4ed8" } // Blue
|
||||
} else if (t === "road_crack") {
|
||||
return { fill: "#f59e0b", stroke: "#b45309" } // Orange
|
||||
} else if (t === "damaged_road_marking") {
|
||||
return { fill: "#6366f1", stroke: "#4338ca" } // Indigo
|
||||
} else if (t === "good_sign_board") {
|
||||
return { fill: "#10b981", stroke: "#047857" } // Emerald
|
||||
}
|
||||
return { fill: "#64748b", stroke: "#475569" } // Default Slate
|
||||
}
|
||||
|
||||
// Get display name for detection type
|
||||
const getTypeName = (type: string) => {
|
||||
return type.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={13}
|
||||
className="h-full w-full"
|
||||
scrollWheelZoom={true}
|
||||
zoomAnimation={false}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Detection markers */}
|
||||
{detections.map((detection, idx) => {
|
||||
const colors = getMarkerColor(detection.type)
|
||||
const typeName = getTypeName(detection.type)
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`${detection.id}-${idx}`}
|
||||
center={[detection.latitude!, detection.longitude!]}
|
||||
radius={10}
|
||||
fillColor={colors.fill}
|
||||
color={colors.stroke}
|
||||
weight={2}
|
||||
opacity={1}
|
||||
fillOpacity={0.8}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm space-y-2 min-w-[180px]">
|
||||
<div className="font-bold text-base border-b pb-1">
|
||||
{typeName}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Class:</span>
|
||||
<span className="font-medium">{detection.class.replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Confidence:</span>
|
||||
<span className="font-medium">{(detection.confidence * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 border-t">
|
||||
<div className="text-xs text-muted-foreground mb-1">Coordinates</div>
|
||||
<div className="font-mono text-xs bg-muted/30 p-2 rounded">
|
||||
<div>Lat: {detection.latitude!.toFixed(6)}</div>
|
||||
<div>Lng: {detection.longitude!.toFixed(6)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Auto-fit bounds */}
|
||||
<FitBounds bounds={bounds} />
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
158
src/components/dashboard/dashboard-map.tsx
Normal file
158
src/components/dashboard/dashboard-map.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Loader2, MapPin, AlertTriangle, RectangleHorizontal } from "lucide-react"
|
||||
import { fetchAllDetections, type Detection } from "@/lib/api"
|
||||
|
||||
// Dynamically import the map to avoid SSR issues with Leaflet
|
||||
const DashboardMapContent = dynamic(
|
||||
() => import("./dashboard-map-content"),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
interface DashboardMapProps {
|
||||
className?: string
|
||||
selectedProjectId?: string | null
|
||||
selectedPackageId?: string | null
|
||||
selectedLocationId?: string | null
|
||||
projectSummary?: any
|
||||
}
|
||||
|
||||
export function DashboardMap({
|
||||
className,
|
||||
selectedProjectId,
|
||||
selectedPackageId,
|
||||
selectedLocationId,
|
||||
projectSummary
|
||||
}: DashboardMapProps) {
|
||||
const [detections, setDetections] = useState<Detection[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectSummary) {
|
||||
setDetections([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const filteredDetections: Detection[] = []
|
||||
|
||||
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 as any).locations[selectedLocationId] }
|
||||
: (pkg as any).locations || {}
|
||||
|
||||
for (const [locName, loc] of Object.entries(locationsToProcess)) {
|
||||
if (!loc) continue
|
||||
const locationDetections = (loc as any).detections || []
|
||||
filteredDetections.push(...locationDetections)
|
||||
}
|
||||
}
|
||||
|
||||
setDetections(filteredDetections)
|
||||
} catch (err) {
|
||||
console.error("Failed to extract detections:", err)
|
||||
setError("Failed to load detection data")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [projectSummary, selectedPackageId, selectedLocationId])
|
||||
|
||||
// Filter detections with valid GPS coordinates
|
||||
const validDetections = detections.filter(d => d.latitude && d.longitude)
|
||||
|
||||
// Count detections by category
|
||||
const counts = {
|
||||
defected_sign_board: validDetections.filter(d => d.type?.toLowerCase() === "defected_sign_board").length,
|
||||
pothole: validDetections.filter(d => d.type?.toLowerCase() === "pothole").length,
|
||||
road_crack: validDetections.filter(d => d.type?.toLowerCase() === "road_crack").length,
|
||||
damaged_road_marking: validDetections.filter(d => d.type?.toLowerCase() === "damaged_road_marking").length,
|
||||
good_sign_board: validDetections.filter(d => d.type?.toLowerCase() === "good_sign_board").length
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`overflow-hidden pb-0 bg-white/60 rounded-b-none shadow-none border-none ${className}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-md bg-linear-to-b from-[#225999] to-[#56A5FF] flex items-center justify-center ">
|
||||
<MapPin className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold text-[#2563eb]">
|
||||
Detection Map
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isLoading ? "Loading..." : `${validDetections.length} detections with GPS coordinates`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact Color Legend */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-4 gap-y-1 text-[10px] max-w-[60%]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#ef4444] shadow-sm shadow-[#ef4444]/30" />
|
||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Potholes ({counts.pothole})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#3b82f6] shadow-sm shadow-[#3b82f6]/30" />
|
||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Defected Signs ({counts.defected_sign_board})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#f59e0b] shadow-sm shadow-[#f59e0b]/30" />
|
||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Cracks ({counts.road_crack})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#6366f1] shadow-sm shadow-[#6366f1]/30" />
|
||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Markings ({counts.damaged_road_marking})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#10b981] shadow-sm shadow-[#10b981]/30" />
|
||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Good Signs ({counts.good_sign_board})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-[500px] p-2 w-full relative">
|
||||
{isLoading ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900/50">
|
||||
<Loader2 className="h-8 w-8 text-indigo-500" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900/50">
|
||||
<p className="text-gray-500">{error}</p>
|
||||
</div>
|
||||
) : validDetections.length === 0 ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">No detections with GPS coordinates found</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Process some videos to see detections on the map</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DashboardMapContent detections={validDetections} />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
59
src/components/dashboard/dashboard-skeleton.tsx
Normal file
59
src/components/dashboard/dashboard-skeleton.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="bg-white/60 backdrop-blur-lg border-none shadow-none overflow-hidden py-8 px-6">
|
||||
<CardContent className="p-0 flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
<Skeleton className="w-16 h-16 rounded-lg shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Row Skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<Card key={i} className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden border-none shadow-none">
|
||||
<CardHeader className="pb-2 border-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-10 h-10 rounded-md" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 h-[350px] flex items-center justify-center">
|
||||
<Skeleton className="h-[300px] w-full max-w-[300px] rounded-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Map Skeleton */}
|
||||
<Card className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden border-none shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-10 h-10 rounded-md" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-32 rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<Skeleton className="h-[500px] w-full rounded-md" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
src/components/dashboard/detection-donut-chart.tsx
Normal file
159
src/components/dashboard/detection-donut-chart.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
interface DetectionDonutChartProps {
|
||||
defectedSignboard: number
|
||||
pothole: number
|
||||
roadCrack: number
|
||||
damagedRoadMarking: number
|
||||
goodSignboard: number
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
defectedSignboard: "#3b82f6", // Blue
|
||||
pothole: "#ef4444", // Red
|
||||
roadCrack: "#f59e0b", // Amber/Orange
|
||||
damagedRoadMarking: "#6366f1", // Indigo
|
||||
goodSignboard: "#10b981" // Emerald
|
||||
}
|
||||
|
||||
export function DetectionDonutChart({
|
||||
defectedSignboard,
|
||||
pothole,
|
||||
roadCrack,
|
||||
damagedRoadMarking,
|
||||
goodSignboard,
|
||||
isLoading = false
|
||||
}: DetectionDonutChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-primary/50" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const total = defectedSignboard + pothole + roadCrack + damagedRoadMarking + goodSignboard
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">No detections found</p>
|
||||
<p className="text-xs mt-1">Process videos to see data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = [
|
||||
{ name: "Defected Signboards", value: defectedSignboard, color: COLORS.defectedSignboard },
|
||||
{ name: "Potholes", value: pothole, color: COLORS.pothole },
|
||||
{ name: "Road Cracks", value: roadCrack, color: COLORS.roadCrack },
|
||||
{ name: "Damaged Markings", value: damagedRoadMarking, color: COLORS.damagedRoadMarking },
|
||||
{ name: "Good Signboards", value: goodSignboard, color: COLORS.goodSignboard }
|
||||
].filter(item => item.value > 0)
|
||||
|
||||
return (
|
||||
<div className="h-[220px] relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<defs>
|
||||
<linearGradient id="gradPothole" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ff8a8a" />
|
||||
<stop offset="100%" stopColor="#ef4444" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradDefectedSign" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#60a5fa" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradCrack" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#fbbf24" />
|
||||
<stop offset="100%" stopColor="#f59e0b" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradMarking" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#818cf8" />
|
||||
<stop offset="100%" stopColor="#6366f1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="gradGoodSign" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#34d399" />
|
||||
<stop offset="100%" stopColor="#10b981" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
innerRadius={50}
|
||||
outerRadius={70}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
strokeWidth={0}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{data.map((entry, index) => {
|
||||
const gradId = entry.name === "Potholes" ? "gradPothole" :
|
||||
entry.name === "Defected Signboards" ? "gradDefectedSign" :
|
||||
entry.name === "Road Cracks" ? "gradCrack" :
|
||||
entry.name === "Damaged Markings" ? "gradMarking" : "gradGoodSign"
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={`url(#${gradId})`}
|
||||
fillOpacity={1}
|
||||
className="hover:fill-opacity-80"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0]
|
||||
return (
|
||||
<div className="bg-background/95 backdrop-blur-sm border border-border rounded-lg px-3 py-2 shadow-lg">
|
||||
<p className="font-medium text-sm">{data.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Count: <span className="font-semibold text-foreground">{data.value}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{((Number(data.value) / total) * 100).toFixed(1)}% of total
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={40}
|
||||
content={({ payload }) => (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 mt-2">
|
||||
{payload?.map((entry, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-[9px] text-muted-foreground whitespace-nowrap">
|
||||
{entry.value}: {data[index].value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Center label */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none" style={{ marginTop: '-55px' }}>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-extrabold text-[#2563eb]">{total}</p>
|
||||
<p className="text-[9px] uppercase tracking-wider text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
src/components/dashboard/filter-selector.tsx
Normal file
125
src/components/dashboard/filter-selector.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { FolderOpen, Package, MapPin } from "lucide-react"
|
||||
import { type Project } from "@/lib/api"
|
||||
|
||||
interface FilterSelectorProps {
|
||||
projects: Project[]
|
||||
selectedProjectId: string | null
|
||||
selectedPackageId: string | null
|
||||
selectedLocationId: string | null
|
||||
onProjectChange: (projectId: string) => void
|
||||
onPackageChange: (packageId: string) => void
|
||||
onLocationChange: (locationId: string) => void
|
||||
packages: Array<{ id: string; name: string }>
|
||||
locations: Array<{ id: string; name: string }>
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function FilterSelector({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
selectedPackageId,
|
||||
selectedLocationId,
|
||||
onProjectChange,
|
||||
onPackageChange,
|
||||
onLocationChange,
|
||||
packages,
|
||||
locations,
|
||||
isLoading = false
|
||||
}: FilterSelectorProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* Project Dropdown */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="p-1 rounded-md bg-blue-100 dark:bg-blue-900/50">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-blue-600" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Project</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedProjectId || ""}
|
||||
onValueChange={onProjectChange}
|
||||
disabled={isLoading || projects.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm bg-slate-50 border border-slate-200 shadow-none ring-0! focus:ring-0 px-3 w-full rounded-lg">
|
||||
<SelectValue placeholder="Select project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-slate-200">
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} className="rounded-lg">
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Package Dropdown */}
|
||||
{selectedProjectId && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="p-1 rounded-md bg-emerald-100 dark:bg-emerald-900/50">
|
||||
<Package className="h-3.5 w-3.5 text-emerald-600" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Package</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedPackageId || "all"}
|
||||
onValueChange={onPackageChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm bg-slate-50 border border-slate-200 shadow-none ring-0! focus:ring-0 px-3 w-full rounded-lg">
|
||||
<SelectValue placeholder="All packages" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-slate-200">
|
||||
<SelectItem value="all" className="rounded-lg">All Packages</SelectItem>
|
||||
{packages.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id} className="rounded-lg">
|
||||
{pkg.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location Dropdown */}
|
||||
{selectedPackageId && selectedPackageId !== "all" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="p-1 rounded-md bg-purple-100 dark:bg-purple-900/50">
|
||||
<MapPin className="h-3.5 w-3.5 text-purple-600" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Location</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedLocationId || "all"}
|
||||
onValueChange={onLocationChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm bg-slate-50 border border-slate-200 shadow-none ring-0! focus:ring-0 px-3 w-full rounded-lg">
|
||||
<SelectValue placeholder="All locations" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border-slate-200">
|
||||
<SelectItem value="all" className="rounded-lg">All Locations</SelectItem>
|
||||
{locations.map((loc) => (
|
||||
<SelectItem key={loc.id} value={loc.id} className="rounded-lg">
|
||||
{loc.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/dashboard/gradient-stats-card.tsx
Normal file
107
src/components/dashboard/gradient-stats-card.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
|
||||
interface GradientStatsCardProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
value: number | string
|
||||
icon: LucideIcon
|
||||
gradient: "green" | "coral" | "blue" | "purple" | "orange" | "indigo" | "emerald"
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const gradientStyles = {
|
||||
green: {
|
||||
background: "bg-card",
|
||||
border: "border-l-4 border-l-[var(--border)]",
|
||||
iconBg: "bg-gradient-to-br from-blue-400 to-blue-600",
|
||||
|
||||
},
|
||||
coral: {
|
||||
background: "bg-card",
|
||||
border: "border-l-4 border-l-[var(--border)]",
|
||||
iconBg: "bg-gradient-to-br from-blue-400 to-blue-600",
|
||||
|
||||
},
|
||||
blue: {
|
||||
background: "bg-card",
|
||||
border: "border-l-4 border-l-[var(--border)]",
|
||||
iconBg: "bg-gradient-to-br from-blue-400 to-blue-600",
|
||||
|
||||
},
|
||||
purple: {
|
||||
background: "bg-card",
|
||||
border: "border-l-4 border-l-[var(--border)]",
|
||||
iconBg: "bg-gradient-to-br from-blue-400 to-blue-600",
|
||||
|
||||
},
|
||||
orange: {
|
||||
background: "bg-card",
|
||||
border: "border-l-4 border-l-[var(--border)]",
|
||||
iconBg: "bg-gradient-to-br from-blue-400 to-blue-600",
|
||||
|
||||
},
|
||||
indigo: {
|
||||
background: "bg-card",
|
||||
border: "border-l-4 border-l-[var(--border)]",
|
||||
iconBg: "bg-gradient-to-br from-blue-400 to-blue-600",
|
||||
|
||||
},
|
||||
emerald: {
|
||||
background: "bg-card",
|
||||
border: "border-l-4 border-l-[var(--border)]",
|
||||
iconBg: "bg-gradient-to-br from-blue-400 to-blue-600",
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export function GradientStatsCard({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
icon: Icon,
|
||||
gradient,
|
||||
isLoading = false
|
||||
}: GradientStatsCardProps) {
|
||||
const styles = gradientStyles[gradient]
|
||||
|
||||
return (
|
||||
<Card className="bg-white/60 backdrop-blur-lg border-none shadow-none hover:shadow-none overflow-hidden py-8 px-6">
|
||||
<CardContent className="p-0 flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-blue-600/70 dark:text-blue-400/70 uppercase tracking-widest">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="h-14 w-24 bg-gray-100 dark:bg-gray-800 rounded-xl mt-2" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-4xl font-bold heading-blue-gradient">
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs font-medium text-gray-500 mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #225999 0%, #56A5FF 100%)",
|
||||
}}
|
||||
className={`
|
||||
w-16 h-16 self-start rounded-lg flex items-center justify-center
|
||||
`}>
|
||||
<Icon className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
179
src/components/dashboard/location-bar-chart.tsx
Normal file
179
src/components/dashboard/location-bar-chart.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
interface LocationData {
|
||||
name: string
|
||||
defected_sign_board: number
|
||||
pothole: number
|
||||
road_crack: number
|
||||
damaged_road_marking: number
|
||||
good_sign_board: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface LocationBarChartProps {
|
||||
data: LocationData[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
defected_sign_board: "#60a5fa", // Lighter Blue
|
||||
pothole: "#ff8a8a", // Lighter Red
|
||||
road_crack: "#fbbf24", // Lighter Orange
|
||||
damaged_road_marking: "#818cf8", // Lighter Indigo
|
||||
good_sign_board: "#34d399" // Lighter Emerald
|
||||
}
|
||||
|
||||
export function LocationBarChart({ data, isLoading = false }: LocationBarChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-primary/50" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">No location data available</p>
|
||||
<p className="text-xs mt-1">Process videos to see detections by location</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 20 }}
|
||||
barCategoryGap="10%"
|
||||
barGap={2}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="barPothole" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ff8a8a" />
|
||||
<stop offset="100%" stopColor="#ef4444" />
|
||||
</linearGradient>
|
||||
<linearGradient id="barDefectedSign" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#60a5fa" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
<linearGradient id="barCrack" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#fbbf24" />
|
||||
<stop offset="100%" stopColor="#f59e0b" />
|
||||
</linearGradient>
|
||||
<linearGradient id="barMarking" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#818cf8" />
|
||||
<stop offset="100%" stopColor="#6366f1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="barGoodSign" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#34d399" />
|
||||
<stop offset="100%" stopColor="#10b981" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="hsl(var(--muted-foreground) / 0.1)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 9, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={25}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-background/95 backdrop-blur-sm border border-border rounded-lg px-3 py-2 shadow-lg">
|
||||
<p className="font-medium text-xs mb-1">{label}</p>
|
||||
{payload.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{(entry.name as string).replace(/_/g, ' ')}:</span>
|
||||
<span className="font-semibold">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={30}
|
||||
content={({ payload }) => (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 mb-2">
|
||||
{payload?.map((entry, index) => (
|
||||
<div key={`legend-${index}`} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-sm"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-[9px] text-muted-foreground whitespace-nowrap">
|
||||
{(entry.value as string).replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="pothole"
|
||||
name="Pothole"
|
||||
fill="url(#barPothole)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="defected_sign_board"
|
||||
name="Defected Signboard"
|
||||
fill="url(#barDefectedSign)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="road_crack"
|
||||
name="Road Crack"
|
||||
fill="url(#barCrack)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="damaged_road_marking"
|
||||
name="Damaged Marking"
|
||||
fill="url(#barMarking)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="good_sign_board"
|
||||
name="Good Signboard"
|
||||
fill="url(#barGoodSign)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
230
src/components/data-table.tsx
Normal file
230
src/components/data-table.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Edit2, Trash2, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render?: (item: T) => React.ReactNode
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
title: string
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
onAddNew: () => void
|
||||
addButtonText: string
|
||||
isLoading?: boolean
|
||||
onEdit?: (item: T) => void
|
||||
onDelete?: (item: T) => void
|
||||
pagination?: {
|
||||
skip: number
|
||||
limit: number
|
||||
totalItems?: number
|
||||
onPageChange: (newSkip: number) => void
|
||||
onLimitChange: (newLimit: number) => void
|
||||
}
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
title,
|
||||
data,
|
||||
columns,
|
||||
onAddNew,
|
||||
addButtonText,
|
||||
isLoading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
pagination
|
||||
}: DataTableProps<T>) {
|
||||
const showActions = !!onEdit || !!onDelete;
|
||||
const totalCols = columns.length + (showActions ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header with Title and Add Button */}
|
||||
<div className="flex justify-between items-center p-3 ">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold tracking-tight heading-blue-gradient">{title}</h2>
|
||||
<span className="badge-blue min-w-[28px] h-[28px] px-2">
|
||||
{data.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onAddNew}
|
||||
className="btn-blue-gradient rounded-full text-sm px-4 py-2.5 h-auto flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<div className="overflow-auto max-h-[450px]">
|
||||
<table className="min-w-full rounded-lg!">
|
||||
<thead className="bg-white/40 backdrop-blur-md rounded-lg! sticky top-0 z-10">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-4 py-3 text-slate-500 text-left font-bold"
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
{showActions && (
|
||||
<th className="px-4 py-3 text-slate-500 text-left font-bold">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-300 backdrop-blur-lg bg-white/70">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100 dark:border-gray-800">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-4">
|
||||
<Skeleton className="h-4 w-full max-w-[120px]" />
|
||||
</td>
|
||||
))}
|
||||
{showActions && (
|
||||
<td className="px-4 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-sm" />
|
||||
<Skeleton className="h-8 w-8 rounded-sm" />
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={totalCols} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-2">
|
||||
<Plus className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">No data available</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Click "{addButtonText}" to get started</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="hover:bg-blue-50/30 dark:hover:bg-blue-900/10 transition-colors duration-200"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3 text-[13px] text-gray-700 dark:text-gray-300 font-medium">
|
||||
{col.render ? col.render(item) : (item[col.key as keyof T] !== null && item[col.key as keyof T] !== undefined ? String(item[col.key as keyof T]) : "—")}
|
||||
</td>
|
||||
))}
|
||||
{showActions && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<TooltipProvider>
|
||||
{onEdit && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(item)}
|
||||
className="h-8 w-8 rounded-sm bg-white text-blue-500 border-slate-200! border hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Edit</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(item)}
|
||||
className="h-8 w-8 rounded-sm border border-slate-200! bg-white text-red-400 hover:bg-red-400 hover:text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white/40 backdrop-blur-md rounded-b-lg border-t border-gray-200 mt-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-500">Rows per page:</span>
|
||||
<select
|
||||
value={pagination.limit}
|
||||
onChange={(e) => pagination.onLimitChange(Number(e.target.value))}
|
||||
className="bg-transparent text-xs font-bold text-gray-700 outline-none cursor-pointer focus:ring-0"
|
||||
>
|
||||
{[10, 25, 50, 100].map((pageSize) => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
{pageSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
Showing {pagination.skip + 1} - {pagination.skip + data.length}
|
||||
{pagination.totalItems !== undefined && ` of ${pagination.totalItems}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={pagination.skip === 0 || isLoading}
|
||||
onClick={() => pagination.onPageChange(Math.max(0, pagination.skip - pagination.limit))}
|
||||
className="h-8 w-8 p-0 rounded-md hover:bg-blue-50 text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={data.length < pagination.limit || isLoading}
|
||||
onClick={() => pagination.onPageChange(pagination.skip + pagination.limit)}
|
||||
className="h-8 w-8 p-0 rounded-md hover:bg-blue-50 text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
176
src/components/map-modal.tsx
Normal file
176
src/components/map-modal.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { MapContainer, TileLayer, Polyline, CircleMarker, Popup, useMap } from "react-leaflet"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { LatLngBounds, LatLng } from "leaflet"
|
||||
import "leaflet/dist/leaflet.css"
|
||||
|
||||
type Detection = {
|
||||
id: number
|
||||
type: string
|
||||
class: string
|
||||
confidence: number
|
||||
latitude: number
|
||||
longitude: number
|
||||
frame_number: number
|
||||
}
|
||||
|
||||
type MapModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
detections: Detection[]
|
||||
detectionType: "pothole-detection" | "sign-board-detection" | "pot-sign-detection"
|
||||
}
|
||||
|
||||
// Component to auto-fit map bounds to show all markers
|
||||
function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
||||
const map = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !bounds.isValid()) return
|
||||
|
||||
// Small delay to ensure the container dimensions are fully calculated (prevents _leaflet_pos error)
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize()
|
||||
map.fitBounds(bounds, {
|
||||
padding: [50, 50],
|
||||
maxZoom: 16,
|
||||
animate: true
|
||||
})
|
||||
}, 200)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [bounds, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function MapModal({ open, onClose, detections, detectionType }: MapModalProps) {
|
||||
// Filter detections with valid GPS coordinates
|
||||
const validDetections = detections.filter(d => d.latitude && d.longitude)
|
||||
|
||||
if (validDetections.length === 0) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl h-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detection Map</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No GPS coordinates available for detections
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Extract route coordinates (start to end)
|
||||
const routeCoordinates: [number, number][] = validDetections.map(d => [d.latitude, d.longitude])
|
||||
|
||||
// Calculate bounds to fit all markers
|
||||
const bounds = new LatLngBounds(
|
||||
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
|
||||
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1])
|
||||
)
|
||||
routeCoordinates.forEach(coord => bounds.extend(new LatLng(coord[0], coord[1])))
|
||||
|
||||
// Center point (middle of route)
|
||||
const center: [number, number] = [
|
||||
(bounds.getNorth() + bounds.getSouth()) / 2,
|
||||
(bounds.getEast() + bounds.getWest()) / 2
|
||||
]
|
||||
|
||||
const isCombined = detectionType === "pot-sign-detection"
|
||||
const isPothole = detectionType === "pothole-detection"
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl h-[80vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{isCombined ? "Pothole & Signboard" : isPothole ? "Pothole" : "Signboard"} Detection Map
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{validDetections.length} detection{validDetections.length !== 1 ? "s" : ""} with GPS coordinates
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 w-full bg-gray-50 dark:bg-gray-950 relative">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={13}
|
||||
className="h-full w-full"
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Route line */}
|
||||
<Polyline
|
||||
positions={routeCoordinates}
|
||||
color="#3b82f6"
|
||||
weight={3}
|
||||
opacity={0.7}
|
||||
/>
|
||||
|
||||
{/* Detection markers */}
|
||||
{validDetections.map((detection, idx) => {
|
||||
const type = (detection.type || "").toLowerCase()
|
||||
let markerColor = "#64748b" // Default Slate
|
||||
let strokeColor = "#475569"
|
||||
|
||||
if (type === "pothole") {
|
||||
markerColor = "#ef4444"
|
||||
strokeColor = "#b91c1c"
|
||||
} else if (type === "defected_sign_board") {
|
||||
markerColor = "#3b82f6"
|
||||
strokeColor = "#1d4ed8"
|
||||
} else if (type === "road_crack") {
|
||||
markerColor = "#f59e0b"
|
||||
strokeColor = "#b45309"
|
||||
} else if (type === "damaged_road_marking") {
|
||||
markerColor = "#6366f1"
|
||||
strokeColor = "#4338ca"
|
||||
} else if (type === "good_sign_board") {
|
||||
markerColor = "#10b981"
|
||||
strokeColor = "#047857"
|
||||
}
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`${detection.id}-${idx}`}
|
||||
center={[detection.latitude, detection.longitude]}
|
||||
radius={8}
|
||||
fillColor={markerColor}
|
||||
color={strokeColor}
|
||||
weight={2}
|
||||
opacity={1}
|
||||
fillOpacity={0.8}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="font-semibold capitalize">
|
||||
{(detection.type || "").replace(/_/g, " ")} #{detection.id}
|
||||
</div>
|
||||
<div>Frame: {detection.frame_number}</div>
|
||||
<div>Confidence: {(detection.confidence * 100).toFixed(1)}%</div>
|
||||
<div className="font-mono text-[10px]">
|
||||
{detection.latitude.toFixed(6)}, {detection.longitude.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Auto-fit bounds */}
|
||||
<FitBounds bounds={bounds} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
30
src/components/page-header.tsx
Normal file
30
src/components/page-header.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { LucideIcon } from "lucide-react"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description: string
|
||||
icon?: LucideIcon
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, icon: Icon, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 rounded-lg bg-blue-gradient flex items-center justify-center relative overflow-hidden">
|
||||
|
||||
{Icon && <Icon className="h-8 w-8 text-white relative z-10" />}
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl font-bold heading-blue-gradient">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm font-medium">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/components/powered-by.tsx
Normal file
18
src/components/powered-by.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Image from "next/image"
|
||||
|
||||
export function PoweredBy() {
|
||||
return (
|
||||
<div className=" flex items-center justify-center gap-2 mt-3 transition-opacity duration-300">
|
||||
<span className="font-medium powered-blue-gradient">
|
||||
Powered by
|
||||
</span>
|
||||
<Image
|
||||
src="/sg.svg"
|
||||
alt="Sentient Geeks"
|
||||
width={130}
|
||||
height={20}
|
||||
className="fill-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
333
src/components/project-selection-section.tsx
Normal file
333
src/components/project-selection-section.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import {
|
||||
fetchProjects,
|
||||
fetchPackagesByProject,
|
||||
fetchLocationsByPackage,
|
||||
type Project,
|
||||
type Package as PackageType,
|
||||
type Location,
|
||||
type SessionContext
|
||||
} from "@/lib/api"
|
||||
|
||||
type ProjectSelectionSectionProps = {
|
||||
onSelectionComplete: (session: SessionContext) => void
|
||||
}
|
||||
|
||||
export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectionSectionProps) {
|
||||
// Data states
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [packages, setPackages] = useState<PackageType[]>([])
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
|
||||
// Selection states
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null)
|
||||
const [selectedPackage, setSelectedPackage] = useState<PackageType | null>(null)
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null)
|
||||
|
||||
// Loading states
|
||||
const [loadingProjects, setLoadingProjects] = useState(true)
|
||||
const [loadingPackages, setLoadingPackages] = useState(false)
|
||||
const [loadingLocations, setLoadingLocations] = useState(false)
|
||||
|
||||
// Error state
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load projects on mount
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setLoadingProjects(true)
|
||||
setError(null)
|
||||
const data = await fetchProjects()
|
||||
setProjects(data)
|
||||
} catch (err) {
|
||||
console.error("Failed to load projects:", err)
|
||||
setError("Failed to load projects. Please check if the backend is running.")
|
||||
} finally {
|
||||
setLoadingProjects(false)
|
||||
}
|
||||
}
|
||||
loadProjects()
|
||||
}, [])
|
||||
|
||||
// Load packages when project changes
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setPackages([])
|
||||
setSelectedPackage(null)
|
||||
return
|
||||
}
|
||||
|
||||
const loadPackages = async () => {
|
||||
try {
|
||||
setLoadingPackages(true)
|
||||
setError(null)
|
||||
setSelectedPackage(null)
|
||||
setSelectedLocation(null)
|
||||
setLocations([])
|
||||
const data = await fetchPackagesByProject(selectedProject.id)
|
||||
setPackages(data)
|
||||
} catch (err) {
|
||||
console.error("Failed to load packages:", err)
|
||||
setError("Failed to load packages for the selected project.")
|
||||
} finally {
|
||||
setLoadingPackages(false)
|
||||
}
|
||||
}
|
||||
loadPackages()
|
||||
}, [selectedProject])
|
||||
|
||||
// Load locations when package changes
|
||||
useEffect(() => {
|
||||
if (!selectedPackage) {
|
||||
setLocations([])
|
||||
setSelectedLocation(null)
|
||||
return
|
||||
}
|
||||
|
||||
const loadLocations = async () => {
|
||||
try {
|
||||
setLoadingLocations(true)
|
||||
setError(null)
|
||||
setSelectedLocation(null)
|
||||
const data = await fetchLocationsByPackage(selectedPackage.id)
|
||||
setLocations(data)
|
||||
} catch (err) {
|
||||
console.error("Failed to load locations:", err)
|
||||
setError("Failed to load locations for the selected package.")
|
||||
} finally {
|
||||
setLoadingLocations(false)
|
||||
}
|
||||
}
|
||||
loadLocations()
|
||||
}, [selectedPackage])
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
const project = projects.find(p => p.id === projectId) || null
|
||||
setSelectedProject(project)
|
||||
}
|
||||
|
||||
const handlePackageChange = (packageId: string) => {
|
||||
const pkg = packages.find(p => p.id === packageId) || null
|
||||
setSelectedPackage(pkg)
|
||||
}
|
||||
|
||||
const handleLocationChange = (locationId: string) => {
|
||||
const location = locations.find(l => l.id === locationId) || null
|
||||
setSelectedLocation(location)
|
||||
}
|
||||
|
||||
const handleProceed = () => {
|
||||
if (selectedProject && selectedPackage && selectedLocation) {
|
||||
onSelectionComplete({
|
||||
projectId: selectedProject.id,
|
||||
projectName: selectedProject.name,
|
||||
packageId: selectedPackage.id,
|
||||
packageName: selectedPackage.name,
|
||||
locationId: selectedLocation.id,
|
||||
locationName: selectedLocation.segment_name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isComplete = selectedProject && selectedPackage && selectedLocation
|
||||
|
||||
// Step status helpers
|
||||
const getStepStatus = (step: number) => {
|
||||
if (step === 1) return selectedProject ? 'completed' : 'active'
|
||||
if (step === 2) return selectedPackage ? 'completed' : selectedProject ? 'active' : 'pending'
|
||||
if (step === 3) return selectedLocation ? 'completed' : selectedPackage ? 'active' : 'pending'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/40 backdrop-blur-sm overflow-hidden">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">Select Project Location</CardTitle>
|
||||
<CardDescription className="mt-2 text-base">
|
||||
Select Project, Package & Location to begin intelligent road analysis with advanced computer vision.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{/* Step Progress Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{[1, 2, 3].map((step, index) => {
|
||||
const status = getStepStatus(step)
|
||||
const labels = ['Project', 'Package', 'Location']
|
||||
return (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold transition-colors ${status === 'completed' ? 'bg-linear-to-b from-[#225999] to-[#56A5FF] text-primary-foreground' :
|
||||
status === 'active' ? 'bg-linear-to-b from-[#225999] to-[#56A5FF] text-primary-foreground ring-4 ring-primary/20' :
|
||||
'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step}
|
||||
</div>
|
||||
<span className={`text-xs mt-1.5 font-medium ${status === 'pending' ? 'text-muted-foreground/60' : 'text-foreground'
|
||||
}`}>
|
||||
{labels[index]}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div className={`w-12 h-0.5 mx-2 mt-[-16px] rounded-full ${getStepStatus(step + 1) !== 'pending' ? 'bg-linear-to-b from-[#225999] to-[#56A5FF]' : 'bg-border'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-destructive/10 border border-destructive/20 text-destructive">
|
||||
<div className="w-5 h-5 rounded-full bg-destructive/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-xs font-bold">!</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Project Dropdown */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="project" className="text-sm font-semibold text-foreground">
|
||||
Project
|
||||
</Label>
|
||||
<div className="input-glow rounded-lg">
|
||||
<Select
|
||||
value={selectedProject?.id || ""}
|
||||
onValueChange={handleProjectChange}
|
||||
disabled={loadingProjects}
|
||||
>
|
||||
<SelectTrigger id="project" className="h-12 bg-background/50 border-border/50 hover:border-primary/50">
|
||||
{loadingProjects ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select 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>
|
||||
</div>
|
||||
|
||||
{/* Package Dropdown */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="package" className="text-sm font-semibold text-foreground">
|
||||
Package
|
||||
</Label>
|
||||
<div className="input-glow rounded-lg">
|
||||
<Select
|
||||
value={selectedPackage?.id || ""}
|
||||
onValueChange={handlePackageChange}
|
||||
disabled={!selectedProject || loadingPackages}
|
||||
>
|
||||
<SelectTrigger id="package" className="h-12 bg-background/50 border-border/50 hover:border-primary/50">
|
||||
{loadingPackages ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={selectedProject ? "Select 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>
|
||||
</div>
|
||||
|
||||
{/* Location Dropdown */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="location" className="text-sm font-semibold text-foreground">
|
||||
Location
|
||||
</Label>
|
||||
<div className="input-glow rounded-lg">
|
||||
<Select
|
||||
value={selectedLocation?.id || ""}
|
||||
onValueChange={handleLocationChange}
|
||||
disabled={!selectedPackage || loadingLocations}
|
||||
>
|
||||
<SelectTrigger id="location" className="h-12 bg-background/50 border-border/50 hover:border-primary/50">
|
||||
{loadingLocations ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={selectedPackage ? "Select a location" : "Select package first"} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations.map((location) => (
|
||||
<SelectItem key={location.id} value={location.id}>
|
||||
<span className="font-medium">{location.segment_name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Summary */}
|
||||
{isComplete && (
|
||||
<div className="px-4 py-3 rounded-lg bg-primary/5 border border-primary/15">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">Path:</span>
|
||||
<span className="text-foreground">{selectedProject?.name}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{selectedPackage?.name}</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{selectedLocation?.segment_name}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proceed Button */}
|
||||
<Button
|
||||
onClick={handleProceed}
|
||||
disabled={!isComplete}
|
||||
className={`w-full h-14 text-base font-semibold ${isComplete ? 'btn-blue-gradient text-white' : ''
|
||||
}`}
|
||||
size="lg"
|
||||
>
|
||||
{isComplete ? (
|
||||
"Proceed to Upload"
|
||||
) : (
|
||||
"Complete all selections to proceed"
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
175
src/components/sidebar-navigation.tsx
Normal file
175
src/components/sidebar-navigation.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { LayoutDashboard, Plus, User, FolderPlus, Package, MapPin } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
import { ROUTES } from "@/utils/routes"
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
icon: React.ElementType
|
||||
gradient: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: ROUTES.DASHBOARD,
|
||||
icon: LayoutDashboard,
|
||||
gradient: "from-blue-400 to-indigo-500"
|
||||
},
|
||||
{
|
||||
title: "Project",
|
||||
href: ROUTES.PROJECT,
|
||||
icon: FolderPlus,
|
||||
gradient: "from-blue-400 to-blue-600"
|
||||
},
|
||||
{
|
||||
title: "Package",
|
||||
href: ROUTES.PACKAGE,
|
||||
icon: Package,
|
||||
gradient: "from-violet-400 to-purple-500"
|
||||
},
|
||||
{
|
||||
title: "Location",
|
||||
href: ROUTES.LOCATION,
|
||||
icon: MapPin,
|
||||
gradient: "from-amber-400 to-orange-500"
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
href: ROUTES.ACCOUNT,
|
||||
icon: User,
|
||||
gradient: "from-purple-400 to-pink-500",
|
||||
disabled: true
|
||||
}
|
||||
]
|
||||
|
||||
export function SidebarNavigation() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const handleNavigation = (item: NavItem) => {
|
||||
if (item.disabled) return
|
||||
router.push(item.href)
|
||||
}
|
||||
|
||||
const isNewAnalysisActive = pathname === ROUTES.NEW_ANALYSIS
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 h-screen w-20 bg-white/40 backdrop-blur-xl border-r border-slate-200/50 z-50 flex flex-col items-center py-8">
|
||||
{/* Navigation Items */}
|
||||
<nav className="flex-1 flex flex-col items-center gap-4">
|
||||
{/* Dashboard - first item */}
|
||||
{navItems.slice(0, 1).map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.href} className="relative group">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleNavigation(item)}
|
||||
className={`
|
||||
relative w-12 h-12 rounded-md flex items-center justify-center
|
||||
${isActive ? 'bg-linear-to-b from-[#3895FF] to-[#86B8F1]' : 'bg-[#f0fafd]/60 border border-slate-100/50'}
|
||||
hover:bg-linear-to-b hover:from-[#3895FF] hover:to-[#86B8F1] hover:border-none
|
||||
transition-all ease-in-out duration-200
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-6 w-6 ${isActive ? 'text-white' : 'text-slate-500 group-hover:text-white'} stroke-[2.5]`}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={12}>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* New Analysis - Special eye-catchy button */}
|
||||
<div className="relative group">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => router.push(ROUTES.NEW_ANALYSIS)}
|
||||
className={`
|
||||
relative w-12 h-12 rounded-md flex items-center justify-center
|
||||
${isNewAnalysisActive ? 'bg-linear-to-b from-[#3895FF] to-[#86B8F1]' : 'bg-[#f0fafd]/60 border border-slate-100/50'}
|
||||
hover:bg-linear-to-b hover:from-[#3895FF] hover:to-[#86B8F1] hover:border-none
|
||||
ease-in-out duration-200 transition-all
|
||||
`}
|
||||
>
|
||||
<Plus
|
||||
className={`h-6 w-6 ${isNewAnalysisActive ? 'text-white' : 'text-slate-500 group-hover:text-white'} stroke-[2.5]`}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={12}>
|
||||
<p>New Analysis</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-8 h-px bg-slate-200 my-1" />
|
||||
|
||||
{/* Create items */}
|
||||
{navItems.slice(1, 4).map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.href} className="relative group">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => handleNavigation(item)}
|
||||
className={`
|
||||
relative w-12 h-12 rounded-md flex items-center justify-center
|
||||
${isActive ? 'bg-linear-to-b from-[#3895FF] to-[#86B8F1]' : 'bg-[#f0fafd]/60 border border-slate-100/50'}
|
||||
hover:bg-linear-to-b hover:from-[#3895FF] hover:to-[#86B8F1] hover:border-none
|
||||
transition-all duration-200
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-6 w-6 ${isActive ? 'text-white' : 'text-slate-500 group-hover:text-white'} stroke-[2.5]`}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={12}>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="mt-auto">
|
||||
<div className="relative group">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => { }}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center bg-linear-to-b from-[#225999] to-[#56A5FF] transition-all duration-200"
|
||||
disabled
|
||||
>
|
||||
<User className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={12}>
|
||||
<p>Account</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-full gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-full px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-md border-none py-6 shadow-none hover:shadow-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
353
src/components/ui/chart.tsx
Normal file
353
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RechartsPrimitive from 'recharts'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>['children']
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: 'line' | 'dot' | 'dashed'
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
nameKey,
|
||||
}: React.ComponentProps<'div'> &
|
||||
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={
|
||||
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
|
||||
}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload &&
|
||||
typeof payload.payload === 'object' &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === 'string'
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-dialog fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
193
src/components/ui/item.tsx
Normal file
193
src/components/ui/item.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn('group/item-group flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn('my-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50',
|
||||
},
|
||||
size: {
|
||||
default: 'p-4 gap-4 ',
|
||||
sm: 'py-3 px-4 gap-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn('flex flex-row items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
|
||||
React.ComponentProps<'a'>
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = 'icon',
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
48
src/components/ui/popover.tsx
Normal file
48
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px select-none',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'sm' | 'default'
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm overflow-hidden shadow-xs outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
139
src/components/ui/sheet.tsx
Normal file
139
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background fixed z-50 flex flex-col gap-4 shadow-lg',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'inset-x-0 bottom-0 h-auto border-t',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
724
src/components/ui/sidebar.tsx
Normal file
724
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,724 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, VariantProps } from 'class-variance-authority'
|
||||
import { PanelLeftIcon } from 'lucide-react'
|
||||
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = '16rem'
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed'
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open],
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? 'expanded' : 'collapsed'
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right'
|
||||
variant?: 'sidebar' | 'floating' | 'inset'
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none'
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
'relative w-(--sidebar-width) bg-transparent',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn('bg-background h-8 w-full shadow-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn('bg-sidebar-border mx-2 w-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn('w-full text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn('group/menu-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
outline:
|
||||
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 text-sm',
|
||||
sm: 'h-7 text-xs',
|
||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === 'string') {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover &&
|
||||
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Fixed width to prevent hydration mismatch.
|
||||
const width = "70%"
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
'--skeleton-width': width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn('group/menu-sub-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = 'md',
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn('bg-muted animate-pulse rounded-md', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner, ToasterProps } from 'sonner'
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn('[&_tr]:border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-foreground text-white z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
19
src/components/ui/use-mobile.tsx
Normal file
19
src/components/ui/use-mobile.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
381
src/components/upload-section.tsx
Normal file
381
src/components/upload-section.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } 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 { Progress } from "@/components/ui/progress"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Upload, Loader2, AlertCircle } from "lucide-react"
|
||||
import type { DetectionData, DetectionType } from "@/lib/types"
|
||||
|
||||
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" },
|
||||
{ value: "yolo_vl", label: "YOLO with VL" },
|
||||
{ value: "sam3", label: "SAM 3" },
|
||||
{ value: "yoloe", label: "YOLOE" },
|
||||
{ value: "yoloe_trained_vl", label: "YOLOE Trained" },
|
||||
] as const
|
||||
|
||||
type UploadSectionProps = {
|
||||
onDetectionComplete: (data: DetectionData, videoId: string, file: File) => void
|
||||
onDetectionTypeChange: (type: DetectionType) => void
|
||||
}
|
||||
|
||||
export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: UploadSectionProps) {
|
||||
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")
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [statusMessage, setStatusMessage] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const jsonFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
const handleDetectionTypeChange = (value: DetectionType) => {
|
||||
setDetectionType(value)
|
||||
onDetectionTypeChange(value)
|
||||
}
|
||||
|
||||
const connectWebSocket = (videoId: string) => {
|
||||
console.log("[Upload] Connecting WebSocket for video:", videoId)
|
||||
|
||||
const ws = new WebSocket(`${WS_URL}/ws/${videoId}`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("[Upload] WebSocket connected")
|
||||
setError(null)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log("[Upload] WebSocket message:", data)
|
||||
|
||||
if (data.type === "progress" || data.progress !== undefined) {
|
||||
const progressValue = data.progress || 0
|
||||
setProgress(progressValue)
|
||||
|
||||
let message = data.message || "Processing..."
|
||||
|
||||
// Handle both pothole and signboard progress messages
|
||||
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! Loading results...")
|
||||
ws.close()
|
||||
setTimeout(() => loadResults(videoId), 500)
|
||||
}
|
||||
|
||||
if (data.type === "error") {
|
||||
setError("Error: " + data.message)
|
||||
setStatusMessage("")
|
||||
setUploading(false)
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("[Upload] WebSocket error:", error)
|
||||
setStatusMessage("Connection error. Retrying...")
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("[Upload] WebSocket closed")
|
||||
}
|
||||
}
|
||||
|
||||
const loadResults = async (videoId: string) => {
|
||||
try {
|
||||
console.log("[Upload] Loading results for:", videoId)
|
||||
const response = await fetch(`${API_URL}/results/${videoId}`, {
|
||||
headers: {
|
||||
"ngrok-skip-browser-warning": "true"
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load results: ${response.status}`)
|
||||
}
|
||||
|
||||
const detectionData: DetectionData = await response.json()
|
||||
console.log("[Upload] Results loaded:", detectionData)
|
||||
|
||||
setUploading(false)
|
||||
setProgress(100)
|
||||
setStatusMessage("✓ Complete!")
|
||||
setError(null)
|
||||
|
||||
// Pass detection data, video_id, and the original file
|
||||
if (file) {
|
||||
onDetectionComplete(detectionData, videoId, file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Upload] Failed to load results:", error)
|
||||
setError("Failed to load results. Please try again.")
|
||||
setStatusMessage("")
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Add JSON file if provided
|
||||
if (jsonFile) {
|
||||
formData.append("json_file", jsonFile)
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
setStatusMessage("Uploading...")
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
console.log("[Upload] Uploading to:", `${API_URL}/upload`)
|
||||
console.log("[Upload] Detection type:", detectionType)
|
||||
console.log("[Upload] FormData contents:")
|
||||
console.log(" - file:", file.name)
|
||||
console.log(" - detection_type:", detectionType)
|
||||
console.log(" - speed_kmh:", speed)
|
||||
|
||||
const response = await fetch(`${API_URL}/upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"ngrok-skip-browser-warning": "true"
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
console.log("[Upload] Upload response status:", response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Upload failed (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const videoId = result.video_id
|
||||
|
||||
console.log("[Upload] Video uploaded successfully, ID:", videoId)
|
||||
console.log("[Upload] Response:", result)
|
||||
setStatusMessage("Uploaded! Starting processing...")
|
||||
setProgress(10)
|
||||
|
||||
// Connect WebSocket for progress updates
|
||||
connectWebSocket(videoId)
|
||||
} catch (error) {
|
||||
console.error("[Upload] Upload error:", error)
|
||||
|
||||
let errorMessage = "Upload failed"
|
||||
|
||||
if (error instanceof TypeError && error.message === "Failed to fetch") {
|
||||
errorMessage = "Cannot connect to server. Please check:\n• Backend is running on " + API_URL + "\n• CORS is configured properly\n• No firewall blocking the connection"
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
setError(errorMessage)
|
||||
setStatusMessage("")
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/20 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Upload Video</CardTitle>
|
||||
<CardDescription>
|
||||
Select video file, detection type, vehicle speed, and method for analysis
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Video File Input kept in 2-col row */}
|
||||
{/* File Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-file">Video File</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id="video-file"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={(e) => {
|
||||
setFile(e.target.files?.[0] || null)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
{file && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON File Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="json-file">GPS JSON File</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={jsonFileInputRef}
|
||||
id="json-file"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(e) => {
|
||||
setJsonFile(e.target.files?.[0] || null)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
{jsonFile && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected: {jsonFile.name} ({(jsonFile.size / 1024).toFixed(2)} KB)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Detection Type, Speed, and Method - 3 column row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Detection Type Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="detection-type">Detection Type</Label>
|
||||
<Select
|
||||
value={detectionType}
|
||||
onValueChange={handleDetectionTypeChange}
|
||||
disabled={uploading}
|
||||
>
|
||||
<SelectTrigger id="detection-type">
|
||||
<SelectValue placeholder="Select detection type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DETECTION_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{type.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Speed Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="speed">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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Select Method */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="select-method">Select Method</Label>
|
||||
<Select
|
||||
value={selectMethod}
|
||||
onValueChange={setSelectMethod}
|
||||
disabled={uploading}
|
||||
>
|
||||
<SelectTrigger id="select-method">
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DETECTION_METHODS.map((method) => (
|
||||
<SelectItem key={method.value} value={method.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{method.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 mt-0.5 shrink-0" />
|
||||
<p className="text-sm whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="w-full btn-blue-gradient rounded-full"
|
||||
|
||||
size="lg"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload & Process
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Progress Section */}
|
||||
{uploading && (
|
||||
<div className="space-y-3">
|
||||
<Progress value={progress} className="h-3" />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{statusMessage}</span>
|
||||
<span className="font-semibold">{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
1199
src/components/video-player-section.tsx
Normal file
1199
src/components/video-player-section.tsx
Normal file
File diff suppressed because it is too large
Load Diff
19
src/hooks/use-mobile.ts
Normal file
19
src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
191
src/hooks/use-toast.ts
Normal file
191
src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from 'react'
|
||||
|
||||
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST']
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST']
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
}
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
501
src/lib/api.ts
Normal file
501
src/lib/api.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* API Service Layer for VisionRoad Frontend
|
||||
* Provides typed functions for interacting with the backend API
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
||||
|
||||
|
||||
// Type definitions
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
state: string | null
|
||||
corridor_name: string | null
|
||||
start_lat: number | null
|
||||
start_lng: number | null
|
||||
end_lat: number | null
|
||||
end_lng: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
id: string
|
||||
project_id: string
|
||||
name: string
|
||||
region: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string
|
||||
package_id: string
|
||||
segment_name: string
|
||||
chainage_start_km: number | null
|
||||
chainage_end_km: number | null
|
||||
start_lat: number
|
||||
start_lng: number
|
||||
end_lat: number
|
||||
end_lng: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Helper function for GET API requests
|
||||
async function apiRequest<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"ngrok-skip-browser-warning": "true"
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Helper function for POST API requests
|
||||
async function apiPostRequest<T>(endpoint: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"ngrok-skip-browser-warning": "true"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API Error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Create request body types
|
||||
export interface ProjectCreate {
|
||||
name: string
|
||||
state?: string | null
|
||||
corridor_name?: string | null
|
||||
start_lat?: number | null
|
||||
start_lng?: number | null
|
||||
end_lat?: number | null
|
||||
end_lng?: number | null
|
||||
}
|
||||
|
||||
export interface PackageCreate {
|
||||
project_id: string
|
||||
name: string
|
||||
region?: string | null
|
||||
}
|
||||
|
||||
export interface LocationCreate {
|
||||
package_id: string
|
||||
segment_name: string
|
||||
chainage_start_km?: number | null
|
||||
chainage_end_km?: number | null
|
||||
start_lat: number
|
||||
start_lng: number
|
||||
end_lat: number
|
||||
end_lng: number
|
||||
}
|
||||
|
||||
// Update request body types
|
||||
export interface ProjectUpdate {
|
||||
name?: string
|
||||
state?: string | null
|
||||
corridor_name?: string | null
|
||||
start_lat?: number | null
|
||||
start_lng?: number | null
|
||||
end_lat?: number | null
|
||||
end_lng?: number | null
|
||||
}
|
||||
|
||||
export interface PackageUpdate {
|
||||
name?: string
|
||||
region?: string | null
|
||||
}
|
||||
|
||||
export interface LocationUpdate {
|
||||
segment_name?: string
|
||||
chainage_start_km?: number | null
|
||||
chainage_end_km?: number | null
|
||||
start_lat?: number
|
||||
start_lng?: number
|
||||
end_lat?: number
|
||||
end_lng?: number
|
||||
}
|
||||
|
||||
// Helper function for PUT API requests
|
||||
async function apiPutRequest<T>(endpoint: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"ngrok-skip-browser-warning": "true"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API Error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Helper function for DELETE API requests
|
||||
async function apiDeleteRequest<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"ngrok-skip-browser-warning": "true"
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API Error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return { message: "Deleted successfully" } as unknown as T
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
export async function createProject(data: ProjectCreate): Promise<Project> {
|
||||
return apiPostRequest<Project>("/projects/", data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new package in a project
|
||||
*/
|
||||
export async function createPackage(data: PackageCreate): Promise<Package> {
|
||||
return apiPostRequest<Package>("/packages/", data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new location in a package
|
||||
*/
|
||||
export async function createLocation(data: LocationCreate): Promise<Location> {
|
||||
return apiPostRequest<Location>("/locations/", data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
export async function updateProject(projectId: string, data: ProjectUpdate): Promise<Project> {
|
||||
return apiPutRequest<Project>(`/projects/${projectId}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
export async function deleteProject(projectId: string): Promise<{ message: string }> {
|
||||
return apiDeleteRequest<{ message: string }>(`/projects/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing package
|
||||
*/
|
||||
export async function updatePackage(packageId: string, data: PackageUpdate): Promise<Package> {
|
||||
return apiPutRequest<Package>(`/packages/${packageId}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a package
|
||||
*/
|
||||
export async function deletePackage(packageId: string): Promise<{ message: string }> {
|
||||
return apiDeleteRequest<{ message: string }>(`/packages/${packageId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing location
|
||||
*/
|
||||
export async function updateLocation(locationId: string, data: LocationUpdate): Promise<Location> {
|
||||
return apiPutRequest<Location>(`/locations/${locationId}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a location
|
||||
*/
|
||||
export async function deleteLocation(locationId: string): Promise<{ message: string }> {
|
||||
return apiDeleteRequest<{ message: string }>(`/locations/${locationId}`)
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
/**
|
||||
* Fetch all projects
|
||||
*/
|
||||
export async function fetchProjects(params?: PaginationParams): Promise<Project[]> {
|
||||
const skip = params?.skip ?? 0
|
||||
const limit = params?.limit ?? 100
|
||||
return apiRequest<Project[]>(`/projects/?skip=${skip}&limit=${limit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch packages filtered by project ID
|
||||
*/
|
||||
export async function fetchPackagesByProject(projectId: string, params?: PaginationParams): Promise<Package[]> {
|
||||
const skip = params?.skip ?? 0
|
||||
const limit = params?.limit ?? 100
|
||||
return apiRequest<Package[]>(`/packages/?project_id=${projectId}&skip=${skip}&limit=${limit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch locations filtered by package ID
|
||||
*/
|
||||
export async function fetchLocationsByPackage(packageId: string, params?: PaginationParams): Promise<Location[]> {
|
||||
const skip = params?.skip ?? 0
|
||||
const limit = params?.limit ?? 100
|
||||
return apiRequest<Location[]>(`/locations/?package_id=${packageId}&skip=${skip}&limit=${limit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all packages (for dashboard)
|
||||
*/
|
||||
export async function fetchAllPackages(params?: PaginationParams): Promise<Package[]> {
|
||||
const skip = params?.skip ?? 0
|
||||
const limit = params?.limit ?? 100
|
||||
return apiRequest<Package[]>(`/packages/?skip=${skip}&limit=${limit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all locations (for dashboard)
|
||||
*/
|
||||
export async function fetchAllLocations(params?: PaginationParams): Promise<Location[]> {
|
||||
const skip = params?.skip ?? 0
|
||||
const limit = params?.limit ?? 100
|
||||
return apiRequest<Location[]>(`/locations/?skip=${skip}&limit=${limit}`)
|
||||
}
|
||||
|
||||
// Video type for dashboard
|
||||
export interface Video {
|
||||
id: string
|
||||
filename: string
|
||||
detection_type: "pothole-detection" | "sign-board-detection" | "pot-sign-detection"
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
unique_defected_sign_board?: number
|
||||
unique_pothole?: number
|
||||
unique_road_crack?: number
|
||||
unique_damaged_road_marking?: number
|
||||
unique_good_sign_board?: number
|
||||
total_road_damage?: number
|
||||
total_detections?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all videos (for dashboard)
|
||||
*/
|
||||
export async function fetchVideos(params?: PaginationParams): Promise<Video[]> {
|
||||
const skip = params?.skip ?? 0
|
||||
const limit = params?.limit ?? 100
|
||||
const response = await apiRequest<{
|
||||
videos: Array<{
|
||||
video_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
summary?: {
|
||||
unique_defected_sign_board?: number;
|
||||
unique_pothole?: number;
|
||||
unique_road_crack?: number;
|
||||
unique_damaged_road_marking?: number;
|
||||
unique_good_sign_board?: number;
|
||||
total_road_damage?: number;
|
||||
total_detections?: number;
|
||||
}
|
||||
}>
|
||||
}>(`/videos?skip=${skip}&limit=${limit}`)
|
||||
|
||||
// Transform the response to match our Video interface
|
||||
return response.videos.map(v => ({
|
||||
id: v.video_id,
|
||||
filename: v.video_id,
|
||||
detection_type: "pot-sign-detection" as const,
|
||||
status: v.status as Video["status"],
|
||||
unique_defected_sign_board: v.summary?.unique_defected_sign_board,
|
||||
unique_pothole: v.summary?.unique_pothole,
|
||||
unique_road_crack: v.summary?.unique_road_crack,
|
||||
unique_damaged_road_marking: v.summary?.unique_damaged_road_marking,
|
||||
unique_good_sign_board: v.summary?.unique_good_sign_board,
|
||||
total_road_damage: v.summary?.total_road_damage,
|
||||
total_detections: v.summary?.total_detections,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}))
|
||||
}
|
||||
|
||||
// Detection type for map display
|
||||
export interface Detection {
|
||||
id: number
|
||||
video_id: string
|
||||
type: string
|
||||
class: string
|
||||
confidence: number
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
frame_number: number
|
||||
timestamp_ms: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all detections from completed videos (for dashboard map)
|
||||
* Uses the summary endpoint to get detections for each project
|
||||
*/
|
||||
export async function fetchAllDetections(): Promise<Detection[]> {
|
||||
try {
|
||||
// First get all projects
|
||||
const projects = await fetchProjects()
|
||||
|
||||
// Then fetch detections for each project
|
||||
const allDetections: Detection[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const summary = await apiRequest<{
|
||||
packages: {
|
||||
[key: string]: {
|
||||
locations: {
|
||||
[key: string]: {
|
||||
detections: Detection[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}>(`/summary/projects/${project.id}`)
|
||||
|
||||
// Extract detections from the nested structure
|
||||
for (const pkg of Object.values(summary.packages || {})) {
|
||||
for (const loc of Object.values(pkg.locations || {})) {
|
||||
allDetections.push(...(loc.detections || []))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip projects that fail to load
|
||||
console.warn(`Failed to load detections for project ${project.id}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
return allDetections
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch all detections:", e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session context type for storing user selections
|
||||
*/
|
||||
export interface SessionContext {
|
||||
projectId: string | null
|
||||
projectName: string | null
|
||||
packageId: string | null
|
||||
packageName: string | null
|
||||
locationId: string | null
|
||||
locationName: string | null
|
||||
}
|
||||
|
||||
export const emptySessionContext: SessionContext = {
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
packageId: null,
|
||||
packageName: null,
|
||||
locationId: null,
|
||||
locationName: null
|
||||
}
|
||||
|
||||
// Session Storage Keys
|
||||
const SESSION_KEY = "visionroad_session"
|
||||
const VIDEO_DATA_KEY = "visionroad_video_data"
|
||||
const DETECTION_TYPE_KEY = "visionroad_detection_type"
|
||||
|
||||
/**
|
||||
* Save session to sessionStorage
|
||||
*/
|
||||
export function saveSession(session: SessionContext): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session from sessionStorage
|
||||
*/
|
||||
export function loadSession(): SessionContext {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem(SESSION_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
}
|
||||
return emptySessionContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all session data
|
||||
*/
|
||||
export function clearSession(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
localStorage.removeItem(VIDEO_DATA_KEY)
|
||||
localStorage.removeItem(DETECTION_TYPE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Video data for results page
|
||||
*/
|
||||
export interface VideoResultData {
|
||||
videoId: string
|
||||
detectionType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Save video result data
|
||||
*/
|
||||
export function saveVideoData(data: VideoResultData): void {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(VIDEO_DATA_KEY, JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load video result data
|
||||
*/
|
||||
export function loadVideoData(): VideoResultData | null {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem(VIDEO_DATA_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is complete
|
||||
*/
|
||||
export function isSessionValid(session: SessionContext): boolean {
|
||||
return !!(session.projectId && session.packageId && session.locationId)
|
||||
}
|
||||
49
src/lib/project-service.ts
Normal file
49
src/lib/project-service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
type Project,
|
||||
type Package,
|
||||
type Location,
|
||||
type Video,
|
||||
type Detection
|
||||
} from "./api"
|
||||
|
||||
/**
|
||||
* Service to handle data extraction from project summaries
|
||||
*/
|
||||
export const projectDataService = {
|
||||
/**
|
||||
* Extracts all detections from a project summary, optionally filtered by package and location
|
||||
*/
|
||||
extractDetections(
|
||||
projectSummary: any,
|
||||
selectedPackageId?: string | null,
|
||||
selectedLocationId?: string | null
|
||||
): Detection[] {
|
||||
if (!projectSummary) return []
|
||||
|
||||
const detections: Detection[] = []
|
||||
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 as any).locations[selectedLocationId] }
|
||||
: (pkg as any).locations || {}
|
||||
|
||||
for (const [locName, loc] of Object.entries(locationsToProcess)) {
|
||||
if (!loc) continue
|
||||
const locationDetections = (loc as any).detections || []
|
||||
detections.push(...locationDetections)
|
||||
}
|
||||
}
|
||||
|
||||
return detections
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API service for project and video data
|
||||
*/
|
||||
export * from "./api"
|
||||
75
src/lib/types.ts
Normal file
75
src/lib/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export type DetectionType = "pothole-detection" | "sign-board-detection" | "pot-sign-detection"
|
||||
|
||||
export interface DetectionListItem {
|
||||
detection_id?: number
|
||||
pothole_id?: number
|
||||
signboard_id?: number
|
||||
type: string
|
||||
first_detected_frame: number
|
||||
first_detected_time: number
|
||||
confidence: number
|
||||
bbox?: { x1: number; y1: number; x2: number; y2: number }
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
export type DetectionData = {
|
||||
video_id: string
|
||||
detection_type?: string
|
||||
output_video_path?: string
|
||||
video_info: {
|
||||
fps: number
|
||||
width: number
|
||||
height: number
|
||||
total_frames: number
|
||||
}
|
||||
summary: {
|
||||
unique_defected_sign_board?: number
|
||||
unique_pothole?: number
|
||||
unique_road_crack?: number
|
||||
unique_damaged_road_marking?: number
|
||||
unique_good_sign_board?: number
|
||||
total_road_damage?: number
|
||||
total_detections: number
|
||||
total_frames: number
|
||||
detection_rate: number
|
||||
}
|
||||
pothole_list?: Array<DetectionListItem>
|
||||
defected_sign_board_list?: Array<DetectionListItem>
|
||||
road_crack_list?: Array<DetectionListItem>
|
||||
damaged_road_marking_list?: Array<DetectionListItem>
|
||||
good_sign_board_list?: Array<DetectionListItem>
|
||||
signboard_list?: Array<DetectionListItem> // Keeping for backward compatibility
|
||||
frames: Array<{
|
||||
frame_id: number
|
||||
// Legacy format: separate arrays
|
||||
potholes?: Array<{
|
||||
pothole_id: number
|
||||
bbox: { x1: number; y1: number; x2: number; y2: number }
|
||||
confidence: number
|
||||
}>
|
||||
signboards?: Array<{
|
||||
signboard_id: number
|
||||
type: string
|
||||
bbox: { x1: number; y1: number; x2: number; y2: number }
|
||||
confidence: number
|
||||
}>
|
||||
// Flat format (pot-sign-detection): unified detections array
|
||||
detections?: Array<{
|
||||
frame_id: number
|
||||
detection_id: number
|
||||
type: string
|
||||
confidence: number
|
||||
bbox: { x1: number; y1: number; x2: number; y2: number }
|
||||
center?: { x: number; y: number }
|
||||
area?: number
|
||||
count?: {
|
||||
defected_sign_board: number
|
||||
pothole: number
|
||||
road_crack: number
|
||||
damaged_road_marking: number
|
||||
good_sign_board: number
|
||||
}
|
||||
}>
|
||||
}>
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
109
src/lib/video-storage.ts
Normal file
109
src/lib/video-storage.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* IndexedDB storage for video files
|
||||
* Used to persist video files across page navigations
|
||||
*/
|
||||
|
||||
const DB_NAME = 'visionroad_db'
|
||||
const DB_VERSION = 1
|
||||
const VIDEO_STORE = 'videos'
|
||||
|
||||
let db: IDBDatabase | null = null
|
||||
|
||||
/**
|
||||
* Open the IndexedDB database
|
||||
*/
|
||||
async function openDB(): Promise<IDBDatabase> {
|
||||
if (db) return db
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const database = (event.target as IDBOpenDBRequest).result
|
||||
if (!database.objectStoreNames.contains(VIDEO_STORE)) {
|
||||
database.createObjectStore(VIDEO_STORE, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a video file in IndexedDB
|
||||
*/
|
||||
export async function storeVideoFile(videoId: string, file: File): Promise<void> {
|
||||
const database = await openDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction([VIDEO_STORE], 'readwrite')
|
||||
const store = transaction.objectStore(VIDEO_STORE)
|
||||
|
||||
const request = store.put({
|
||||
id: videoId,
|
||||
file: file,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a video file from IndexedDB
|
||||
*/
|
||||
export async function getVideoFile(videoId: string): Promise<File | null> {
|
||||
const database = await openDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction([VIDEO_STORE], 'readonly')
|
||||
const store = transaction.objectStore(VIDEO_STORE)
|
||||
|
||||
const request = store.get(videoId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
const result = request.result
|
||||
resolve(result ? result.file : null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a video file from IndexedDB
|
||||
*/
|
||||
export async function clearVideoFile(videoId: string): Promise<void> {
|
||||
const database = await openDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction([VIDEO_STORE], 'readwrite')
|
||||
const store = transaction.objectStore(VIDEO_STORE)
|
||||
|
||||
const request = store.delete(videoId)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all video files from IndexedDB
|
||||
*/
|
||||
export async function clearAllVideos(): Promise<void> {
|
||||
const database = await openDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction([VIDEO_STORE], 'readwrite')
|
||||
const store = transaction.objectStore(VIDEO_STORE)
|
||||
|
||||
const request = store.clear()
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
10
src/utils/routes.ts
Normal file
10
src/utils/routes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const ROUTES = {
|
||||
DASHBOARD: "/dashboard",
|
||||
PROJECT: "/project",
|
||||
PACKAGE: "/package",
|
||||
LOCATION: "/location",
|
||||
ACCOUNT: "/account",
|
||||
NEW_ANALYSIS: "/new-analysis",
|
||||
UPLOAD: "/upload",
|
||||
RESULTS: "/results",
|
||||
} as const;
|
||||
Reference in New Issue
Block a user