refactor: refactor the color schema
This commit is contained in:
1025
package-lock.json
generated
1025
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@vercel/analytics": "1.3.1",
|
"@vercel/analytics": "1.3.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"next": "16.0.10",
|
"next": "16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import {
|
|||||||
PencilLine,
|
PencilLine,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation";
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||||
import { GradientStatsCard } from "@/components/dashboard/gradient-stats-card";
|
|
||||||
import { CompactProjectSelector } from "@/components/dashboard/compact-project-selector";
|
import { CompactProjectSelector } from "@/components/dashboard/compact-project-selector";
|
||||||
import { FilterSelector } from "@/components/dashboard/filter-selector";
|
import { FilterSelector } from "@/components/dashboard/filter-selector";
|
||||||
import { DetectionDonutChart } from "@/components/dashboard/detection-donut-chart";
|
import { DetectionDonutChart } from "@/components/dashboard/detection-donut-chart";
|
||||||
@@ -424,54 +423,33 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
||||||
{/* Sidebar Navigation */}
|
{/* Main Content */}
|
||||||
<SidebarNavigation />
|
<main className="min-h-screen">
|
||||||
|
|
||||||
{/* Main Content - offset by sidebar width */}
|
|
||||||
<main className="ml-20 min-h-screen">
|
|
||||||
<div className="p-4 px-8 max-w-340 mx-auto">
|
<div className="p-4 px-8 max-w-340 mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-8 flex items-center justify-between">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
|
description="A Comprehensive Overview Of Your Road Infrastructure Analysis"
|
||||||
>
|
icon={BarChart3}
|
||||||
<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>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<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">
|
<Button variant="outline" className="gap-2 font-semibold">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
<span>Filters</span>
|
<span>Filters</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[320px] p-0 border-none shadow-2xl rounded-md overflow-hidden" align="end">
|
<PopoverContent className="w-[320px] p-0 overflow-hidden" align="end">
|
||||||
<div className="bg-linear-to-b from-[#225999] to-[#56A5FF] p-4 text-white">
|
<div className="p-4 border-b bg-muted/30">
|
||||||
<h3 className="font-bold text-sm flex items-center gap-2">
|
<h3 className="font-bold text-sm flex items-center gap-2 text-foreground">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4 text-primary" />
|
||||||
Filter Analysis
|
Filter Analysis
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-blue-100 mt-1 opacity-80">Refine your view by project, package, or location</p>
|
<p className="text-xs mt-1 text-muted-foreground">Refine your view by project, package, or location</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-1 bg-white">
|
<div>
|
||||||
<FilterSelector
|
<FilterSelector
|
||||||
projects={projects}
|
projects={projects}
|
||||||
selectedProjectId={selectedProjectId}
|
selectedProjectId={selectedProjectId}
|
||||||
@@ -503,60 +481,54 @@ export default function DashboardPage() {
|
|||||||
<>
|
<>
|
||||||
{/* Stats Cards - Top Row */}
|
{/* Stats Cards - Top Row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
<GradientStatsCard
|
<StatsCard
|
||||||
title="Total Road Damage"
|
title="Total Road Damage"
|
||||||
subtitle="Combined damage detections"
|
subtitle="Combined damage detections"
|
||||||
value={stats.totalRoadDamage}
|
value={stats.totalRoadDamage}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
gradient="purple"
|
|
||||||
/>
|
/>
|
||||||
<GradientStatsCard
|
<StatsCard
|
||||||
title="Potholes"
|
title="Potholes"
|
||||||
subtitle="Surface depressions"
|
subtitle="Surface depressions"
|
||||||
value={stats.totalPothole}
|
value={stats.totalPothole}
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
gradient="green"
|
|
||||||
/>
|
/>
|
||||||
<GradientStatsCard
|
<StatsCard
|
||||||
title="Defected Signboards"
|
title="Defected Signboards"
|
||||||
subtitle="Damaged traffic signs"
|
subtitle="Damaged traffic signs"
|
||||||
value={stats.totalDefectedSignboard}
|
value={stats.totalDefectedSignboard}
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
gradient="blue"
|
|
||||||
/>
|
/>
|
||||||
<GradientStatsCard
|
<StatsCard
|
||||||
title="Road Cracks"
|
title="Road Cracks"
|
||||||
subtitle="Surface fissures"
|
subtitle="Surface fissures"
|
||||||
value={stats.totalRoadCrack}
|
value={stats.totalRoadCrack}
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
gradient="orange"
|
|
||||||
/>
|
/>
|
||||||
<GradientStatsCard
|
<StatsCard
|
||||||
title="Damaged Markings"
|
title="Damaged Markings"
|
||||||
subtitle="Worn road lines"
|
subtitle="Worn road lines"
|
||||||
value={stats.totalDamagedRoadMarking}
|
value={stats.totalDamagedRoadMarking}
|
||||||
icon={PencilLine}
|
icon={PencilLine}
|
||||||
gradient="indigo"
|
|
||||||
/>
|
/>
|
||||||
<GradientStatsCard
|
<StatsCard
|
||||||
title="Good Signboards"
|
title="Good Signboards"
|
||||||
subtitle="Informational markers"
|
subtitle="Informational markers"
|
||||||
value={stats.totalGoodSignboard}
|
value={stats.totalGoodSignboard}
|
||||||
icon={CheckCircle2}
|
icon={CheckCircle2}
|
||||||
gradient="emerald"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row - Side by Side */}
|
{/* Charts Row - Side by Side */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||||
{/* Left Chart - Detection Distribution */}
|
{/* Left Chart - Detection Distribution */}
|
||||||
<Card className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden">
|
<Card>
|
||||||
<CardHeader className="pb-2 border-none">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base font-bold flex items-center gap-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 ">
|
<div className="p-2 rounded-md bg-secondary text-secondary-foreground">
|
||||||
<BarChart3 className="h-5 w-5 text-white" />
|
<BarChart3 className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="heading-blue-gradient text-xl">
|
<span className="text-xl">
|
||||||
Detection Distribution
|
Detection Distribution
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -573,13 +545,13 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right Chart - Location Bar Chart */}
|
{/* Right Chart - Location Bar Chart */}
|
||||||
<Card className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden">
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base font-bold flex items-center gap-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 ">
|
<div className="p-2 rounded-md bg-secondary text-secondary-foreground">
|
||||||
<MapPinIcon className="h-5 w-5 text-white" />
|
<MapPinIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="heading-blue-gradient text-xl">Detections by Location</span>
|
<span className="text-xl">Detections by Location</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
25
src/app/(modules)/layout.tsx
Normal file
25
src/app/(modules)/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
import { ModeToggle } from "@/components/mode-toogle";
|
||||||
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const ModulesLayout = ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) => {
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppSidebar />
|
||||||
|
<main className="flex flex-1 flex-col p-4 pt-0 w-full h-screen overflow-hidden">
|
||||||
|
<div className="flex gap-3 items-center mt-2 sticky top-0 bg-background z-50 py-2">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 py-2 overflow-auto">{children}</div>
|
||||||
|
</main>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModulesLayout;
|
||||||
@@ -8,7 +8,6 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Loader2, CheckCircle2, MapPin, Navigation, Milestone } from "lucide-react"
|
import { Loader2, CheckCircle2, MapPin, Navigation, Milestone } from "lucide-react"
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation"
|
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { PoweredBy } from "@/components/powered-by"
|
import { PoweredBy } from "@/components/powered-by"
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
type LocationCreate,
|
type LocationCreate,
|
||||||
type LocationUpdate
|
type LocationUpdate
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function LocationPage() {
|
export default function LocationPage() {
|
||||||
@@ -239,32 +239,34 @@ export default function LocationPage() {
|
|||||||
|
|
||||||
const isFormComplete = selectedPackageId && segmentName.trim() && startLat && startLng && endLat && endLng
|
const isFormComplete = selectedPackageId && segmentName.trim() && startLat && startLng && endLat && endLng
|
||||||
|
|
||||||
const columns = [
|
const columns: ColumnDef<Location>[] = [
|
||||||
{
|
{
|
||||||
key: "segment_name",
|
accessorKey: "segment_name",
|
||||||
header: "Segment Name",
|
header: "Segment Name",
|
||||||
render: (location: Location) => (
|
cell: ({ row }) => (
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{location.segment_name}</div>
|
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.segment_name}</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "package_id",
|
accessorKey: "package_id",
|
||||||
header: "Package",
|
header: "Package",
|
||||||
render: (location: Location) => getPackageName(location.package_id)
|
cell: ({ row }) => getPackageName(row.original.package_id)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "project",
|
accessorKey: "project",
|
||||||
header: "Project",
|
header: "Project",
|
||||||
render: (location: Location) => {
|
cell: ({ row }) => {
|
||||||
|
const location = row.original
|
||||||
const pkg = allPackages.find(p => p.id === location.package_id)
|
const pkg = allPackages.find(p => p.id === location.package_id)
|
||||||
const project = projects.find(p => p.id === pkg?.project_id)
|
const project = projects.find(p => p.id === pkg?.project_id)
|
||||||
return project?.name || "—"
|
return project?.name || "—"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "chainage",
|
accessorKey: "chainage",
|
||||||
header: "Chainage (km)",
|
header: "Chainage (km)",
|
||||||
render: (location: Location) => {
|
cell: ({ row }) => {
|
||||||
|
const location = row.original
|
||||||
if (location.chainage_start_km !== null && location.chainage_end_km !== null) {
|
if (location.chainage_start_km !== null && location.chainage_end_km !== null) {
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -276,20 +278,20 @@ export default function LocationPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "start_gps",
|
accessorKey: "start_gps",
|
||||||
header: "Start GPS",
|
header: "Start GPS",
|
||||||
render: (location: Location) => (
|
cell: ({ row }) => (
|
||||||
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 whitespace-nowrap">
|
<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)}
|
{row.original.start_lat.toFixed(4)}, {row.original.start_lng.toFixed(4)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "end_gps",
|
accessorKey: "end_gps",
|
||||||
header: "End GPS",
|
header: "End GPS",
|
||||||
render: (location: Location) => (
|
cell: ({ row }) => (
|
||||||
<span className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-100 dark:border-blue-800 whitespace-nowrap">
|
<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)}
|
{row.original.end_lat.toFixed(4)}, {row.original.end_lng.toFixed(4)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -297,8 +299,7 @@ export default function LocationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
||||||
<SidebarNavigation />
|
<main className="min-h-screen relative overflow-hidden">
|
||||||
<main className="ml-20 min-h-screen relative overflow-hidden">
|
|
||||||
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
||||||
{/* Refined Header */}
|
{/* Refined Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -322,7 +323,7 @@ export default function LocationPage() {
|
|||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<div>
|
<div>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="All Locations"
|
title="Locations"
|
||||||
data={locations}
|
data={locations}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
||||||
@@ -349,16 +350,17 @@ export default function LocationPage() {
|
|||||||
{/* Modal Dialog */}
|
{/* Modal Dialog */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
<Dialog open={isModalOpen} onOpenChange={(open) => { if (!open) { setIsModalOpen(false); resetForm(); } else { setIsModalOpen(true); } }}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-2xl"
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader className="gap-2">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-3 text-xl">
|
||||||
<MapPin className="h-5 w-5 text-blue-500" />
|
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
{isEditing ? 'Edit Location' : 'Create New Location'}
|
<MapPin className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
{isEditing ? 'Edit Location Details' : 'Create New Location'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="text-sm">
|
||||||
{isEditing ? 'Update disclosure details for your road infrastructure segment' : 'Select project & package, then fill in the location details'}
|
{isEditing ? 'Update the technical specifications for your road segment location.' : 'Select a project & package, then provide the essential location data.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -374,97 +376,97 @@ export default function LocationPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Step 1: Select Project */}
|
|
||||||
{!isEditing && (
|
{!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="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{/* Step 1: Select Project */}
|
||||||
<div className="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center">
|
<div className="space-y-3">
|
||||||
<span className="text-white text-[10px] font-bold">1</span>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold uppercase">
|
||||||
|
01
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-blue-700 dark:text-blue-400">Select Project</p>
|
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Select Project</p>
|
||||||
</div>
|
</div>
|
||||||
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
|
<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">
|
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
|
||||||
{loadingProjects ? (
|
{loadingProjects ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
<span className="text-gray-400">Loading projects...</span>
|
<span className="text-muted-foreground text-xs">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder="Choose a project" />
|
<SelectValue placeholder="Choose a project..." />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
<SelectItem key={project.id} value={project.id}>
|
<SelectItem key={project.id} value={project.id} className="py-2.5">
|
||||||
<span className="font-medium">{project.name}</span>
|
<span className="font-semibold">{project.name}</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: Select Package */}
|
{/* Step 2: Select Package */}
|
||||||
{!isEditing && (
|
<div className={`space-y-3 ${selectedProjectId ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
||||||
<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.5">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedProjectId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${selectedProjectId ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
02
|
||||||
<span className="text-white text-[10px] font-bold">2</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-blue-700 dark:text-blue-400">Select Package</p>
|
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Select Package</p>
|
||||||
</div>
|
</div>
|
||||||
<Select value={selectedPackageId} onValueChange={setSelectedPackageId} disabled={!selectedProjectId}>
|
<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">
|
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
|
||||||
{loadingPackages ? (
|
{loadingPackages ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
<span className="text-gray-400">Loading packages...</span>
|
<span className="text-muted-foreground text-xs">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder={selectedProjectId ? "Choose a package" : "Select project first"} />
|
<SelectValue placeholder={selectedProjectId ? "Choose a package..." : "Select project first"} />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{packages.map(pkg => (
|
{packages.map(pkg => (
|
||||||
<SelectItem key={pkg.id} value={pkg.id}>
|
<SelectItem key={pkg.id} value={pkg.id} className="py-2.5">
|
||||||
<span className="font-medium">{pkg.name}</span>
|
<span className="font-semibold">{pkg.name}</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Location Details */}
|
{/* Step 3: Location Details */}
|
||||||
<div className={`space-y-4 ${selectedPackageId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
<div className={`space-y-4 ${selectedPackageId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${selectedPackageId ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedPackageId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||||
<span className="text-white text-[10px] font-bold">3</span>
|
03
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-gray-700 dark:text-gray-300">Location Information</p>
|
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Location Information</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Segment Name & Chainage Row */}
|
{/* Segment Name & Chainage Row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="segment" className="text-xs font-semibold flex items-center gap-1">
|
<Label htmlFor="segment" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
Segment Name <span className="text-red-500">*</span>
|
Segment Name <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="segment"
|
id="segment"
|
||||||
value={segmentName}
|
value={segmentName}
|
||||||
onChange={(e) => setSegmentName(e.target.value)}
|
onChange={(e) => setSegmentName(e.target.value)}
|
||||||
placeholder="Location to Location"
|
placeholder="e.g. Mumbai to Pune"
|
||||||
className="h-9 text-sm"
|
className="h-11 bg-muted/20 border-border/60"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ch-start" className="text-[10px] font-semibold flex items-center gap-1">
|
<Label htmlFor="ch-start" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
Start (km)
|
Start (km)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -474,12 +476,12 @@ export default function LocationPage() {
|
|||||||
min="0"
|
min="0"
|
||||||
value={chainageStartKm}
|
value={chainageStartKm}
|
||||||
onChange={(e) => setChainageStartKm(e.target.value)}
|
onChange={(e) => setChainageStartKm(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="0.0"
|
||||||
className="h-9 text-sm"
|
className="h-11 bg-muted/20 border-border/60 text-sm font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ch-end" className="text-[10px] font-semibold flex items-center gap-1">
|
<Label htmlFor="ch-end" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
End (km)
|
End (km)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -489,44 +491,44 @@ export default function LocationPage() {
|
|||||||
min="0"
|
min="0"
|
||||||
value={chainageEndKm}
|
value={chainageEndKm}
|
||||||
onChange={(e) => setChainageEndKm(e.target.value)}
|
onChange={(e) => setChainageEndKm(e.target.value)}
|
||||||
placeholder="0"
|
placeholder="1.0"
|
||||||
className="h-9 text-sm"
|
className="h-11 bg-muted/20 border-border/60 text-sm font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GPS Coordinates Grid */}
|
{/* GPS Coordinates Grid */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-8 pt-4">
|
||||||
{/* Start Point */}
|
{/* 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">
|
<div className="space-y-4">
|
||||||
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
|
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
|
||||||
<MapPin className="h-3 w-3" /> Start Point
|
<MapPin className="h-3.5 w-3.5 opacity-60" /> START POINT
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Label htmlFor="s-lat" className="text-[10px] text-gray-500 w-8">Lat</Label>
|
<Label htmlFor="s-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lat</Label>
|
||||||
<Input
|
<Input
|
||||||
id="s-lat"
|
id="s-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={startLat}
|
value={startLat}
|
||||||
onChange={(e) => setStartLat(e.target.value)}
|
onChange={(e) => setStartLat(e.target.value)}
|
||||||
placeholder="Lat"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Label htmlFor="s-lng" className="text-[10px] text-gray-500 w-8">Lng</Label>
|
<Label htmlFor="s-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lng</Label>
|
||||||
<Input
|
<Input
|
||||||
id="s-lng"
|
id="s-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={startLng}
|
value={startLng}
|
||||||
onChange={(e) => setStartLng(e.target.value)}
|
onChange={(e) => setStartLng(e.target.value)}
|
||||||
placeholder="Lng"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -534,34 +536,34 @@ export default function LocationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End Point */}
|
{/* 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">
|
<div className="space-y-4">
|
||||||
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
|
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
|
||||||
<MapPin className="h-3 w-3" /> End Point
|
<MapPin className="h-3.5 w-3.5 opacity-60" /> END POINT
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Label htmlFor="e-lat" className="text-[10px] text-gray-500 w-8">Lat</Label>
|
<Label htmlFor="e-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lat</Label>
|
||||||
<Input
|
<Input
|
||||||
id="e-lat"
|
id="e-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={endLat}
|
value={endLat}
|
||||||
onChange={(e) => setEndLat(e.target.value)}
|
onChange={(e) => setEndLat(e.target.value)}
|
||||||
placeholder="Lat"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Label htmlFor="e-lng" className="text-[10px] text-gray-500 w-8">Lng</Label>
|
<Label htmlFor="e-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70 w-8">Lng</Label>
|
||||||
<Input
|
<Input
|
||||||
id="e-lng"
|
id="e-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={endLng}
|
value={endLng}
|
||||||
onChange={(e) => setEndLng(e.target.value)}
|
onChange={(e) => setEndLng(e.target.value)}
|
||||||
placeholder="Lng"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +573,7 @@ export default function LocationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-4 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -580,25 +582,25 @@ export default function LocationPage() {
|
|||||||
resetForm()
|
resetForm()
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1"
|
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs border-border/80 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !isFormComplete}
|
disabled={isSubmitting || !isFormComplete}
|
||||||
className="flex-1 btn-blue-gradient"
|
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{isEditing ? 'Updating...' : 'Creating...'}
|
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
|
||||||
</span>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
|
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
|
||||||
{isEditing ? 'Update Location' : 'Create Location'}
|
<span>{isEditing ? 'Update Location' : 'Create Location'}</span>
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation"
|
|
||||||
import { type SessionContext, saveSession } from "@/lib/api"
|
import { type SessionContext, saveSession } from "@/lib/api"
|
||||||
import { TrendingUp } from "lucide-react"
|
import { TrendingUp } from "lucide-react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
@@ -25,14 +24,11 @@ export default function NewAnalysisPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen">
|
||||||
{/* Sidebar Navigation */}
|
|
||||||
<SidebarNavigation />
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="ml-20 min-h-screen relative overflow-hidden">
|
<main className="min-h-screen">
|
||||||
|
|
||||||
<div className="container mx-auto px-6 py-10 max-w-340 relative z-10">
|
<div className="container mx-auto px-6 py-10 max-w-340">
|
||||||
{/* Refined Left-Aligned Header */}
|
{/* Refined Left-Aligned Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -8,7 +8,6 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Loader2, CheckCircle2, Package, FolderKanban, Globe } from "lucide-react"
|
import { Loader2, CheckCircle2, Package, FolderKanban, Globe } from "lucide-react"
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation"
|
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
type PackageCreate,
|
type PackageCreate,
|
||||||
type PackageUpdate
|
type PackageUpdate
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { PoweredBy } from "@/components/powered-by"
|
import { PoweredBy } from "@/components/powered-by"
|
||||||
|
|
||||||
@@ -160,23 +160,24 @@ export default function PackagePage() {
|
|||||||
return projects.find(p => p.id === projectId)?.name || projectId
|
return projects.find(p => p.id === projectId)?.name || projectId
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns: ColumnDef<PackageType>[] = [
|
||||||
{
|
{
|
||||||
key: "name",
|
accessorKey: "name",
|
||||||
header: "Package Name",
|
header: "Package Name",
|
||||||
render: (pkg: PackageType) => (
|
cell: ({ row }) => (
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{pkg.name}</div>
|
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.name}</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "project_id",
|
accessorKey: "project_id",
|
||||||
header: "Project",
|
header: "Project",
|
||||||
render: (pkg: PackageType) => getProjectName(pkg.project_id)
|
cell: ({ row }) => getProjectName(row.original.project_id)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "project_state",
|
accessorKey: "project_state",
|
||||||
header: "Project State",
|
header: "Project State",
|
||||||
render: (pkg: PackageType) => {
|
cell: ({ row }) => {
|
||||||
|
const pkg = row.original
|
||||||
const project = projects.find(p => p.id === pkg.project_id)
|
const project = projects.find(p => p.id === pkg.project_id)
|
||||||
if (!project?.state) return <span className="text-gray-400">—</span>
|
if (!project?.state) return <span className="text-gray-400">—</span>
|
||||||
return (
|
return (
|
||||||
@@ -194,15 +195,14 @@ export default function PackagePage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "region",
|
accessorKey: "region",
|
||||||
header: "Region",
|
header: "Region",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
||||||
<SidebarNavigation />
|
<main className="min-h-screen relative overflow-hidden">
|
||||||
<main className="ml-20 min-h-screen relative overflow-hidden">
|
|
||||||
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
||||||
{/* Refined Header */}
|
{/* Refined Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -226,7 +226,7 @@ export default function PackagePage() {
|
|||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<div>
|
<div>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="All Packages"
|
title="Packages"
|
||||||
data={packages}
|
data={packages}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
||||||
@@ -256,13 +256,15 @@ export default function PackagePage() {
|
|||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader className="gap-2">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-3 text-xl">
|
||||||
<Package className="h-5 w-5 text-blue-500" />
|
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
{isEditing ? 'Edit Package' : 'Create New Package'}
|
<Package className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
{isEditing ? 'Edit Package Details' : 'Create New Package'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="text-sm">
|
||||||
{isEditing ? 'Update disclosure details for your road infrastructure package' : 'Select a project and fill in the package details'}
|
{isEditing ? 'Update the technical specifications for your road infrastructure package.' : 'Select a project and provide the essential data to establish a new package.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -280,28 +282,28 @@ export default function PackagePage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
{/* Step 1: Select Project */}
|
{/* Step 1: Select Project */}
|
||||||
{!isEditing && (
|
{!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="space-y-3">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center">
|
<div className="w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold uppercase">
|
||||||
<span className="text-white text-xs font-bold">1</span>
|
01
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-blue-700 dark:text-blue-400">Select Project</p>
|
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Select Parent Project</p>
|
||||||
</div>
|
</div>
|
||||||
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
|
<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">
|
<SelectTrigger className="h-11 bg-muted/10 border-border/40 focus:ring-primary/20">
|
||||||
{loadingProjects ? (
|
{loadingProjects ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
<span className="text-gray-400">Loading projects...</span>
|
<span className="text-muted-foreground text-sm">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder="Choose a project" />
|
<SelectValue placeholder="Choose a project..." />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{projects.map(project => (
|
{projects.map(project => (
|
||||||
<SelectItem key={project.id} value={project.id}>
|
<SelectItem key={project.id} value={project.id} className="py-3">
|
||||||
<span className="font-medium">{project.name}</span>
|
<span className="font-semibold">{project.name}</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -313,47 +315,47 @@ export default function PackagePage() {
|
|||||||
{/* Step 2: Package Info */}
|
{/* Step 2: Package Info */}
|
||||||
<div className={`space-y-4 ${selectedProjectId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
<div className={`space-y-4 ${selectedProjectId || isEditing ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${selectedProjectId ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold uppercase ${selectedProjectId ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||||
<span className="text-white text-xs font-bold">2</span>
|
02
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">Package Information</p>
|
<p className="text-[11px] font-black text-muted-foreground uppercase tracking-widest">Package Information</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="pkg-name" className="text-sm font-semibold flex items-center gap-1">
|
<Label htmlFor="pkg-name" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
Package Name <span className="text-red-500">*</span>
|
Package Name <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="pkg-name"
|
id="pkg-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Package Name"
|
placeholder="e.g. Package 01"
|
||||||
className="h-10 text-sm"
|
className="h-11 bg-muted/20 border-border/60"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="region" className="text-sm font-semibold flex items-center gap-1">
|
<Label htmlFor="region" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||||
<Globe className="h-3.5 w-3.5 text-gray-400" />
|
<Globe className="h-3.5 w-3.5 opacity-60" />
|
||||||
Region <span className="text-gray-400 font-normal text-xs">(Optional)</span>
|
Region <span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="region"
|
id="region"
|
||||||
value={region}
|
value={region}
|
||||||
onChange={(e) => setRegion(e.target.value)}
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
placeholder="Region"
|
placeholder="e.g. North Zone"
|
||||||
className="h-10 text-sm focus-visible:ring-blue-500"
|
className="h-11 bg-muted/20 border-border/60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-4 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -362,25 +364,25 @@ export default function PackagePage() {
|
|||||||
resetForm()
|
resetForm()
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1"
|
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs border-border/80 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !name.trim() || !selectedProjectId}
|
disabled={isSubmitting || !name.trim() || !selectedProjectId}
|
||||||
className="flex-1 btn-blue-gradient text-white"
|
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{isEditing ? 'Updating...' : 'Creating...'}
|
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
|
||||||
</span>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <Package className="h-4 w-4" />}
|
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <Package className="h-4 w-4" />}
|
||||||
{isEditing ? 'Update Package' : 'Create Package'}
|
<span>{isEditing ? 'Update Package' : 'Create Package'}</span>
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -7,10 +7,10 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Loader2, CheckCircle2, FolderPlus, MapPin, Building2, Route, X } from "lucide-react"
|
import { Loader2, CheckCircle2, FolderPlus, MapPin, Building2, Route, X } from "lucide-react"
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation"
|
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { createProject, fetchProjects, updateProject, deleteProject, type ProjectCreate, type Project, type ProjectUpdate } from "@/lib/api"
|
import { createProject, fetchProjects, updateProject, deleteProject, type ProjectCreate, type Project, type ProjectUpdate } from "@/lib/api"
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { PoweredBy } from "@/components/powered-by"
|
import { PoweredBy } from "@/components/powered-by"
|
||||||
|
|
||||||
@@ -147,18 +147,19 @@ export default function ProjectPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns: ColumnDef<Project>[] = [
|
||||||
{
|
{
|
||||||
key: "name",
|
accessorKey: "name",
|
||||||
header: "Project Name",
|
header: "Project Name",
|
||||||
render: (project: Project) => (
|
cell: ({ row }) => (
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{project.name}</div>
|
<div className="font-semibold text-gray-900 dark:text-gray-100">{row.original.name}</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "state",
|
accessorKey: "state",
|
||||||
header: "State",
|
header: "State",
|
||||||
render: (project: Project) => {
|
cell: ({ row }) => {
|
||||||
|
const project = row.original
|
||||||
if (!project.state) return <span className="text-gray-400">—</span>
|
if (!project.state) return <span className="text-gray-400">—</span>
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -175,15 +176,14 @@ export default function ProjectPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "corridor_name",
|
accessorKey: "corridor_name",
|
||||||
header: "Corridor",
|
header: "Corridor",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen">
|
||||||
<SidebarNavigation />
|
<main className="min-h-screen relative overflow-hidden">
|
||||||
<main className="ml-20 min-h-screen relative overflow-hidden">
|
|
||||||
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
<div className="mx-auto px-6 py-8 max-w-340 relative z-10">
|
||||||
{/* Refined Header */}
|
{/* Refined Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -207,7 +207,7 @@ export default function ProjectPage() {
|
|||||||
{/* Data Table */}
|
{/* Data Table */}
|
||||||
<div>
|
<div>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="All Projects"
|
title="Projects"
|
||||||
data={projects}
|
data={projects}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
onAddNew={() => { setIsEditing(false); setIsModalOpen(true); }}
|
||||||
@@ -237,13 +237,15 @@ export default function ProjectPage() {
|
|||||||
className="max-w-2xl"
|
className="max-w-2xl"
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader className="gap-2">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-3 text-xl">
|
||||||
<FolderPlus className="h-5 w-5 text-blue-500" />
|
<div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
{isEditing ? 'Edit Project' : 'Create New Project'}
|
<FolderPlus className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
{isEditing ? 'Edit Project Details' : 'Create New Project'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="text-sm">
|
||||||
{isEditing ? 'Update disclosure details for your road infrastructure project' : 'Fill in the details for your new road infrastructure project'}
|
{isEditing ? 'Update the technical specifications for your road infrastructure project.' : 'Provide the essential road data to establish a new analysis project.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -260,112 +262,112 @@ export default function ProjectPage() {
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Project Name */}
|
{/* Project Name */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name" className="text-sm font-semibold flex items-center gap-1">
|
<Label htmlFor="name" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
Project Name <span className="text-red-500">*</span>
|
Project Name <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Project Name"
|
placeholder="Enter a descriptive project name..."
|
||||||
className="h-10 text-sm"
|
className="h-11 bg-muted/20 border-border/60"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* State & Corridor Row */}
|
{/* State & Corridor Row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="state" className="text-sm font-semibold">
|
<Label htmlFor="state" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
|
||||||
State <span className="text-gray-400 font-normal text-xs">(Optional)</span>
|
State <span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="state"
|
id="state"
|
||||||
value={state}
|
value={state}
|
||||||
onChange={(e) => setState(e.target.value)}
|
onChange={(e) => setState(e.target.value)}
|
||||||
placeholder="State"
|
placeholder="e.g. Maharashtra"
|
||||||
className="h-10 text-sm"
|
className="h-11 bg-muted/20 border-border/60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="corridor" className="text-sm font-semibold flex items-center gap-1">
|
<Label htmlFor="corridor" className="text-[11px] font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||||
<Route className="h-3.5 w-3.5 text-gray-400" />
|
<Route className="h-3.5 w-3.5 opacity-60" />
|
||||||
Corridor Name <span className="text-gray-400 font-normal text-xs">(Optional)</span>
|
Corridor Name <span className="text-[10px] lowercase font-normal opacity-70">(Optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="corridor"
|
id="corridor"
|
||||||
value={corridorName}
|
value={corridorName}
|
||||||
onChange={(e) => setCorridorName(e.target.value)}
|
onChange={(e) => setCorridorName(e.target.value)}
|
||||||
placeholder="Corridor Name"
|
placeholder="e.g. Mumbai-Goa Highway"
|
||||||
className="h-10 text-sm"
|
className="h-11 bg-muted/20 border-border/60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GPS Coordinates Grid */}
|
{/* GPS Coordinates Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
|
||||||
{/* Start Point */}
|
{/* 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">
|
<div className="space-y-4">
|
||||||
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
|
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
|
||||||
<MapPin className="h-3 w-3" /> Start Point
|
<MapPin className="h-3.5 w-3.5 opacity-60" /> START POINT
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="start-lat" className="text-[10px] text-gray-500">Lat</Label>
|
<Label htmlFor="start-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lat</Label>
|
||||||
<Input
|
<Input
|
||||||
id="start-lat"
|
id="start-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={startLat}
|
value={startLat}
|
||||||
onChange={(e) => setStartLat(e.target.value)}
|
onChange={(e) => setStartLat(e.target.value)}
|
||||||
placeholder="Lat"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="start-lng" className="text-[10px] text-gray-500">Lng</Label>
|
<Label htmlFor="start-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lng</Label>
|
||||||
<Input
|
<Input
|
||||||
id="start-lng"
|
id="start-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={startLng}
|
value={startLng}
|
||||||
onChange={(e) => setStartLng(e.target.value)}
|
onChange={(e) => setStartLng(e.target.value)}
|
||||||
placeholder="Lng"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End Point */}
|
{/* 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">
|
<div className="space-y-4">
|
||||||
<p className="text-[10px] font-bold text-blue-600 dark:text-blue-400 mb-2 uppercase tracking-wide flex items-center gap-1">
|
<p className="text-[11px] font-black text-muted-foreground tracking-widest flex items-center gap-2">
|
||||||
<MapPin className="h-3 w-3" /> End Point
|
<MapPin className="h-3.5 w-3.5 opacity-60" /> END POINT
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="end-lat" className="text-[10px] text-gray-500">Lat</Label>
|
<Label htmlFor="end-lat" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lat</Label>
|
||||||
<Input
|
<Input
|
||||||
id="end-lat"
|
id="end-lat"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={endLat}
|
value={endLat}
|
||||||
onChange={(e) => setEndLat(e.target.value)}
|
onChange={(e) => setEndLat(e.target.value)}
|
||||||
placeholder="Lat"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="end-lng" className="text-[10px] text-gray-500">Lng</Label>
|
<Label htmlFor="end-lng" className="text-[10px] font-bold text-muted-foreground uppercase opacity-70">Lng</Label>
|
||||||
<Input
|
<Input
|
||||||
id="end-lng"
|
id="end-lng"
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={endLng}
|
value={endLng}
|
||||||
onChange={(e) => setEndLng(e.target.value)}
|
onChange={(e) => setEndLng(e.target.value)}
|
||||||
placeholder="Lng"
|
placeholder="0.0000"
|
||||||
className="h-8 text-[11px]"
|
className="h-10 bg-muted/10 border-border/40 text-xs font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +375,7 @@ export default function ProjectPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-4 pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -382,25 +384,25 @@ export default function ProjectPage() {
|
|||||||
resetForm()
|
resetForm()
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1"
|
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs border-border/80 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !name.trim()}
|
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"
|
className="flex-1 h-12 font-bold uppercase tracking-wider text-xs shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{isEditing ? 'Updating...' : 'Creating...'}
|
<span>{isEditing ? 'Updating...' : 'Creating...'}</span>
|
||||||
</span>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <FolderPlus className="h-4 w-4" />}
|
{isEditing ? <CheckCircle2 className="h-4 w-4" /> : <FolderPlus className="h-4 w-4" />}
|
||||||
{isEditing ? 'Update Project' : 'Create Project'}
|
<span>{isEditing ? 'Update Project' : 'Create Project'}</span>
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -5,7 +5,6 @@ import { useRouter, useParams } from "next/navigation"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Loader2, TrendingUp } from "lucide-react"
|
import { Loader2, TrendingUp } from "lucide-react"
|
||||||
import VideoPlayerSection from "@/components/video-player-section"
|
import VideoPlayerSection from "@/components/video-player-section"
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { PoweredBy } from "@/components/powered-by"
|
import { PoweredBy } from "@/components/powered-by"
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
import { getVideoFile, clearVideoFile } from "@/lib/video-storage"
|
import { getVideoFile, clearVideoFile } from "@/lib/video-storage"
|
||||||
import { DetectionData, DetectionType } from "@/lib/types"
|
import { DetectionData, DetectionType } from "@/lib/types"
|
||||||
import { ROUTES } from "@/utils/routes"
|
import { ROUTES } from "@/utils/routes"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
@@ -95,10 +95,10 @@ export default function VideoResultsPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<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">
|
<Card className="flex flex-col items-center gap-4 p-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<p className="text-gray-500">Loading detection results...</p>
|
<p className="text-sm text-muted-foreground">Loading detection results...</p>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -106,22 +106,21 @@ export default function VideoResultsPage() {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<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">
|
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
||||||
<p className="text-red-500 font-medium">{error}</p>
|
<p className="text-destructive font-medium">{error}</p>
|
||||||
<div className="flex gap-4 mt-4">
|
<div className="flex gap-4">
|
||||||
<Button onClick={() => router.push(ROUTES.UPLOAD)} variant="outline">Back to Upload</Button>
|
<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>
|
<Button onClick={handleNewAnalysis}>New Analysis</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen max-w-340 mx-auto text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen">
|
||||||
<SidebarNavigation />
|
<main className="min-h-screen">
|
||||||
<main className="ml-20 min-h-screen">
|
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
||||||
<div className="container mx-auto px-6 py-8 max-w-full">
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={getTitle()}
|
title={getTitle()}
|
||||||
@@ -132,31 +131,33 @@ export default function VideoResultsPage() {
|
|||||||
|
|
||||||
{session && (
|
{session && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between rounded-md px-4 py-3 bg-white/60 backdrop-blur-sm">
|
<Card className="p-0 border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-12">
|
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
||||||
<div className="flex flex-col">
|
<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-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
|
||||||
<span className="text-base font-bold text-gray-900 dark:text-white leading-tight">
|
<span className="text-base font-bold leading-tight">
|
||||||
{session.projectName}
|
{session.projectName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Package</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
|
||||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
{session.packageName}
|
{session.packageName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Location</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
|
||||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
{session.locationName}
|
{session.locationName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleNewAnalysis} variant="outline" size="sm" className="btn-blue-gradient">
|
<Button onClick={handleNewAnalysis} variant="outline" size="sm" className="font-semibold px-6 shrink-0 h-9">
|
||||||
Start New
|
Start New Analysis
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
150
src/app/(modules)/results/page.tsx
Normal file
150
src/app/(modules)/results/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Loader2, TrendingUp } from "lucide-react"
|
||||||
|
import VideoPlayerSection from "@/components/video-player-section"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { PoweredBy } from "@/components/powered-by"
|
||||||
|
import {
|
||||||
|
type SessionContext,
|
||||||
|
loadSession,
|
||||||
|
loadVideoData,
|
||||||
|
isSessionValid,
|
||||||
|
clearSession
|
||||||
|
} from "@/lib/api"
|
||||||
|
import { clearVideoFile } from "@/lib/video-storage"
|
||||||
|
import { DetectionData, DetectionType } from "@/lib/types"
|
||||||
|
import { ROUTES } from "@/utils/routes"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function ResultsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [session, setSession] = useState<SessionContext | null>(null)
|
||||||
|
const [detectionData] = useState<DetectionData | null>(null)
|
||||||
|
const [detectionType] = useState<DetectionType>("pothole-detection")
|
||||||
|
const [videoId] = useState<string | null>(null)
|
||||||
|
const [videoFile] = useState<File | null>(null)
|
||||||
|
const [isLoading] = useState(true)
|
||||||
|
const [error] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load session and video data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const storedSession = loadSession()
|
||||||
|
const videoData = loadVideoData()
|
||||||
|
|
||||||
|
if (!isSessionValid(storedSession) || !videoData) {
|
||||||
|
router.replace(ROUTES.NEW_ANALYSIS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a videoId, redirect to the dynamic results page
|
||||||
|
if (videoData.videoId) {
|
||||||
|
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(storedSession)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const handleNewAnalysis = async () => {
|
||||||
|
// Clear video from IndexedDB
|
||||||
|
if (videoId) {
|
||||||
|
try {
|
||||||
|
await clearVideoFile(videoId)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to clear video file:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSession()
|
||||||
|
router.push(ROUTES.NEW_ANALYSIS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (detectionType === "pothole-detection") return "Pothole Detection Results"
|
||||||
|
if (detectionType === "sign-board-detection") return "Signboard Detection Results"
|
||||||
|
return "Pothole & Signboard Detection Results"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Card className="flex flex-col items-center gap-4 p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading results...</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
||||||
|
<p className="text-destructive font-medium">{error}</p>
|
||||||
|
<Button onClick={handleNewAnalysis}>Start New Analysis</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
||||||
|
<div className="mb-8">
|
||||||
|
<PageHeader
|
||||||
|
title={getTitle()}
|
||||||
|
description="View your AI-powered road analysis results"
|
||||||
|
icon={TrendingUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Card className="p-0 border shadow-sm overflow-hidden">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
|
||||||
|
<span className="text-base font-bold leading-tight">
|
||||||
|
{session.projectName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
|
||||||
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
|
{session.packageName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
|
||||||
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
|
{session.locationName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleNewAnalysis} variant="outline" size="sm" className="font-semibold px-6 shrink-0 h-9">
|
||||||
|
Start New Analysis
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detectionData && videoId && (
|
||||||
|
<VideoPlayerSection
|
||||||
|
data={detectionData}
|
||||||
|
videoId={videoId}
|
||||||
|
videoFile={videoFile}
|
||||||
|
detectionType={detectionType}
|
||||||
|
projectId={session?.projectId || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<PoweredBy />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,18 +2,16 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import { useRouter, useParams } from "next/navigation"
|
import { useRouter, useParams } from "next/navigation"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Loader2, TrendingUp } from "lucide-react"
|
import { Loader2, TrendingUp } from "lucide-react"
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { PoweredBy } from "@/components/powered-by"
|
import { PoweredBy } from "@/components/powered-by"
|
||||||
import {
|
import {
|
||||||
type SessionContext,
|
type SessionContext,
|
||||||
loadSession,
|
loadSession,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { getVideoFile } from "@/lib/video-storage"
|
|
||||||
import { storeVideoFile } from "@/lib/video-storage"
|
|
||||||
import { ROUTES } from "@/utils/routes"
|
import { ROUTES } from "@/utils/routes"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
||||||
const WS_URL = API_URL?.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://")
|
const WS_URL = API_URL?.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://")
|
||||||
@@ -28,7 +26,6 @@ export default function VideoProcessingPage() {
|
|||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
const [statusMessage, setStatusMessage] = useState("Initializing...")
|
const [statusMessage, setStatusMessage] = useState("Initializing...")
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [detectionType, setDetectionType] = useState<string>("pothole-detection")
|
|
||||||
|
|
||||||
const connectWebSocket = useCallback((vid: string) => {
|
const connectWebSocket = useCallback((vid: string) => {
|
||||||
const ws = new WebSocket(`${WS_URL}/ws/${vid}`)
|
const ws = new WebSocket(`${WS_URL}/ws/${vid}`)
|
||||||
@@ -123,18 +120,17 @@ export default function VideoProcessingPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<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">
|
<Card className="p-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen">
|
||||||
<SidebarNavigation />
|
<main className="min-h-screen flex flex-col">
|
||||||
<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 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">
|
<div className="mb-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Processing Analysis"
|
title="Processing Analysis"
|
||||||
@@ -144,85 +140,78 @@ export default function VideoProcessingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session && (
|
{session && (
|
||||||
<div className="mb-4">
|
<div className="mb-6">
|
||||||
<div className="rounded-md px-4 py-3 bg-white/60 backdrop-blur-sm">
|
<Card className="p-0 border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-12">
|
<div className="flex flex-col md:flex-row md:items-center py-4 px-6 gap-6 bg-card">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-12 gap-y-4 flex-1">
|
||||||
<div className="flex flex-col">
|
<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-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
|
||||||
<span className="text-base font-bold text-gray-900 dark:text-white leading-tight">
|
<span className="text-base font-bold leading-tight">
|
||||||
{session.projectName}
|
{session.projectName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Package</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
|
||||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
{session.packageName}
|
{session.packageName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Location</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
|
||||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
{session.locationName}
|
{session.locationName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</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]">
|
<Card className="flex-1 flex flex-col items-center justify-center py-12 px-8 min-h-[450px]">
|
||||||
<div className="flex flex-col items-center justify-center w-full max-w-4xl space-y-8">
|
<div className="flex flex-col items-center justify-center w-full max-w-2xl space-y-10">
|
||||||
{/* Visual Spinner Area */}
|
<div className="text-center space-y-4">
|
||||||
<div className="relative">
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
<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">
|
Processing Analysis
|
||||||
<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>
|
</h2>
|
||||||
<p className="text-sm font-medium text-gray-400 dark:text-gray-500 font-mono tracking-wider">
|
<p className="text-sm font-mono text-muted-foreground tracking-widest">
|
||||||
ID: {videoId}
|
ID: {videoId}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<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-bold text-muted-foreground text-xs uppercase tracking-widest">Progress</span>
|
||||||
<span className="font-black text-blue-600 dark:text-blue-400 text-base">{progress}%</span>
|
<span className="font-bold text-primary text-lg">{progress}%</span>
|
||||||
</div>
|
</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-4 rounded-full bg-secondary overflow-hidden border">
|
||||||
<div
|
<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)]"
|
className="h-full bg-primary transition-all duration-1000 ease-in-out"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center pt-2">
|
||||||
<p className="text-sm font-bold text-gray-500 dark:text-gray-400 animate-pulse">
|
<div className="inline-flex items-center gap-3 px-4 py-2 rounded-full border bg-secondary/30">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
{statusMessage}
|
{statusMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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">
|
<div className="w-full p-6 rounded-md bg-destructive/10 border border-destructive/20 text-center space-y-3">
|
||||||
<p className="text-red-600 dark:text-red-400 text-sm font-medium">{error}</p>
|
<p className="text-destructive font-medium">{error}</p>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="mt-3 text-xs font-bold text-red-700 dark:text-red-300 underline underline-offset-4"
|
|
||||||
>
|
>
|
||||||
Retry Connection
|
Retry Connection
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Loader2, TrendingUp } from "lucide-react"
|
import { Loader2, TrendingUp } from "lucide-react"
|
||||||
import { SidebarNavigation } from "@/components/sidebar-navigation"
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { PoweredBy } from "@/components/powered-by"
|
import { PoweredBy } from "@/components/powered-by"
|
||||||
import {
|
import {
|
||||||
@@ -20,9 +19,9 @@ import {
|
|||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { ROUTES } from "@/utils/routes"
|
import { ROUTES } from "@/utils/routes"
|
||||||
import { storeVideoFile } from "@/lib/video-storage"
|
import { storeVideoFile } from "@/lib/video-storage"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
||||||
const WS_URL = API_URL?.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://")
|
|
||||||
|
|
||||||
const DETECTION_TYPES = [
|
const DETECTION_TYPES = [
|
||||||
{ value: "pothole-detection", label: "Pothole Detection" },
|
{ value: "pothole-detection", label: "Pothole Detection" },
|
||||||
@@ -40,8 +39,6 @@ const DETECTION_METHODS = [
|
|||||||
|
|
||||||
type DetectionType = "pothole-detection" | "sign-board-detection" | "pot-sign-detection"
|
type DetectionType = "pothole-detection" | "sign-board-detection" | "pot-sign-detection"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [session, setSession] = useState<SessionContext | null>(null)
|
const [session, setSession] = useState<SessionContext | null>(null)
|
||||||
@@ -71,8 +68,6 @@ export default function UploadPage() {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setError("Please select a video file")
|
setError("Please select a video file")
|
||||||
@@ -149,23 +144,19 @@ export default function UploadPage() {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<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">
|
<Card className="p-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen text-gray-900 dark:text-gray-100">
|
<div className="min-h-screen">
|
||||||
{/* Sidebar Navigation */}
|
|
||||||
<SidebarNavigation />
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="ml-20 min-h-screen relative overflow-hidden flex flex-col">
|
<main className="min-h-screen flex flex-col">
|
||||||
|
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 flex flex-col">
|
||||||
<div className="flex-1 container mx-auto px-6 py-6 max-w-340 relative z-10 flex flex-col">
|
{/* Header */}
|
||||||
{/* Refined Header */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={getTitle()}
|
title={getTitle()}
|
||||||
@@ -176,56 +167,54 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
{/* Compact Session Info Bar */}
|
{/* Compact Session Info Bar */}
|
||||||
{session && (
|
{session && (
|
||||||
<div className="mb-4">
|
<div className="mb-6">
|
||||||
<div className="rounded-md px-4 py-3 bg-white/60 backdrop-blur-sm ">
|
<Card className="p-0 border shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
||||||
<div className="flex items-center gap-12">
|
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
||||||
<div className="flex flex-col">
|
<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-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Project</span>
|
||||||
<span className="text-base font-bold text-gray-900 dark:text-white leading-tight">
|
<span className="text-base font-bold leading-tight">
|
||||||
{session.projectName}
|
{session.projectName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Package</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Package</span>
|
||||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
{session.packageName}
|
{session.packageName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1.5">Location</span>
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">Location</span>
|
||||||
<span className="text-sm font-bold text-gray-700 dark:text-gray-300 leading-tight">
|
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||||
{session.locationName}
|
{session.locationName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleBackToSelection}
|
onClick={handleBackToSelection}
|
||||||
className="btn-blue-gradient"
|
className="font-semibold px-6 shrink-0 h-9"
|
||||||
>
|
>
|
||||||
Change Selection
|
Change Selection
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upload Card */}
|
{/* Upload Card */}
|
||||||
<Card className="bg-white/60 backdrop-blur-md rounded-xl overflow-hidden flex-1">
|
<Card className="flex-1">
|
||||||
<CardHeader className="pb-4 ">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-xl font-bold">
|
<CardTitle className="text-xl font-bold">
|
||||||
<span className="heading-gradient">
|
|
||||||
Upload Video
|
Upload Video
|
||||||
</span>
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm">
|
<CardDescription className="text-sm">
|
||||||
Select video file, detection type, vehicle speed, and method for analysis
|
Select video file, detection type, vehicle speed, and method for analysis
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4 space-y-4">
|
<CardContent className="pt-4 space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4">
|
||||||
{/* Video File Input */}
|
{/* Video File Input */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="video-file" className="text-sm font-semibold">
|
<Label htmlFor="video-file" className="text-sm font-semibold">
|
||||||
@@ -240,7 +229,7 @@ export default function UploadPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
}}
|
}}
|
||||||
disabled={uploading}
|
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"
|
className="h-11 bg-muted/20 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary/10 file:text-primary file:font-medium file:text-xs hover:file:bg-primary/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -258,13 +247,13 @@ export default function UploadPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
}}
|
}}
|
||||||
disabled={uploading}
|
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"
|
className="h-11 bg-muted/20 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:bg-primary/10 file:text-primary file:font-medium file:text-xs hover:file:bg-primary/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detection, Speed, and Method Row */}
|
{/* Detection, Speed, and Method Row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{/* Detection Type */}
|
{/* Detection Type */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="detection-type" className="text-sm font-semibold">
|
<Label htmlFor="detection-type" className="text-sm font-semibold">
|
||||||
@@ -275,13 +264,13 @@ export default function UploadPage() {
|
|||||||
onValueChange={(v) => setDetectionType(v as DetectionType)}
|
onValueChange={(v) => setDetectionType(v as DetectionType)}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="detection-type" className="h-10 bg-gray-50 dark:bg-gray-800">
|
<SelectTrigger id="detection-type" className="h-11">
|
||||||
<SelectValue placeholder="Select detection type" />
|
<SelectValue placeholder="Select detection type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DETECTION_TYPES.map((type) => (
|
{DETECTION_TYPES.map((type) => (
|
||||||
<SelectItem key={type.value} value={type.value}>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
<span className="font-medium">{type.label}</span>
|
{type.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -301,7 +290,7 @@ export default function UploadPage() {
|
|||||||
value={speed}
|
value={speed}
|
||||||
onChange={(e) => setSpeed(Number(e.target.value))}
|
onChange={(e) => setSpeed(Number(e.target.value))}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="h-10 bg-gray-50 dark:bg-gray-800"
|
className="h-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -315,13 +304,13 @@ export default function UploadPage() {
|
|||||||
onValueChange={setSelectMethod}
|
onValueChange={setSelectMethod}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="select-method" className="h-10 bg-gray-50 dark:bg-gray-800">
|
<SelectTrigger id="select-method" className="h-11">
|
||||||
<SelectValue placeholder="Select method" />
|
<SelectValue placeholder="Select method" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DETECTION_METHODS.map((method) => (
|
{DETECTION_METHODS.map((method) => (
|
||||||
<SelectItem key={method.value} value={method.value}>
|
<SelectItem key={method.value} value={method.value}>
|
||||||
<span className="font-medium">{method.label}</span>
|
{method.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -331,11 +320,8 @@ export default function UploadPage() {
|
|||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{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="p-4 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm leading-relaxed whitespace-pre-line">
|
||||||
<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">
|
{error}
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -343,23 +329,18 @@ export default function UploadPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!file || uploading}
|
disabled={!file || uploading}
|
||||||
className={`w-full h-12 text-sm font-semibold transition-all rounded-full ${file && !uploading
|
className="w-full h-14 text-base font-bold uppercase tracking-wide"
|
||||||
? 'btn-blue-gradient'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
Processing...
|
<span>Processing...</span>
|
||||||
</span>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
"Upload and Process"
|
"Upload and Process"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -4,116 +4,75 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Primary - Indigo */
|
--radius: 0.65rem;
|
||||||
--primary: oklch(0.55 0.24 264);
|
--background: oklch(1 0 0);
|
||||||
--primary-foreground: oklch(0.98 0.01 264);
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
/* Background & Surfaces */
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
--background: hsla(210, 40%, 98%, 0.2);
|
--popover: oklch(1 0 0);
|
||||||
--foreground: oklch(0.15 0.02 280);
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
--dialog: white;
|
--primary: oklch(0.541 0.281 293.009);
|
||||||
--card: white;
|
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
--card-foreground: oklch(0.15 0.02 280);
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
--popover: white;
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--popover-foreground: oklch(0.15 0.02 280);
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
/* Secondary */
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
--secondary: oklch(0.95 0.02 264);
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--secondary-foreground: oklch(0.25 0.05 264);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
/* Muted */
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--muted: oklch(0.94 0.02 280);
|
--ring: oklch(0.702 0.183 293.541);
|
||||||
--muted-foreground: oklch(0.45 0.03 280);
|
--chart-1: oklch(0.811 0.111 293.571);
|
||||||
|
--chart-2: oklch(0.606 0.25 292.717);
|
||||||
/* Accent - Cyan */
|
--chart-3: oklch(0.541 0.281 293.009);
|
||||||
--accent: oklch(0.92 0.04 200);
|
--chart-4: oklch(0.491 0.27 292.581);
|
||||||
--accent-foreground: oklch(0.25 0.08 200);
|
--chart-5: oklch(0.432 0.232 292.759);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
/* Destructive */
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
--destructive: oklch(0.55 0.22 25);
|
--sidebar-primary: oklch(0.541 0.281 293.009);
|
||||||
--destructive-foreground: oklch(0.98 0.01 25);
|
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
/* Borders & Inputs */
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--border: hsla(210, 100%, 85%, 1);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.92 0.02 280);
|
--sidebar-ring: oklch(0.702 0.183 293.541);
|
||||||
--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 {
|
.dark {
|
||||||
/* Primary - Light Indigo */
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--primary: oklch(0.7 0.2 264);
|
--foreground: oklch(0.985 0 0);
|
||||||
--primary-foreground: oklch(0.15 0.02 264);
|
--card: oklch(0.21 0.006 285.885);
|
||||||
--dialog: white;
|
--card-foreground: oklch(0.985 0 0);
|
||||||
/* Background & Surfaces */
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
--background: hsla(280, 20%, 12%, 0.3);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--foreground: oklch(0.95 0.01 280);
|
--primary: oklch(0.606 0.25 292.717);
|
||||||
--card: hsla(280, 20%, 16%, 0.4);
|
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
--card-foreground: oklch(0.95 0.01 280);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--popover: hsla(280, 20%, 16%, 0.7);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--popover-foreground: oklch(0.95 0.01 280);
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
/* Secondary */
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
--secondary: oklch(0.22 0.03 264);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--secondary-foreground: oklch(0.9 0.02 264);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
/* Muted */
|
--input: oklch(1 0 0 / 15%);
|
||||||
--muted: oklch(0.2 0.02 280);
|
--ring: oklch(0.38 0.189 293.745);
|
||||||
--muted-foreground: oklch(0.65 0.02 280);
|
--chart-1: oklch(0.811 0.111 293.571);
|
||||||
|
--chart-2: oklch(0.606 0.25 292.717);
|
||||||
/* Accent */
|
--chart-3: oklch(0.541 0.281 293.009);
|
||||||
--accent: oklch(0.22 0.04 200);
|
--chart-4: oklch(0.491 0.27 292.581);
|
||||||
--accent-foreground: oklch(0.85 0.08 200);
|
--chart-5: oklch(0.432 0.232 292.759);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
/* Destructive */
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.45 0.18 25);
|
--sidebar-primary: oklch(0.606 0.25 292.717);
|
||||||
--destructive-foreground: oklch(0.9 0.08 25);
|
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
/* Borders & Inputs */
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(0.25 0.02 280);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(0.22 0.02 280);
|
--sidebar-ring: oklch(0.38 0.189 293.745);
|
||||||
--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 {
|
@theme inline {
|
||||||
--font-sans: "Geist", "Geist Fallback", system-ui, sans-serif;
|
--font-sans: "Geist", "Geist Fallback", system-ui, sans-serif;
|
||||||
--font-mono: "Geist Mono", "Geist Mono Fallback", monospace;
|
--font-mono: "Geist Mono", "Geist Mono Fallback", monospace;
|
||||||
@@ -157,7 +116,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
@layer base {
|
/* @layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
@@ -181,54 +140,15 @@
|
|||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|
||||||
/*
|
@layer base {
|
||||||
Definitive Layout Stability Reset
|
* {
|
||||||
Prevents Radix UI / Shadcn from "sliding" content when scroll-locking occurs
|
@apply border-border outline-ring/50;
|
||||||
*/
|
}
|
||||||
:root {
|
body {
|
||||||
--removed-body-scroll-bar-size: 0px;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,53 +1,42 @@
|
|||||||
import type React from "react"
|
import type { Metadata } from "next";
|
||||||
import type { Metadata } from "next"
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { Geist, Geist_Mono } from "next/font/google"
|
import "./globals.css";
|
||||||
import { Analytics } from "@vercel/analytics/next"
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import "./globals.css"
|
|
||||||
import "leaflet/dist/leaflet.css"
|
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] })
|
const geistSans = Geist({
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] })
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Pothole Detection System",
|
title: "Create Next App",
|
||||||
description: "AI-powered pothole detection and tracking system",
|
description: "Generated by create next app",
|
||||||
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-scroll-behavior="smooth">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`font-sans antialiased`} suppressHydrationWarning>
|
<body
|
||||||
<TooltipProvider>
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
</ThemeProvider>
|
||||||
</TooltipProvider>
|
|
||||||
<Analytics />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
106
src/components/app-sidebar.tsx
Normal file
106
src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Plus,
|
||||||
|
FolderPlus,
|
||||||
|
Package,
|
||||||
|
MapPin,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { NavUser } from "@/components/nav-user";
|
||||||
|
import { ROUTES } from "@/utils/routes";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
// This is the navigation data
|
||||||
|
const data = {
|
||||||
|
user: {
|
||||||
|
name: "shadcn",
|
||||||
|
email: "m@example.com",
|
||||||
|
avatar: "/avatars/shadcn.jpg",
|
||||||
|
},
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "Dashboard",
|
||||||
|
url: ROUTES.DASHBOARD,
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "New Analysis",
|
||||||
|
url: ROUTES.NEW_ANALYSIS,
|
||||||
|
icon: Plus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Project",
|
||||||
|
url: ROUTES.PROJECT,
|
||||||
|
icon: FolderPlus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Package",
|
||||||
|
url: ROUTES.PACKAGE,
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Location",
|
||||||
|
url: ROUTES.LOCATION,
|
||||||
|
icon: MapPin,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon" {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<Package className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="truncate font-semibold">Vision Road</span>
|
||||||
|
<span className="truncate text-xs">Analytics Platform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{data.navMain.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip={item.title}
|
||||||
|
isActive={pathname === item.url}
|
||||||
|
>
|
||||||
|
<Link href={item.url}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser user={data.user} />
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { FolderOpen, MapPin, Building2 } from "lucide-react"
|
|
||||||
import { type Project } from "@/lib/api"
|
import { type Project } from "@/lib/api"
|
||||||
|
|
||||||
interface CompactProjectSelectorProps {
|
interface CompactProjectSelectorProps {
|
||||||
@@ -26,7 +25,7 @@ export function CompactProjectSelector({
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}: CompactProjectSelectorProps) {
|
}: CompactProjectSelectorProps) {
|
||||||
return (
|
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">
|
<div className="flex flex-wrap items-center gap-3 p-3 rounded-md border">
|
||||||
{/* Project Dropdown */}
|
{/* Project Dropdown */}
|
||||||
<div className="flex flex-col min-w-[120px]">
|
<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>
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground leading-none mb-1">Project</span>
|
||||||
@@ -35,7 +34,7 @@ export function CompactProjectSelector({
|
|||||||
onValueChange={onProjectChange}
|
onValueChange={onProjectChange}
|
||||||
disabled={isLoading || projects.length === 0}
|
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">
|
<SelectTrigger className="h-auto p-0 border-0 shadow-none focus:ring-0 text-sm font-bold bg-transparent text-left">
|
||||||
<SelectValue placeholder="Select project" />
|
<SelectValue placeholder="Select project" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -51,10 +50,10 @@ export function CompactProjectSelector({
|
|||||||
{/* Corridor Badge */}
|
{/* Corridor Badge */}
|
||||||
{selectedProject?.corridor_name && (
|
{selectedProject?.corridor_name && (
|
||||||
<>
|
<>
|
||||||
<div className="h-8 w-px bg-gray-200 dark:bg-gray-700 mx-2" />
|
<div className="h-8 w-px bg-border mx-2" />
|
||||||
<div className="flex flex-col">
|
<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-[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">
|
<span className="text-xs font-bold text-muted-foreground">
|
||||||
{selectedProject.corridor_name}
|
{selectedProject.corridor_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,10 +63,10 @@ export function CompactProjectSelector({
|
|||||||
{/* State Badge */}
|
{/* State Badge */}
|
||||||
{selectedProject?.state && (
|
{selectedProject?.state && (
|
||||||
<>
|
<>
|
||||||
<div className="h-8 w-px bg-gray-200 dark:bg-gray-700 mx-2" />
|
<div className="h-8 w-px bg-border mx-2" />
|
||||||
<div className="flex flex-col">
|
<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-[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">
|
<span className="text-xs font-bold text-muted-foreground">
|
||||||
{selectedProject.state}
|
{selectedProject.state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Loader2, MapPin, AlertTriangle, RectangleHorizontal } from "lucide-react"
|
import { Loader2, MapPin } from "lucide-react"
|
||||||
import { fetchAllDetections, type Detection } from "@/lib/api"
|
import { type Detection } from "@/lib/api"
|
||||||
|
|
||||||
// Dynamically import the map to avoid SSR issues with Leaflet
|
// Dynamically import the map to avoid SSR issues with Leaflet
|
||||||
const DashboardMapContent = dynamic(
|
const DashboardMapContent = dynamic(
|
||||||
@@ -89,18 +89,18 @@ export function DashboardMap({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`overflow-hidden pb-0 bg-white/60 rounded-b-none shadow-none border-none ${className}`}>
|
<Card className={`overflow-hidden ${className}`}>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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 ">
|
<div className="p-2 rounded-md bg-secondary text-secondary-foreground flex items-center justify-center">
|
||||||
<MapPin className="h-5 w-5 text-white" />
|
<MapPin className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base font-bold text-[#2563eb]">
|
<CardTitle className="text-base font-bold">
|
||||||
Detection Map
|
Detection Map
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isLoading ? "Loading..." : `${validDetections.length} detections with GPS coordinates`}
|
{isLoading ? "Loading..." : `${validDetections.length} detections with GPS coordinates`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,24 +109,24 @@ export function DashboardMap({
|
|||||||
{/* Compact Color Legend */}
|
{/* 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 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="flex items-center gap-1">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-[#ef4444] shadow-sm shadow-[#ef4444]/30" />
|
<div className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
||||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Potholes ({counts.pothole})</span>
|
<span className="text-muted-foreground font-medium">Potholes ({counts.pothole})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-[#3b82f6] shadow-sm shadow-[#3b82f6]/30" />
|
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
|
||||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Defected Signs ({counts.defected_sign_board})</span>
|
<span className="text-muted-foreground font-medium">Defected Signs ({counts.defected_sign_board})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-[#f59e0b] shadow-sm shadow-[#f59e0b]/30" />
|
<div className="w-2.5 h-2.5 rounded-full bg-amber-500" />
|
||||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Cracks ({counts.road_crack})</span>
|
<span className="text-muted-foreground font-medium">Cracks ({counts.road_crack})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-[#6366f1] shadow-sm shadow-[#6366f1]/30" />
|
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
|
||||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Markings ({counts.damaged_road_marking})</span>
|
<span className="text-muted-foreground font-medium">Markings ({counts.damaged_road_marking})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-[#10b981] shadow-sm shadow-[#10b981]/30" />
|
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
|
||||||
<span className="text-gray-600 dark:text-gray-400 font-medium">Good Signs ({counts.good_sign_board})</span>
|
<span className="text-muted-foreground font-medium">Good Signs ({counts.good_sign_board})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,18 +134,18 @@ export function DashboardMap({
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="h-[500px] p-2 w-full relative">
|
<div className="h-[500px] p-2 w-full relative">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900/50">
|
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||||
<Loader2 className="h-8 w-8 text-indigo-500" />
|
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900/50">
|
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||||
<p className="text-gray-500">{error}</p>
|
<p className="text-muted-foreground">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : validDetections.length === 0 ? (
|
) : validDetections.length === 0 ? (
|
||||||
<div className="h-full w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900/50">
|
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-gray-500">No detections with GPS coordinates found</p>
|
<p className="text-muted-foreground">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>
|
<p className="text-sm text-muted-foreground mt-1 opacity-70">Process some videos to see detections on the map</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function DashboardSkeleton() {
|
|||||||
{/* Stats Cards Skeleton */}
|
{/* Stats Cards Skeleton */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<Card key={i} className="bg-white/60 backdrop-blur-lg border-none shadow-none overflow-hidden py-8 px-6">
|
<Card key={i} className="py-8 px-6">
|
||||||
<CardContent className="p-0 flex items-center justify-between gap-6">
|
<CardContent className="p-0 flex items-center justify-between gap-6">
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
@@ -25,8 +25,8 @@ export function DashboardSkeleton() {
|
|||||||
{/* Charts Row Skeleton */}
|
{/* Charts Row Skeleton */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||||
{[...Array(2)].map((_, i) => (
|
{[...Array(2)].map((_, i) => (
|
||||||
<Card key={i} className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden border-none shadow-none">
|
<Card key={i}>
|
||||||
<CardHeader className="pb-2 border-none">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="w-10 h-10 rounded-md" />
|
<Skeleton className="w-10 h-10 rounded-md" />
|
||||||
<Skeleton className="h-6 w-48" />
|
<Skeleton className="h-6 w-48" />
|
||||||
@@ -40,7 +40,7 @@ export function DashboardSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map Skeleton */}
|
{/* Map Skeleton */}
|
||||||
<Card className="rounded-md bg-white/60 backdrop-blur-lg overflow-hidden border-none shadow-none">
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function DetectionDonutChart({
|
|||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const data = payload[0]
|
const data = payload[0]
|
||||||
return (
|
return (
|
||||||
<div className="bg-background/95 backdrop-blur-sm border border-border rounded-lg px-3 py-2 shadow-lg">
|
<div className="bg-popover border border-border rounded-lg px-3 py-2 shadow-lg">
|
||||||
<p className="font-medium text-sm">{data.name}</p>
|
<p className="font-medium text-sm">{data.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Count: <span className="font-semibold text-foreground">{data.value}</span>
|
Count: <span className="font-semibold text-foreground">{data.value}</span>
|
||||||
|
|||||||
@@ -36,26 +36,26 @@ export function FilterSelector({
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}: FilterSelectorProps) {
|
}: FilterSelectorProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-5 p-5">
|
||||||
{/* Project Dropdown */}
|
{/* Project Dropdown */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="p-1 rounded-md bg-blue-100 dark:bg-blue-900/50">
|
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-blue-600" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Project</span>
|
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">Project</span>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={selectedProjectId || ""}
|
value={selectedProjectId || ""}
|
||||||
onValueChange={onProjectChange}
|
onValueChange={onProjectChange}
|
||||||
disabled={isLoading || projects.length === 0}
|
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">
|
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60 shadow-xs">
|
||||||
<SelectValue placeholder="Select project" />
|
<SelectValue placeholder="Select project" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-xl border-slate-200">
|
<SelectContent>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<SelectItem key={project.id} value={project.id} className="rounded-lg">
|
<SelectItem key={project.id} value={project.id}>
|
||||||
{project.name}
|
{project.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -65,25 +65,25 @@ export function FilterSelector({
|
|||||||
|
|
||||||
{/* Package Dropdown */}
|
{/* Package Dropdown */}
|
||||||
{selectedProjectId && (
|
{selectedProjectId && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="p-1 rounded-md bg-emerald-100 dark:bg-emerald-900/50">
|
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
|
||||||
<Package className="h-3.5 w-3.5 text-emerald-600" />
|
<Package className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Package</span>
|
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">Package</span>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={selectedPackageId || "all"}
|
value={selectedPackageId || "all"}
|
||||||
onValueChange={onPackageChange}
|
onValueChange={onPackageChange}
|
||||||
disabled={isLoading}
|
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">
|
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60 shadow-xs">
|
||||||
<SelectValue placeholder="All packages" />
|
<SelectValue placeholder="All packages" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-xl border-slate-200">
|
<SelectContent>
|
||||||
<SelectItem value="all" className="rounded-lg">All Packages</SelectItem>
|
<SelectItem value="all">All Packages</SelectItem>
|
||||||
{packages.map((pkg) => (
|
{packages.map((pkg) => (
|
||||||
<SelectItem key={pkg.id} value={pkg.id} className="rounded-lg">
|
<SelectItem key={pkg.id} value={pkg.id}>
|
||||||
{pkg.name}
|
{pkg.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -94,25 +94,25 @@ export function FilterSelector({
|
|||||||
|
|
||||||
{/* Location Dropdown */}
|
{/* Location Dropdown */}
|
||||||
{selectedPackageId && selectedPackageId !== "all" && (
|
{selectedPackageId && selectedPackageId !== "all" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="p-1 rounded-md bg-purple-100 dark:bg-purple-900/50">
|
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
|
||||||
<MapPin className="h-3.5 w-3.5 text-purple-600" />
|
<MapPin className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-wider">Location</span>
|
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">Location</span>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={selectedLocationId || "all"}
|
value={selectedLocationId || "all"}
|
||||||
onValueChange={onLocationChange}
|
onValueChange={onLocationChange}
|
||||||
disabled={isLoading}
|
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">
|
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60 shadow-xs">
|
||||||
<SelectValue placeholder="All locations" />
|
<SelectValue placeholder="All locations" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-xl border-slate-200">
|
<SelectContent>
|
||||||
<SelectItem value="all" className="rounded-lg">All Locations</SelectItem>
|
<SelectItem value="all">All Locations</SelectItem>
|
||||||
{locations.map((loc) => (
|
{locations.map((loc) => (
|
||||||
<SelectItem key={loc.id} value={loc.id} className="rounded-lg">
|
<SelectItem key={loc.id} value={loc.id}>
|
||||||
{loc.name}
|
{loc.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,88 +2,41 @@
|
|||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { LucideIcon } from "lucide-react"
|
import { LucideIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface GradientStatsCardProps {
|
interface GradientStatsCardProps {
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
value: number | string
|
value: number | string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
gradient: "green" | "coral" | "blue" | "purple" | "orange" | "indigo" | "emerald"
|
gradient?: "green" | "coral" | "blue" | "purple" | "orange" | "indigo" | "emerald"
|
||||||
isLoading?: boolean
|
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({
|
export function GradientStatsCard({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
value,
|
value,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
gradient,
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}: GradientStatsCardProps) {
|
}: GradientStatsCardProps) {
|
||||||
const styles = gradientStyles[gradient]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white/60 backdrop-blur-lg border-none shadow-none hover:shadow-none overflow-hidden py-8 px-6">
|
<Card className="overflow-hidden border shadow-sm">
|
||||||
<CardContent className="p-0 flex items-center justify-between gap-6">
|
<CardContent className="p-6 flex items-center justify-between gap-4">
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
<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">
|
<h3 className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="h-14 w-24 bg-gray-100 dark:bg-gray-800 rounded-xl mt-2" />
|
<div className="h-10 w-24 bg-muted animate-pulse rounded-md mt-1" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-4xl font-bold heading-blue-gradient">
|
<p className="text-3xl font-bold tracking-tight">
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="text-xs font-medium text-gray-500 mt-1">
|
<p className="text-[10px] font-semibold text-muted-foreground mt-0.5">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -92,14 +45,8 @@ export function GradientStatsCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="p-3 rounded-lg bg-secondary shrink-0">
|
||||||
style={{
|
<Icon className="h-6 w-6 text-primary" />
|
||||||
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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function LocationBarChart({ data, isLoading = false }: LocationBarChartPr
|
|||||||
content={({ active, payload, label }) => {
|
content={({ active, payload, label }) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background/95 backdrop-blur-sm border border-border rounded-lg px-3 py-2 shadow-lg">
|
<div className="bg-popover border border-border rounded-lg px-3 py-2 shadow-lg">
|
||||||
<p className="font-medium text-xs mb-1">{label}</p>
|
<p className="font-medium text-xs mb-1">{label}</p>
|
||||||
{payload.map((entry, index) => (
|
{payload.map((entry, index) => (
|
||||||
<div key={index} className="flex items-center gap-2 text-xs">
|
<div key={index} className="flex items-center gap-2 text-xs">
|
||||||
|
|||||||
52
src/components/dashboard/stats-card.tsx
Normal file
52
src/components/dashboard/stats-card.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
value: number | string
|
||||||
|
icon: LucideIcon
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
isLoading = false
|
||||||
|
}: StatsCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="py-6 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-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-10 w-24 bg-muted rounded-md mt-2 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-md bg-secondary text-secondary-foreground">
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
64
src/components/data-table/Footer/index.tsx
Normal file
64
src/components/data-table/Footer/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
export function TableFooter<TData>({ table }: { table: Table<TData> }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end px-4 py-4 border-t gap-6 lg:gap-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">Rows per page</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||||
|
{table.getPageCount() || 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/data-table/Header/SearchBar.tsx
Normal file
17
src/components/data-table/Header/SearchBar.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
const SearchBar = () => {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<SearchIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
className="pl-8 h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchBar;
|
||||||
45
src/components/data-table/Header/index.tsx
Normal file
45
src/components/data-table/Header/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface TopHeaderProps {
|
||||||
|
title?: string;
|
||||||
|
itemCount?: number;
|
||||||
|
onAddNew?: () => void;
|
||||||
|
addButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopHeader = ({ title, itemCount, onAddNew, addButtonText = "Add New" }: TopHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||||
|
{itemCount !== undefined && (
|
||||||
|
<span className="flex items-center justify-center bg-secondary text-secondary-foreground min-w-[24px] h-[24px] px-2 text-xs font-bold rounded-full">
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onAddNew && (
|
||||||
|
<Button
|
||||||
|
onClick={onAddNew}
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
{addButtonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full max-w-sm ml-auto">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopHeader;
|
||||||
33
src/components/data-table/TableHeader.tsx
Normal file
33
src/components/data-table/TableHeader.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
import { flexRender } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
TableHead,
|
||||||
|
TableHeader as ShadTableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||||
|
return (
|
||||||
|
<ShadTableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</ShadTableHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TableHeader;
|
||||||
186
src/components/data-table/index.tsx
Normal file
186
src/components/data-table/index.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
getSortedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
|
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Edit2, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import TopHeader from "./Header";
|
||||||
|
import TableHeader from "./TableHeader";
|
||||||
|
import { TableFooter } from "./Footer";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
title?: string;
|
||||||
|
onAddNew?: () => void;
|
||||||
|
addButtonText?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onEdit?: (item: TData) => void;
|
||||||
|
onDelete?: (item: TData) => void;
|
||||||
|
pagination?: {
|
||||||
|
skip: number;
|
||||||
|
limit: number;
|
||||||
|
totalItems?: number;
|
||||||
|
onPageChange: (newSkip: number) => void;
|
||||||
|
onLimitChange: (newLimit: number) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({
|
||||||
|
columns: initialColumns,
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
onAddNew,
|
||||||
|
addButtonText,
|
||||||
|
isLoading = false,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
pagination,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
|
||||||
|
const columns = React.useMemo(() => {
|
||||||
|
const cols = [...initialColumns];
|
||||||
|
if (onEdit || onDelete) {
|
||||||
|
cols.push({
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">Actions</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{onEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cols;
|
||||||
|
}, [initialColumns, onEdit, onDelete]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
manualPagination: !!pagination,
|
||||||
|
pageCount: pagination?.totalItems ? Math.ceil(pagination.totalItems / pagination.limit) : -1,
|
||||||
|
state: {
|
||||||
|
rowSelection,
|
||||||
|
sorting,
|
||||||
|
pagination: pagination ? {
|
||||||
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||||
|
pageSize: pagination.limit,
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
onPaginationChange: (updater) => {
|
||||||
|
if (typeof updater === 'function' && pagination) {
|
||||||
|
const newState = updater({
|
||||||
|
pageIndex: Math.floor(pagination.skip / pagination.limit),
|
||||||
|
pageSize: pagination.limit,
|
||||||
|
});
|
||||||
|
pagination.onLimitChange(newState.pageSize);
|
||||||
|
pagination.onPageChange(newState.pageIndex * newState.pageSize);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 10,
|
||||||
|
pageIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<TopHeader
|
||||||
|
title={title}
|
||||||
|
itemCount={data.length}
|
||||||
|
onAddNew={onAddNew}
|
||||||
|
addButtonText={addButtonText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Table>
|
||||||
|
<TableHeader table={table} />
|
||||||
|
<TableBody className="bg-transparent">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, idx) => (
|
||||||
|
<TableRow key={idx}>
|
||||||
|
{columns.map((_, colIdx) => (
|
||||||
|
<TableCell key={colIdx} className="px-8 py-4">
|
||||||
|
<Skeleton className="h-4 w-full max-w-[120px]" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-32 text-center text-sm"
|
||||||
|
>
|
||||||
|
No results found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableFooter table={table} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !bounds.isValid()) return
|
if (!map || !bounds.isValid()) return
|
||||||
|
|
||||||
// Small delay to ensure the container dimensions are fully calculated (prevents _leaflet_pos error)
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
map.invalidateSize()
|
map.invalidateSize()
|
||||||
map.fitBounds(bounds, {
|
map.fitBounds(bounds, {
|
||||||
@@ -47,7 +46,6 @@ function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MapModal({ open, onClose, detections, detectionType }: MapModalProps) {
|
export default function MapModal({ open, onClose, detections, detectionType }: MapModalProps) {
|
||||||
// Filter detections with valid GPS coordinates
|
|
||||||
const validDetections = detections.filter(d => d.latitude && d.longitude)
|
const validDetections = detections.filter(d => d.latitude && d.longitude)
|
||||||
|
|
||||||
if (validDetections.length === 0) {
|
if (validDetections.length === 0) {
|
||||||
@@ -57,46 +55,35 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Detection Map</DialogTitle>
|
<DialogTitle>Detection Map</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm font-medium">
|
||||||
No GPS coordinates available for detections
|
No GPS data available for detections.
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract route coordinates (start to end)
|
|
||||||
const routeCoordinates: [number, number][] = validDetections.map(d => [d.latitude, d.longitude])
|
const routeCoordinates: [number, number][] = validDetections.map(d => [d.latitude, d.longitude])
|
||||||
|
const bounds = new LatLngBounds(new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]), new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]))
|
||||||
// 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])))
|
routeCoordinates.forEach(coord => bounds.extend(new LatLng(coord[0], coord[1])))
|
||||||
|
const center: [number, number] = [(bounds.getNorth() + bounds.getSouth()) / 2, (bounds.getEast() + bounds.getWest()) / 2]
|
||||||
// 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 isCombined = detectionType === "pot-sign-detection"
|
||||||
const isPothole = detectionType === "pothole-detection"
|
const isPothole = detectionType === "pothole-detection"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-6xl h-[80vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
|
<DialogContent className="max-w-6xl h-[85vh] 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">
|
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||||
<DialogTitle className="text-xl font-bold">
|
<DialogTitle className="text-xl font-bold">
|
||||||
{isCombined ? "Pothole & Signboard" : isPothole ? "Pothole" : "Signboard"} Detection Map
|
{isCombined ? "Combined" : isPothole ? "Pothole" : "Signboard"} Detection Map
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
{validDetections.length} detection{validDetections.length !== 1 ? "s" : ""} with GPS coordinates
|
{validDetections.length} points of interest identified with GPS data
|
||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 w-full bg-gray-50 dark:bg-gray-950 relative">
|
<div className="flex-1 w-full relative bg-muted/20">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
zoom={13}
|
zoom={13}
|
||||||
@@ -108,56 +95,47 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
|
|||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Route line */}
|
|
||||||
<Polyline
|
<Polyline
|
||||||
positions={routeCoordinates}
|
positions={routeCoordinates}
|
||||||
color="#3b82f6"
|
color="#3b82f6"
|
||||||
weight={3}
|
weight={4}
|
||||||
opacity={0.7}
|
opacity={0.6}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Detection markers */}
|
|
||||||
{validDetections.map((detection, idx) => {
|
{validDetections.map((detection, idx) => {
|
||||||
const type = (detection.type || "").toLowerCase()
|
const type = (detection.type || "").toLowerCase()
|
||||||
let markerColor = "#64748b" // Default Slate
|
const colors: Record<string, { fill: string, stroke: string }> = {
|
||||||
let strokeColor = "#475569"
|
'pothole': { fill: '#ef4444', stroke: '#b91c1c' },
|
||||||
|
'defected_sign_board': { fill: '#3b82f6', stroke: '#1d4ed8' },
|
||||||
if (type === "pothole") {
|
'road_crack': { fill: '#f59e0b', stroke: '#b45309' },
|
||||||
markerColor = "#ef4444"
|
'damaged_road_marking': { fill: '#6366f1', stroke: '#4338ca' },
|
||||||
strokeColor = "#b91c1c"
|
'good_sign_board': { fill: '#10b981', stroke: '#047857' }
|
||||||
} 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"
|
|
||||||
}
|
}
|
||||||
|
const color = colors[type] || { fill: '#64748b', stroke: '#475569' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CircleMarker
|
<CircleMarker
|
||||||
key={`${detection.id}-${idx}`}
|
key={`${detection.id}-${idx}`}
|
||||||
center={[detection.latitude, detection.longitude]}
|
center={[detection.latitude, detection.longitude]}
|
||||||
radius={8}
|
radius={7}
|
||||||
fillColor={markerColor}
|
fillColor={color.fill}
|
||||||
color={strokeColor}
|
color={color.stroke}
|
||||||
weight={2}
|
weight={2}
|
||||||
opacity={1}
|
opacity={1}
|
||||||
fillOpacity={0.8}
|
fillOpacity={0.9}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="text-xs space-y-1">
|
<div className="text-[11px] space-y-2 p-1">
|
||||||
<div className="font-semibold capitalize">
|
<div className="font-bold border-b pb-1 capitalize">
|
||||||
{(detection.type || "").replace(/_/g, " ")} #{detection.id}
|
{(detection.type || "").replace(/_/g, " ")} #{detection.id}
|
||||||
</div>
|
</div>
|
||||||
<div>Frame: {detection.frame_number}</div>
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||||
<div>Confidence: {(detection.confidence * 100).toFixed(1)}%</div>
|
<span className="text-muted-foreground">Frame:</span>
|
||||||
<div className="font-mono text-[10px]">
|
<span className="font-semibold text-right">{detection.frame_number}</span>
|
||||||
|
<span className="text-muted-foreground">Confidence:</span>
|
||||||
|
<span className="font-semibold text-right">{(detection.confidence * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono bg-muted p-1.5 rounded border text-center text-[9px]">
|
||||||
{detection.latitude.toFixed(6)}, {detection.longitude.toFixed(6)}
|
{detection.latitude.toFixed(6)}, {detection.longitude.toFixed(6)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,8 +143,6 @@ export default function MapModal({ open, onClose, detections, detectionType }: M
|
|||||||
</CircleMarker>
|
</CircleMarker>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Auto-fit bounds */}
|
|
||||||
<FitBounds bounds={bounds} />
|
<FitBounds bounds={bounds} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
40
src/components/mode-toogle.tsx
Normal file
40
src/components/mode-toogle.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export function ModeToggle() {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/nav-main.tsx
Normal file
73
src/components/nav-main.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
export function NavMain({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
icon?: LucideIcon
|
||||||
|
isActive?: boolean
|
||||||
|
items?: {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Collapsible
|
||||||
|
key={item.title}
|
||||||
|
asChild
|
||||||
|
defaultOpen={item.isActive}
|
||||||
|
className="group/collapsible"
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton tooltip={item.title}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.items?.map((subItem) => (
|
||||||
|
<SidebarMenuSubItem key={subItem.title}>
|
||||||
|
<SidebarMenuSubButton asChild>
|
||||||
|
<a href={subItem.url}>
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
src/components/nav-projects.tsx
Normal file
89
src/components/nav-projects.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
Forward,
|
||||||
|
MoreHorizontal,
|
||||||
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
export function NavProjects({
|
||||||
|
projects,
|
||||||
|
}: {
|
||||||
|
projects: {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{projects.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.name}>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<a href={item.url}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuAction showOnHover>
|
||||||
|
<MoreHorizontal />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</SidebarMenuAction>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-48 rounded-lg"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
align={isMobile ? "end" : "start"}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Folder className="text-muted-foreground" />
|
||||||
|
<span>View Project</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Forward className="text-muted-foreground" />
|
||||||
|
<span>Share Project</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Trash2 className="text-muted-foreground" />
|
||||||
|
<span>Delete Project</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||||
|
<MoreHorizontal className="text-sidebar-foreground/70" />
|
||||||
|
<span>More</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
src/components/nav-user.tsx
Normal file
114
src/components/nav-user.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
Bell,
|
||||||
|
ChevronsUpDown,
|
||||||
|
CreditCard,
|
||||||
|
LogOut,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
export function NavUser({
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
user: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">{user.name}</span>
|
||||||
|
<span className="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">{user.name}</span>
|
||||||
|
<span className="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Sparkles />
|
||||||
|
Upgrade to Pro
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<BadgeCheck />
|
||||||
|
Account
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<CreditCard />
|
||||||
|
Billing
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Bell />
|
||||||
|
Notifications
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<LogOut />
|
||||||
|
Log out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,17 +11,16 @@ interface PageHeaderProps {
|
|||||||
|
|
||||||
export function PageHeader({ title, description, icon: Icon, children }: PageHeaderProps) {
|
export function PageHeader({ title, description, icon: Icon, children }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-6">
|
||||||
<div className="p-3 rounded-lg bg-blue-gradient flex items-center justify-center relative overflow-hidden">
|
<div className="p-3.5 rounded-xl bg-primary text-primary-foreground flex items-center justify-center shadow-lg shadow-primary/5 ring-1 ring-white/10">
|
||||||
|
{Icon && <Icon className="h-7 w-7" />}
|
||||||
{Icon && <Icon className="h-8 w-8 text-white relative z-10" />}
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-3xl font-bold heading-blue-gradient">
|
<h1 className="text-3xl font-extrabold tracking-tight ">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm font-medium">
|
<p className="text-muted-foreground mt-1 text-sm font-semibold tracking-wide opacity-80">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Image from "next/image"
|
|||||||
|
|
||||||
export function PoweredBy() {
|
export function PoweredBy() {
|
||||||
return (
|
return (
|
||||||
<div className=" flex items-center justify-center gap-2 mt-3 transition-opacity duration-300">
|
<div className="flex items-center justify-center gap-2 mt-3 transition-opacity duration-300">
|
||||||
<span className="font-medium powered-blue-gradient">
|
<span className="font-medium text-muted-foreground">
|
||||||
Powered by
|
Powered by
|
||||||
</span>
|
</span>
|
||||||
<Image
|
<Image
|
||||||
@@ -11,7 +11,6 @@ export function PoweredBy() {
|
|||||||
alt="Sentient Geeks"
|
alt="Sentient Geeks"
|
||||||
width={130}
|
width={130}
|
||||||
height={20}
|
height={20}
|
||||||
className="fill-blue-500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2, Check } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
fetchPackagesByProject,
|
fetchPackagesByProject,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type Location,
|
type Location,
|
||||||
type SessionContext
|
type SessionContext
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type ProjectSelectionSectionProps = {
|
type ProjectSelectionSectionProps = {
|
||||||
onSelectionComplete: (session: SessionContext) => void
|
onSelectionComplete: (session: SessionContext) => void
|
||||||
@@ -148,18 +149,18 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white/40 backdrop-blur-sm overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader className="pb-6">
|
<CardHeader className="pb-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-2xl font-semibold">Select Project Location</CardTitle>
|
<CardTitle className="text-2xl font-bold">Select Project Location</CardTitle>
|
||||||
<CardDescription className="mt-2 text-base">
|
<CardDescription className="mt-2 text-base">
|
||||||
Select Project, Package & Location to begin intelligent road analysis with advanced computer vision.
|
Select Project, Package & Location to begin intelligent road analysis.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step Progress Indicator */}
|
{/* Step Progress Indicator */}
|
||||||
<div className="flex items-center justify-center gap-2 pt-2">
|
<div className="flex items-center justify-center pt-2">
|
||||||
{[1, 2, 3].map((step, index) => {
|
{[1, 2, 3].map((step, index) => {
|
||||||
const status = getStepStatus(step)
|
const status = getStepStatus(step)
|
||||||
const labels = ['Project', 'Package', 'Location']
|
const labels = ['Project', 'Package', 'Location']
|
||||||
@@ -167,21 +168,27 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio
|
|||||||
<div key={step} className="flex items-center">
|
<div key={step} className="flex items-center">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div
|
<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' :
|
className={cn(
|
||||||
status === 'active' ? 'bg-linear-to-b from-[#225999] to-[#56A5FF] text-primary-foreground ring-4 ring-primary/20' :
|
"w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all",
|
||||||
'bg-muted text-muted-foreground'
|
status === 'completed' ? "bg-primary text-primary-foreground" :
|
||||||
}`}
|
status === 'active' ? "bg-primary text-primary-foreground ring-4 ring-primary/10" :
|
||||||
|
"bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{step}
|
{status === 'completed' ? <Check className="h-5 w-5" /> : step}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs mt-1.5 font-medium ${status === 'pending' ? 'text-muted-foreground/60' : 'text-foreground'
|
<span className={cn(
|
||||||
}`}>
|
"text-xs mt-2 font-medium",
|
||||||
|
status === 'pending' ? "text-muted-foreground/60" : "text-foreground"
|
||||||
|
)}>
|
||||||
{labels[index]}
|
{labels[index]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{index < 2 && (
|
{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 className={cn(
|
||||||
}`} />
|
"w-16 h-0.5 mx-2 mb-6 rounded-full",
|
||||||
|
getStepStatus(step + 1) !== 'pending' ? "bg-primary" : "bg-border"
|
||||||
|
)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -190,126 +197,117 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="pt-6 space-y-6">
|
<CardContent className="space-y-8">
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-destructive/10 border border-destructive/20 text-destructive">
|
<div className="p-4 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm">
|
||||||
<div className="w-5 h-5 rounded-full bg-destructive/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
{error}
|
||||||
<span className="text-xs font-bold">!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed">{error}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{/* Project Dropdown */}
|
{/* Project Dropdown */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="project" className="text-sm font-semibold text-foreground">
|
<Label htmlFor="project" className="text-sm font-semibold">
|
||||||
Project
|
Project
|
||||||
</Label>
|
</Label>
|
||||||
<div className="input-glow rounded-lg">
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedProject?.id || ""}
|
value={selectedProject?.id || ""}
|
||||||
onValueChange={handleProjectChange}
|
onValueChange={handleProjectChange}
|
||||||
disabled={loadingProjects}
|
disabled={loadingProjects}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="project" className="h-12 bg-background/50 border-border/50 hover:border-primary/50">
|
<SelectTrigger id="project" className="h-11">
|
||||||
{loadingProjects ? (
|
{loadingProjects ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span className="text-muted-foreground">Loading...</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder="Select a project" />
|
<SelectValue placeholder="Select project" />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<SelectItem key={project.id} value={project.id}>
|
<SelectItem key={project.id} value={project.id}>
|
||||||
<span className="font-medium">{project.name}</span>
|
{project.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Package Dropdown */}
|
{/* Package Dropdown */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="package" className="text-sm font-semibold text-foreground">
|
<Label htmlFor="package" className="text-sm font-semibold">
|
||||||
Package
|
Package
|
||||||
</Label>
|
</Label>
|
||||||
<div className="input-glow rounded-lg">
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedPackage?.id || ""}
|
value={selectedPackage?.id || ""}
|
||||||
onValueChange={handlePackageChange}
|
onValueChange={handlePackageChange}
|
||||||
disabled={!selectedProject || loadingPackages}
|
disabled={!selectedProject || loadingPackages}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="package" className="h-12 bg-background/50 border-border/50 hover:border-primary/50">
|
<SelectTrigger id="package" className="h-11">
|
||||||
{loadingPackages ? (
|
{loadingPackages ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span className="text-muted-foreground">Loading...</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder={selectedProject ? "Select a package" : "Select project first"} />
|
<SelectValue placeholder={selectedProject ? "Select package" : "Select project first"} />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{packages.map((pkg) => (
|
{packages.map((pkg) => (
|
||||||
<SelectItem key={pkg.id} value={pkg.id}>
|
<SelectItem key={pkg.id} value={pkg.id}>
|
||||||
<span className="font-medium">{pkg.name}</span>
|
{pkg.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Location Dropdown */}
|
{/* Location Dropdown */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="location" className="text-sm font-semibold text-foreground">
|
<Label htmlFor="location" className="text-sm font-semibold">
|
||||||
Location
|
Location
|
||||||
</Label>
|
</Label>
|
||||||
<div className="input-glow rounded-lg">
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedLocation?.id || ""}
|
value={selectedLocation?.id || ""}
|
||||||
onValueChange={handleLocationChange}
|
onValueChange={handleLocationChange}
|
||||||
disabled={!selectedPackage || loadingLocations}
|
disabled={!selectedPackage || loadingLocations}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="location" className="h-12 bg-background/50 border-border/50 hover:border-primary/50">
|
<SelectTrigger id="location" className="h-11">
|
||||||
{loadingLocations ? (
|
{loadingLocations ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span className="text-muted-foreground">Loading...</span>
|
<span>Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder={selectedPackage ? "Select a location" : "Select package first"} />
|
<SelectValue placeholder={selectedPackage ? "Select location" : "Select package first"} />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{locations.map((location) => (
|
{locations.map((location) => (
|
||||||
<SelectItem key={location.id} value={location.id}>
|
<SelectItem key={location.id} value={location.id}>
|
||||||
<span className="font-medium">{location.segment_name}</span>
|
{location.segment_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected Summary */}
|
{/* Selected Summary */}
|
||||||
{isComplete && (
|
{isComplete && (
|
||||||
<div className="px-4 py-3 rounded-lg bg-primary/5 border border-primary/15">
|
<div className="px-4 py-3 rounded-md bg-secondary/50 border text-sm">
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap text-muted-foreground">
|
||||||
<span className="font-medium">Path:</span>
|
<span className="font-semibold text-foreground">Selection:</span>
|
||||||
<span className="text-foreground">{selectedProject?.name}</span>
|
<span className="text-foreground">{selectedProject?.name}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">{selectedPackage?.name}</span>
|
<span className="text-foreground">{selectedPackage?.name}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground">{selectedLocation?.segment_name}</span>
|
<span className="text-foreground">{selectedLocation?.segment_name}</span>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -317,15 +315,10 @@ export function ProjectSelectionSection({ onSelectionComplete }: ProjectSelectio
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleProceed}
|
onClick={handleProceed}
|
||||||
disabled={!isComplete}
|
disabled={!isComplete}
|
||||||
className={`w-full h-14 text-base font-semibold ${isComplete ? 'btn-blue-gradient text-white' : ''
|
className="w-full h-12 text-base font-bold uppercase tracking-wide"
|
||||||
}`}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{isComplete ? (
|
{isComplete ? "Proceed to Upload" : "Select all fields to proceed"}
|
||||||
"Proceed to Upload"
|
|
||||||
) : (
|
|
||||||
"Complete all selections to proceed"
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
"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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
89
src/components/team-switcher.tsx
Normal file
89
src/components/team-switcher.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronsUpDown, Plus } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
|
export function TeamSwitcher({
|
||||||
|
teams,
|
||||||
|
}: {
|
||||||
|
teams: {
|
||||||
|
name: string
|
||||||
|
logo: React.ElementType
|
||||||
|
plan: string
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<activeTeam.logo className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{activeTeam.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs">{activeTeam.plan}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
|
align="start"
|
||||||
|
side={isMobile ? "bottom" : "right"}
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
Teams
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{teams.map((team, index) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={team.name}
|
||||||
|
onClick={() => setActiveTeam(team)}
|
||||||
|
className="gap-2 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||||
|
<team.logo className="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
{team.name}
|
||||||
|
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="gap-2 p-2">
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-muted-foreground">Add team</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
|
}
|
||||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@@ -1,39 +1,39 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all 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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
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',
|
"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:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
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',
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: 'h-10 rounded-full px-6 has-[>svg]:px-4',
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: 'size-9',
|
icon: "size-9",
|
||||||
'icon-sm': 'size-8',
|
"icon-sm": "size-8",
|
||||||
'icon-lg': 'size-10',
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
@@ -42,11 +42,11 @@ function Button({
|
|||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|||||||
@@ -1,81 +1,81 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-md border-none py-6 shadow-none hover:shadow-none',
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
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',
|
"@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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn('leading-none font-semibold', className)}
|
className={cn("leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-content"
|
data-slot="card-content"
|
||||||
className={cn('px-6', className)}
|
className={cn("px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
import { XIcon } from "lucide-react"
|
||||||
import { XIcon } from 'lucide-react'
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
@@ -38,8 +39,8 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 bg-black/50',
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -60,8 +61,8 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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',
|
"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 bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -69,7 +70,7 @@ function DialogContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
data-slot="dialog-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"
|
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
@@ -80,26 +81,40 @@ function DialogContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +125,7 @@ function DialogTitle({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn('text-lg leading-none font-semibold', className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -123,7 +138,7 @@ function DialogDescription({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
|
|||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
@@ -42,13 +42,13 @@ function DropdownMenuContent({
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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',
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
@@ -56,17 +56,17 @@ function DropdownMenuGroup({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
variant = 'default',
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: 'default' | 'destructive'
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -75,11 +75,11 @@ function DropdownMenuItem({
|
|||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
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",
|
"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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
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",
|
"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,
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
|
|||||||
data-slot="dropdown-menu-radio-group"
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
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",
|
"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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
@@ -170,32 +170,32 @@ function DropdownMenuSeparator({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'span'>) {
|
}: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
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",
|
"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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
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',
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -254,4 +254,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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',
|
"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]',
|
"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',
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
orientation = 'horizontal',
|
orientation = "horizontal",
|
||||||
decorative = true,
|
decorative = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
@@ -17,8 +17,8 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
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',
|
"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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
import { XIcon } from 'lucide-react'
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
@@ -36,8 +36,8 @@ function SheetOverlay({
|
|||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 bg-black/50',
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -47,10 +47,10 @@ function SheetOverlay({
|
|||||||
function SheetContent({
|
function SheetContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
side = 'right',
|
side = "right",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@@ -58,16 +58,16 @@ function SheetContent({
|
|||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background fixed z-50 flex flex-col gap-4 shadow-lg',
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
side === 'right' &&
|
side === "right" &&
|
||||||
'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
side === 'left' &&
|
side === "left" &&
|
||||||
'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
side === 'top' &&
|
side === "top" &&
|
||||||
'inset-x-0 top-0 h-auto border-b',
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
side === 'bottom' &&
|
side === "bottom" &&
|
||||||
'inset-x-0 bottom-0 h-auto border-t',
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -81,21 +81,21 @@ function SheetContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-header"
|
data-slot="sheet-header"
|
||||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-footer"
|
data-slot="sheet-footer"
|
||||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -108,7 +108,7 @@ function SheetTitle({
|
|||||||
return (
|
return (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
data-slot="sheet-title"
|
data-slot="sheet-title"
|
||||||
className={cn('text-foreground font-semibold', className)}
|
className={cn("text-foreground font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -121,7 +121,7 @@ function SheetDescription({
|
|||||||
return (
|
return (
|
||||||
<SheetPrimitive.Description
|
<SheetPrimitive.Description
|
||||||
data-slot="sheet-description"
|
data-slot="sheet-description"
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import { Slot } from '@radix-ui/react-slot'
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, VariantProps } from 'class-variance-authority'
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { PanelLeftIcon } from 'lucide-react'
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from '@/hooks/use-mobile'
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from "@/components/ui/separator"
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from "@/components/ui/sheet"
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
const SIDEBAR_WIDTH = '16rem'
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
const SIDEBAR_WIDTH_ICON = '3rem'
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
type SidebarContextProps = {
|
type SidebarContextProps = {
|
||||||
state: 'expanded' | 'collapsed'
|
state: "expanded" | "collapsed"
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
openMobile: boolean
|
openMobile: boolean
|
||||||
@@ -47,7 +47,7 @@ const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
|||||||
function useSidebar() {
|
function useSidebar() {
|
||||||
const context = React.useContext(SidebarContext)
|
const context = React.useContext(SidebarContext)
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useSidebar must be used within a SidebarProvider.')
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return context
|
return context
|
||||||
@@ -61,7 +61,7 @@ function SidebarProvider({
|
|||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
@@ -75,7 +75,7 @@ function SidebarProvider({
|
|||||||
const open = openProp ?? _open
|
const open = openProp ?? _open
|
||||||
const setOpen = React.useCallback(
|
const setOpen = React.useCallback(
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
const openState = typeof value === 'function' ? value(open) : value
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
if (setOpenProp) {
|
if (setOpenProp) {
|
||||||
setOpenProp(openState)
|
setOpenProp(openState)
|
||||||
} else {
|
} else {
|
||||||
@@ -85,7 +85,7 @@ function SidebarProvider({
|
|||||||
// This sets the cookie to keep the sidebar state.
|
// This sets the cookie to keep the sidebar state.
|
||||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
},
|
},
|
||||||
[setOpenProp, open],
|
[setOpenProp, open]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
// Helper to toggle the sidebar.
|
||||||
@@ -105,13 +105,13 @@ function SidebarProvider({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [toggleSidebar])
|
}, [toggleSidebar])
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
// 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.
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
const state = open ? 'expanded' : 'collapsed'
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
const contextValue = React.useMemo<SidebarContextProps>(
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -123,7 +123,7 @@ function SidebarProvider({
|
|||||||
setOpenMobile,
|
setOpenMobile,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
}),
|
}),
|
||||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,14 +133,14 @@ function SidebarProvider({
|
|||||||
data-slot="sidebar-wrapper"
|
data-slot="sidebar-wrapper"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--sidebar-width': SIDEBAR_WIDTH,
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
...style,
|
...style,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -152,26 +152,26 @@ function SidebarProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({
|
function Sidebar({
|
||||||
side = 'left',
|
side = "left",
|
||||||
variant = 'sidebar',
|
variant = "sidebar",
|
||||||
collapsible = 'offcanvas',
|
collapsible = "offcanvas",
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
side?: 'left' | 'right'
|
side?: "left" | "right"
|
||||||
variant?: 'sidebar' | 'floating' | 'inset'
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
collapsible?: 'offcanvas' | 'icon' | 'none'
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
if (collapsible === 'none') {
|
if (collapsible === "none") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -190,7 +190,7 @@ function Sidebar({
|
|||||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
side={side}
|
side={side}
|
||||||
@@ -209,7 +209,7 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
className="group peer text-sidebar-foreground hidden md:block"
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
data-state={state}
|
data-state={state}
|
||||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-side={side}
|
data-side={side}
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
@@ -218,26 +218,26 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
data-slot="sidebar-gap"
|
data-slot="sidebar-gap"
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative w-(--sidebar-width) bg-transparent',
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
'group-data-[collapsible=offcanvas]:w-0',
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
'group-data-[side=right]:rotate-180',
|
"group-data-[side=right]:rotate-180",
|
||||||
variant === 'floating' || variant === 'inset'
|
variant === "floating" || variant === "inset"
|
||||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-container"
|
data-slot="sidebar-container"
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex',
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
side === 'left'
|
side === "left"
|
||||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[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.
|
// Adjust the padding for floating and inset variants.
|
||||||
variant === 'floating' || variant === 'inset'
|
variant === "floating" || variant === "inset"
|
||||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
? "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',
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -266,7 +266,7 @@ function SidebarTrigger({
|
|||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('size-7', className)}
|
className={cn("size-7", className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
toggleSidebar()
|
toggleSidebar()
|
||||||
@@ -279,7 +279,7 @@ function SidebarTrigger({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -291,27 +291,27 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
|||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle Sidebar"
|
title="Toggle Sidebar"
|
||||||
className={cn(
|
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',
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear 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',
|
"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',
|
"[[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',
|
"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=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background relative flex w-full flex-1 flex-col',
|
"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',
|
"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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -326,29 +326,29 @@ function SidebarInput({
|
|||||||
<Input
|
<Input
|
||||||
data-slot="sidebar-input"
|
data-slot="sidebar-input"
|
||||||
data-sidebar="input"
|
data-sidebar="input"
|
||||||
className={cn('bg-background h-8 w-full shadow-none', className)}
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-header"
|
data-slot="sidebar-header"
|
||||||
data-sidebar="header"
|
data-sidebar="header"
|
||||||
className={cn('flex flex-col gap-2 p-2', className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-footer"
|
data-slot="sidebar-footer"
|
||||||
data-sidebar="footer"
|
data-sidebar="footer"
|
||||||
className={cn('flex flex-col gap-2 p-2', className)}
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -362,32 +362,32 @@ function SidebarSeparator({
|
|||||||
<Separator
|
<Separator
|
||||||
data-slot="sidebar-separator"
|
data-slot="sidebar-separator"
|
||||||
data-sidebar="separator"
|
data-sidebar="separator"
|
||||||
className={cn('bg-sidebar-border mx-2 w-auto', className)}
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-content"
|
data-slot="sidebar-content"
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group"
|
data-slot="sidebar-group"
|
||||||
data-sidebar="group"
|
data-sidebar="group"
|
||||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -397,17 +397,17 @@ function SidebarGroupLabel({
|
|||||||
className,
|
className,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : 'div'
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-group-label"
|
data-slot="sidebar-group-label"
|
||||||
data-sidebar="group-label"
|
data-sidebar="group-label"
|
||||||
className={cn(
|
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',
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -418,19 +418,19 @@ function SidebarGroupAction({
|
|||||||
className,
|
className,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-group-action"
|
data-slot="sidebar-group-action"
|
||||||
data-sidebar="group-action"
|
data-sidebar="group-action"
|
||||||
className={cn(
|
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',
|
"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 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
// Increases the hit area of the button on mobile.
|
// Increases the hit area of the button on mobile.
|
||||||
'after:absolute after:-inset-2 md:after:hidden',
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
'group-data-[collapsible=icon]:hidden',
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -440,75 +440,75 @@ function SidebarGroupAction({
|
|||||||
function SidebarGroupContent({
|
function SidebarGroupContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group-content"
|
data-slot="sidebar-group-content"
|
||||||
data-sidebar="group-content"
|
data-sidebar="group-content"
|
||||||
className={cn('w-full text-sm', className)}
|
className={cn("w-full text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu"
|
data-slot="sidebar-menu"
|
||||||
data-sidebar="menu"
|
data-sidebar="menu"
|
||||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="sidebar-menu-item"
|
data-slot="sidebar-menu-item"
|
||||||
data-sidebar="menu-item"
|
data-sidebar="menu-item"
|
||||||
className={cn('group/menu-item relative', className)}
|
className={cn("group/menu-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
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',
|
"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 transition-[width,height,padding] 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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
outline:
|
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))]',
|
"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: {
|
size: {
|
||||||
default: 'h-8 text-sm',
|
default: "h-8 text-sm",
|
||||||
sm: 'h-7 text-xs',
|
sm: "h-7 text-xs",
|
||||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function SidebarMenuButton({
|
function SidebarMenuButton({
|
||||||
asChild = false,
|
asChild = false,
|
||||||
isActive = false,
|
isActive = false,
|
||||||
variant = 'default',
|
variant = "default",
|
||||||
size = 'default',
|
size = "default",
|
||||||
tooltip,
|
tooltip,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : "button"
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
@@ -526,7 +526,7 @@ function SidebarMenuButton({
|
|||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof tooltip === 'string') {
|
if (typeof tooltip === "string") {
|
||||||
tooltip = {
|
tooltip = {
|
||||||
children: tooltip,
|
children: tooltip,
|
||||||
}
|
}
|
||||||
@@ -538,7 +538,7 @@ function SidebarMenuButton({
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="right"
|
side="right"
|
||||||
align="center"
|
align="center"
|
||||||
hidden={state !== 'collapsed' || isMobile}
|
hidden={state !== "collapsed" || isMobile}
|
||||||
{...tooltip}
|
{...tooltip}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -550,27 +550,27 @@ function SidebarMenuAction({
|
|||||||
asChild = false,
|
asChild = false,
|
||||||
showOnHover = false,
|
showOnHover = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> & {
|
}: React.ComponentProps<"button"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
showOnHover?: boolean
|
showOnHover?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="sidebar-menu-action"
|
data-slot="sidebar-menu-action"
|
||||||
data-sidebar="menu-action"
|
data-sidebar="menu-action"
|
||||||
className={cn(
|
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',
|
"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 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
// Increases the hit area of the button on mobile.
|
// Increases the hit area of the button on mobile.
|
||||||
'after:absolute after:-inset-2 md:after:hidden',
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
'peer-data-[size=sm]/menu-button:top-1',
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
'peer-data-[size=default]/menu-button:top-1.5',
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
'group-data-[collapsible=icon]:hidden',
|
"group-data-[collapsible=icon]:hidden",
|
||||||
showOnHover &&
|
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',
|
"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,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -580,19 +580,19 @@ function SidebarMenuAction({
|
|||||||
function SidebarMenuBadge({
|
function SidebarMenuBadge({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-menu-badge"
|
data-slot="sidebar-menu-badge"
|
||||||
data-sidebar="menu-badge"
|
data-sidebar="menu-badge"
|
||||||
className={cn(
|
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',
|
"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-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=sm]/menu-button:top-1",
|
||||||
'peer-data-[size=default]/menu-button:top-1.5',
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
'group-data-[collapsible=icon]:hidden',
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -603,17 +603,19 @@ function SidebarMenuSkeleton({
|
|||||||
className,
|
className,
|
||||||
showIcon = false,
|
showIcon = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showIcon?: boolean
|
showIcon?: boolean
|
||||||
}) {
|
}) {
|
||||||
// Fixed width to prevent hydration mismatch.
|
// Random width between 50 to 90%.
|
||||||
const width = "70%"
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-menu-skeleton"
|
data-slot="sidebar-menu-skeleton"
|
||||||
data-sidebar="menu-skeleton"
|
data-sidebar="menu-skeleton"
|
||||||
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showIcon && (
|
{showIcon && (
|
||||||
@@ -627,7 +629,7 @@ function SidebarMenuSkeleton({
|
|||||||
data-sidebar="menu-skeleton-text"
|
data-sidebar="menu-skeleton-text"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--skeleton-width': width,
|
"--skeleton-width": width,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -635,15 +637,15 @@ function SidebarMenuSkeleton({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu-sub"
|
data-slot="sidebar-menu-sub"
|
||||||
data-sidebar="menu-sub"
|
data-sidebar="menu-sub"
|
||||||
className={cn(
|
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',
|
"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',
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -653,12 +655,12 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
|||||||
function SidebarMenuSubItem({
|
function SidebarMenuSubItem({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'li'>) {
|
}: React.ComponentProps<"li">) {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
data-slot="sidebar-menu-sub-item"
|
data-slot="sidebar-menu-sub-item"
|
||||||
data-sidebar="menu-sub-item"
|
data-sidebar="menu-sub-item"
|
||||||
className={cn('group/menu-sub-item relative', className)}
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -666,16 +668,16 @@ function SidebarMenuSubItem({
|
|||||||
|
|
||||||
function SidebarMenuSubButton({
|
function SidebarMenuSubButton({
|
||||||
asChild = false,
|
asChild = false,
|
||||||
size = 'md',
|
size = "md",
|
||||||
isActive = false,
|
isActive = false,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'a'> & {
|
}: React.ComponentProps<"a"> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
size?: 'sm' | 'md'
|
size?: "sm" | "md"
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : 'a'
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -684,12 +686,12 @@ function SidebarMenuSubButton({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
className={cn(
|
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',
|
"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',
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
size === 'sm' && 'text-xs',
|
size === "sm" && "text-xs",
|
||||||
size === 'md' && 'text-sm',
|
size === "md" && "text-sm",
|
||||||
'group-data-[collapsible=icon]:hidden',
|
"group-data-[collapsible=icon]:hidden",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="skeleton"
|
data-slot="skeleton"
|
||||||
className={cn('bg-muted animate-pulse rounded-md', className)}
|
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="table-container"
|
data-slot="table-container"
|
||||||
@@ -12,79 +12,79 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
|||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
data-slot="table"
|
||||||
className={cn('w-full caption-bottom text-sm', className)}
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
return (
|
return (
|
||||||
<thead
|
<thead
|
||||||
data-slot="table-header"
|
data-slot="table-header"
|
||||||
className={cn('[&_tr]:border-b', className)}
|
className={cn("[&_tr]:border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
return (
|
return (
|
||||||
<tbody
|
<tbody
|
||||||
data-slot="table-body"
|
data-slot="table-body"
|
||||||
className={cn('[&_tr:last-child]:border-0', className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
return (
|
return (
|
||||||
<tfoot
|
<tfoot
|
||||||
data-slot="table-footer"
|
data-slot="table-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b',
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
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]',
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -94,11 +94,11 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
|||||||
function TableCaption({
|
function TableCaption({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'caption'>) {
|
}: React.ComponentProps<"caption">) {
|
||||||
return (
|
return (
|
||||||
<caption
|
<caption
|
||||||
data-slot="table-caption"
|
data-slot="table-caption"
|
||||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client'
|
"use client"
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from "react"
|
||||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
@@ -46,8 +46,8 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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',
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -213,7 +213,7 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white/20 backdrop-blur-md">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Upload Video</CardTitle>
|
<CardTitle>Upload Video</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -222,8 +222,6 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="video-file">Video File</Label>
|
<Label htmlFor="video-file">Video File</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -247,7 +245,6 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* JSON File Input */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="json-file">GPS JSON File</Label>
|
<Label htmlFor="json-file">GPS JSON File</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -270,12 +267,9 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detection Type, Speed, and Method - 3 column row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* Detection Type Selector */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="detection-type">Detection Type</Label>
|
<Label htmlFor="detection-type">Detection Type</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -289,16 +283,13 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DETECTION_TYPES.map((type) => (
|
{DETECTION_TYPES.map((type) => (
|
||||||
<SelectItem key={type.value} value={type.value}>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{type.label}</span>
|
<span>{type.label}</span>
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Speed Input */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="speed">Vehicle Speed (km/h)</Label>
|
<Label htmlFor="speed">Vehicle Speed (km/h)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -312,7 +303,6 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select Method */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="select-method">Select Method</Label>
|
<Label htmlFor="select-method">Select Method</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -326,9 +316,7 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DETECTION_METHODS.map((method) => (
|
{DETECTION_METHODS.map((method) => (
|
||||||
<SelectItem key={method.value} value={method.value}>
|
<SelectItem key={method.value} value={method.value}>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{method.label}</span>
|
<span>{method.label}</span>
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -336,7 +324,6 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 text-destructive">
|
<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" />
|
<AlertCircle className="h-5 w-5 mt-0.5 shrink-0" />
|
||||||
@@ -344,12 +331,10 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upload Button */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!file || uploading}
|
disabled={!file || uploading}
|
||||||
className="w-full btn-blue-gradient rounded-full"
|
className="w-full"
|
||||||
|
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
@@ -365,7 +350,6 @@ export function UploadSection({ onDetectionComplete, onDetectionTypeChange }: Up
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Progress Section */}
|
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Progress value={progress} className="h-3" />
|
<Progress value={progress} className="h-3" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user