changed full frontend,added new features, and components.

This commit is contained in:
sumona-banerjeee
2026-02-11 11:17:22 +05:30
parent ae16cec5ff
commit 3c3aef7d82
24 changed files with 2144 additions and 379 deletions

View File

@@ -0,0 +1,543 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Loader2, CheckCircle2, MapPin, Navigation, Milestone } from "lucide-react"
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { DataTable } from "@/components/data-table"
import {
fetchProjects,
fetchPackagesByProject,
fetchAllLocations,
fetchAllPackages,
createLocation,
type Project,
type Package as PackageType,
type Location,
type LocationCreate
} from "@/lib/api"
export default function CreateLocationPage() {
const [locations, setLocations] = useState<Location[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [packages, setPackages] = useState<PackageType[]>([])
const [allPackages, setAllPackages] = useState<PackageType[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadingProjects, setLoadingProjects] = useState(false)
const [loadingPackages, setLoadingPackages] = useState(false)
// Form fields
const [selectedProjectId, setSelectedProjectId] = useState("")
const [selectedPackageId, setSelectedPackageId] = useState("")
const [segmentName, setSegmentName] = useState("")
const [chainageStartKm, setChainageStartKm] = useState("")
const [chainageEndKm, setChainageEndKm] = useState("")
const [startLat, setStartLat] = useState("")
const [startLng, setStartLng] = useState("")
const [endLat, setEndLat] = useState("")
const [endLng, setEndLng] = useState("")
// Load locations and projects
const loadLocations = async () => {
try {
setIsLoading(true)
setError(null)
const data = await fetchAllLocations()
setLocations(data)
} catch (err) {
setError("Failed to load locations. Please check if the backend is running.")
} finally {
setIsLoading(false)
}
}
const loadProjects = async () => {
try {
setLoadingProjects(true)
const data = await fetchProjects()
setProjects(data)
} catch (err) {
setError("Failed to load projects.")
} finally {
setLoadingProjects(false)
}
}
const loadAllPackages = async () => {
try {
const data = await fetchAllPackages()
setAllPackages(data)
} catch (err) {
console.error("Failed to load all packages")
}
}
useEffect(() => {
loadLocations()
loadProjects()
loadAllPackages()
}, [])
// Load packages when project changes
useEffect(() => {
if (!selectedProjectId) {
setPackages([])
setSelectedPackageId("")
return
}
const loadPackages = async () => {
try {
setLoadingPackages(true)
setSelectedPackageId("")
const data = await fetchPackagesByProject(selectedProjectId)
setPackages(data)
} catch (err) {
setError("Failed to load packages for the selected project.")
} finally {
setLoadingPackages(false)
}
}
loadPackages()
}, [selectedProjectId])
const resetForm = () => {
setSelectedProjectId("")
setSelectedPackageId("")
setSegmentName("")
setChainageStartKm("")
setChainageEndKm("")
setStartLat("")
setStartLng("")
setEndLat("")
setEndLng("")
setError(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedPackageId) {
setError("Please select a project and package first")
return
}
if (!segmentName.trim()) {
setError("Segment name is required")
return
}
if (!startLat || !startLng || !endLat || !endLng) {
setError("All GPS coordinates are required for locations")
return
}
setIsSubmitting(true)
setError(null)
try {
const data: LocationCreate = {
package_id: selectedPackageId,
segment_name: segmentName.trim(),
chainage_start_km: chainageStartKm ? parseFloat(chainageStartKm) : null,
chainage_end_km: chainageEndKm ? parseFloat(chainageEndKm) : null,
start_lat: parseFloat(startLat),
start_lng: parseFloat(startLng),
end_lat: parseFloat(endLat),
end_lng: parseFloat(endLng),
}
await createLocation(data)
setSuccess(true)
// Refresh locations list
await loadLocations()
setTimeout(() => {
resetForm()
setSuccess(false)
setIsModalOpen(false)
}, 2000)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create location")
} finally {
setIsSubmitting(false)
}
}
const getPackageName = (packageId: string) => {
return allPackages.find(p => p.id === packageId)?.name || packageId
}
const selectedProject = projects.find(p => p.id === selectedProjectId)
const selectedPackage = packages.find(p => p.id === selectedPackageId)
const isFormComplete = selectedPackageId && segmentName.trim() && startLat && startLng && endLat && endLng
const columns = [
{
key: "segment_name",
header: "Segment Name",
render: (location: Location) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{location.segment_name}</div>
)
},
{
key: "package_id",
header: "Package",
render: (location: Location) => getPackageName(location.package_id)
},
{
key: "chainage",
header: "Chainage (km)",
render: (location: Location) => {
if (location.chainage_start_km && location.chainage_end_km) {
return `${location.chainage_start_km} - ${location.chainage_end_km}`
}
return "—"
}
},
{
key: "created_at",
header: "Created",
render: (location: Location) => new Date(location.created_at).toLocaleDateString()
},
]
return (
<div className="min-h-screen bg-mesh-gradient text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-16 min-h-screen relative overflow-hidden">
<div className="mx-auto px-4 py-8 max-w-6xl relative z-10">
{/* Header */}
<div className="mb-8 animate-in fade-in slide-in-from-left duration-700">
<div className="flex items-center gap-6">
<div className="flex-shrink-0 w-16 h-16 rounded-2xl bg-white shadow-xl border-2 border-[#1e40af] transition-transform duration-300 hover:scale-110 flex items-center justify-center">
<MapPin className="h-8 w-8 text-[#2563eb]" />
</div>
<div className="flex flex-col">
<h1 className="text-3xl md:text-4xl font-extrabold text-[#2563eb] tracking-tight">
Locations
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm font-medium italic">
Manage road segment locations
</p>
</div>
</div>
</div>
{/* Error Message */}
{error && !isModalOpen && (
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top duration-300">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Data Table */}
<div className="animate-in fade-in slide-in-from-bottom duration-700 delay-150">
<DataTable
title="All Locations"
data={locations}
columns={columns}
onAddNew={() => setIsModalOpen(true)}
addButtonText="Add New Location"
isLoading={isLoading}
/>
</div>
{/* Footer */}
<div className="mt-8 text-center animate-in fade-in duration-700 delay-300">
<p className="text-xs text-gray-400 dark:text-gray-500">
Sentient Geeks Pvt. Ltd.
</p>
</div>
</div>
</main>
{/* Modal Dialog */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-amber-500" />
Create New Location
</DialogTitle>
<DialogDescription>
Select project & package, then fill in the location details
</DialogDescription>
</DialogHeader>
{/* Success Message in Modal */}
{success && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 border border-amber-200 dark:border-amber-800 animate-in fade-in slide-in-from-top duration-300">
<CheckCircle2 className="h-5 w-5 text-amber-500 flex-shrink-0" />
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Location created successfully!</p>
</div>
)}
{/* Error Message in Modal */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top duration-300">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Step 1: Select Project */}
<div className="p-4 rounded-xl bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/20 dark:to-orange-950/20 border border-amber-100 dark:border-amber-900/50">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center">
<span className="text-white text-xs font-bold">1</span>
</div>
<p className="text-sm font-bold text-amber-700 dark:text-amber-400">Select Project</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-white dark:bg-gray-800 border-amber-200 dark:border-amber-800 focus:border-amber-400">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
<span className="text-gray-400">Loading projects...</span>
</div>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
<span className="font-medium">{project.name}</span>
{project.state && <span className="text-gray-400 ml-2">({project.state})</span>}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedProject && (
<div className="mt-2 px-3 py-1.5 rounded-lg bg-amber-100/50 dark:bg-amber-900/20 text-xs text-amber-600 dark:text-amber-400">
Selected: <span className="font-semibold">{selectedProject.name}</span>
</div>
)}
</div>
{/* Step 2: Select Package */}
<div className={`p-4 rounded-xl bg-gradient-to-r from-orange-50/50 to-rose-50/50 dark:from-orange-950/20 dark:to-rose-950/20 border border-orange-100 dark:border-orange-900/50 transition-opacity duration-300 ${selectedProjectId ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${selectedProjectId ? 'bg-gradient-to-br from-orange-500 to-rose-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
<span className="text-white text-xs font-bold">2</span>
</div>
<p className="text-sm font-bold text-orange-700 dark:text-orange-400">Select Package</p>
</div>
<Select value={selectedPackageId} onValueChange={setSelectedPackageId} disabled={!selectedProjectId}>
<SelectTrigger className="h-11 bg-white dark:bg-gray-800 border-orange-200 dark:border-orange-800 focus:border-orange-400">
{loadingPackages ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-orange-500" />
<span className="text-gray-400">Loading packages...</span>
</div>
) : (
<SelectValue placeholder={selectedProjectId ? "Choose a package" : "Select project first"} />
)}
</SelectTrigger>
<SelectContent>
{packages.map(pkg => (
<SelectItem key={pkg.id} value={pkg.id}>
<span className="font-medium">{pkg.name}</span>
{pkg.region && <span className="text-gray-400 ml-2">({pkg.region})</span>}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPackage && (
<div className="mt-2 px-3 py-1.5 rounded-lg bg-orange-100/50 dark:bg-orange-900/20 text-xs text-orange-600 dark:text-orange-400">
Selected: <span className="font-semibold">{selectedPackage.name}</span>
{selectedPackage.region && `${selectedPackage.region}`}
</div>
)}
</div>
{/* Step 3: Location Details */}
<div className={`space-y-5 transition-opacity duration-300 ${selectedPackageId ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${selectedPackageId ? 'bg-gradient-to-br from-rose-500 to-red-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
<span className="text-white text-xs font-bold">3</span>
</div>
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">Location Information</p>
</div>
{/* Segment Name */}
<div className="space-y-2">
<Label htmlFor="segment" className="text-sm font-semibold flex items-center gap-1">
Segment Name <span className="text-red-500">*</span>
</Label>
<Input
id="segment"
value={segmentName}
onChange={(e) => setSegmentName(e.target.value)}
placeholder="e.g., KM 120 to KM 135"
className="h-11"
required
/>
</div>
{/* Chainage */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ch-start" className="text-sm font-semibold flex items-center gap-1">
<Milestone className="h-3.5 w-3.5 text-gray-400" />
Chainage Start (km) <span className="text-gray-400 font-normal text-xs">(Opt)</span>
</Label>
<Input
id="ch-start"
type="number"
step="any"
min="0"
value={chainageStartKm}
onChange={(e) => setChainageStartKm(e.target.value)}
placeholder="0"
className="h-10 text-sm"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ch-end" className="text-sm font-semibold flex items-center gap-1">
<Milestone className="h-3.5 w-3.5 text-gray-400" />
Chainage End (km) <span className="text-gray-400 font-normal text-xs">(Opt)</span>
</Label>
<Input
id="ch-end"
type="number"
step="any"
min="0"
value={chainageEndKm}
onChange={(e) => setChainageEndKm(e.target.value)}
placeholder="0"
className="h-10 text-sm"
/>
</div>
</div>
{/* GPS Coordinates */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 shadow-md shadow-amber-500/20">
<MapPin className="h-4 w-4 text-white" />
</div>
<h3 className="text-sm font-bold text-gray-700 dark:text-gray-300">GPS Coordinates</h3>
<span className="text-xs text-red-500 font-medium">Required</span>
</div>
{/* Start Point */}
<div className="p-4 rounded-xl bg-gradient-to-r from-amber-50/50 to-yellow-50/50 dark:from-amber-950/20 dark:to-yellow-950/20 border border-amber-100 dark:border-amber-900/50">
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400 mb-3 uppercase tracking-wide">Start Point</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="s-lat" className="text-xs text-gray-500">Latitude *</Label>
<Input
id="s-lat"
type="number"
step="any"
value={startLat}
onChange={(e) => setStartLat(e.target.value)}
placeholder="-90 to 90"
className="h-10 text-sm"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="s-lng" className="text-xs text-gray-500">Longitude *</Label>
<Input
id="s-lng"
type="number"
step="any"
value={startLng}
onChange={(e) => setStartLng(e.target.value)}
placeholder="-180 to 180"
className="h-10 text-sm"
required
/>
</div>
</div>
</div>
{/* End Point */}
<div className="p-4 rounded-xl bg-gradient-to-r from-orange-50/50 to-red-50/50 dark:from-orange-950/20 dark:to-red-950/20 border border-orange-100 dark:border-orange-900/50">
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-3 uppercase tracking-wide">End Point</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="e-lat" className="text-xs text-gray-500">Latitude *</Label>
<Input
id="e-lat"
type="number"
step="any"
value={endLat}
onChange={(e) => setEndLat(e.target.value)}
placeholder="-90 to 90"
className="h-10 text-sm"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="e-lng" className="text-xs text-gray-500">Longitude *</Label>
<Input
id="e-lng"
type="number"
step="any"
value={endLng}
onChange={(e) => setEndLng(e.target.value)}
placeholder="-180 to 180"
className="h-10 text-sm"
required
/>
</div>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !isFormComplete}
className="flex-1 bg-gradient-to-r from-amber-500 via-orange-500 to-red-600 hover:from-amber-600 hover:via-orange-600 hover:to-red-700 text-white"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</span>
) : (
<span className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Create Location
</span>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

340
app/create-package/page.tsx Normal file
View File

@@ -0,0 +1,340 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Loader2, CheckCircle2, Package, FolderKanban, Globe } from "lucide-react"
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { DataTable } from "@/components/data-table"
import {
fetchProjects,
fetchAllPackages,
createPackage,
type Project,
type Package as PackageType,
type PackageCreate
} from "@/lib/api"
export default function CreatePackagePage() {
const [packages, setPackages] = useState<PackageType[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadingProjects, setLoadingProjects] = useState(false)
// Form fields
const [selectedProjectId, setSelectedProjectId] = useState("")
const [name, setName] = useState("")
const [region, setRegion] = useState("")
// Load packages and projects
const loadPackages = async () => {
try {
setIsLoading(true)
setError(null)
const data = await fetchAllPackages()
setPackages(data)
} catch (err) {
setError("Failed to load packages. Please check if the backend is running.")
} finally {
setIsLoading(false)
}
}
const loadProjects = async () => {
try {
setLoadingProjects(true)
const data = await fetchProjects()
setProjects(data)
} catch (err) {
setError("Failed to load projects.")
} finally {
setLoadingProjects(false)
}
}
useEffect(() => {
loadPackages()
loadProjects()
}, [])
const resetForm = () => {
setSelectedProjectId("")
setName("")
setRegion("")
setError(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedProjectId) {
setError("Please select a project first")
return
}
if (!name.trim()) {
setError("Package name is required")
return
}
setIsSubmitting(true)
setError(null)
try {
const data: PackageCreate = {
project_id: selectedProjectId,
name: name.trim(),
region: region.trim() || null,
}
await createPackage(data)
setSuccess(true)
// Refresh packages list
await loadPackages()
setTimeout(() => {
resetForm()
setSuccess(false)
setIsModalOpen(false)
}, 2000)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create package")
} finally {
setIsSubmitting(false)
}
}
const getProjectName = (projectId: string) => {
return projects.find(p => p.id === projectId)?.name || projectId
}
const selectedProject = projects.find(p => p.id === selectedProjectId)
const columns = [
{
key: "name",
header: "Package Name",
render: (pkg: PackageType) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{pkg.name}</div>
)
},
{
key: "project_id",
header: "Project",
render: (pkg: PackageType) => getProjectName(pkg.project_id)
},
{
key: "region",
header: "Region",
},
{
key: "created_at",
header: "Created",
render: (pkg: PackageType) => new Date(pkg.created_at).toLocaleDateString()
},
]
return (
<div className="min-h-screen bg-mesh-gradient text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-16 min-h-screen relative overflow-hidden">
<div className="mx-auto px-4 py-8 max-w-6xl relative z-10">
{/* Header */}
<div className="mb-8 animate-in fade-in slide-in-from-left duration-700">
<div className="flex items-center gap-6">
<div className="flex-shrink-0 w-16 h-16 rounded-2xl bg-white shadow-xl border-2 border-[#1e40af] transition-transform duration-300 hover:scale-110 flex items-center justify-center">
<Package className="h-8 w-8 text-[#2563eb]" />
</div>
<div className="flex flex-col">
<h1 className="text-3xl md:text-4xl font-extrabold text-[#2563eb] tracking-tight">
Packages
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm font-medium italic">
Manage project packages
</p>
</div>
</div>
</div>
{/* Error Message */}
{error && !isModalOpen && (
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top duration-300">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Data Table */}
<div className="animate-in fade-in slide-in-from-bottom duration-700 delay-150">
<DataTable
title="All Packages"
data={packages}
columns={columns}
onAddNew={() => setIsModalOpen(true)}
addButtonText="Add New Package"
isLoading={isLoading}
/>
</div>
{/* Footer */}
<div className="mt-8 text-center animate-in fade-in duration-700 delay-300">
<p className="text-xs text-gray-400 dark:text-gray-500">
Sentient Geeks Pvt. Ltd.
</p>
</div>
</div>
</main>
{/* Modal Dialog */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5 text-violet-500" />
Create New Package
</DialogTitle>
<DialogDescription>
Select a project and fill in the package details
</DialogDescription>
</DialogHeader>
{/* Success Message in Modal */}
{success && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30 border border-violet-200 dark:border-violet-800 animate-in fade-in slide-in-from-top duration-300">
<CheckCircle2 className="h-5 w-5 text-violet-500 flex-shrink-0" />
<p className="text-sm font-medium text-violet-700 dark:text-violet-400">Package created successfully!</p>
</div>
)}
{/* Error Message in Modal */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top duration-300">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Step 1: Select Project */}
<div className="p-4 rounded-xl bg-gradient-to-r from-violet-50/50 to-purple-50/50 dark:from-violet-950/20 dark:to-purple-950/20 border border-violet-100 dark:border-violet-900/50">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
<span className="text-white text-xs font-bold">1</span>
</div>
<p className="text-sm font-bold text-violet-700 dark:text-violet-400">Select Project</p>
</div>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger className="h-11 bg-white dark:bg-gray-800 border-violet-200 dark:border-violet-800 focus:border-violet-400">
{loadingProjects ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-violet-500" />
<span className="text-gray-400">Loading projects...</span>
</div>
) : (
<SelectValue placeholder="Choose a project" />
)}
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
<span className="font-medium">{project.name}</span>
{project.state && <span className="text-gray-400 ml-2">({project.state})</span>}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedProject && (
<div className="mt-2 px-3 py-1.5 rounded-lg bg-violet-100/50 dark:bg-violet-900/20 text-xs text-violet-600 dark:text-violet-400">
Selected: <span className="font-semibold">{selectedProject.name}</span>
{selectedProject.corridor_name && `${selectedProject.corridor_name}`}
</div>
)}
</div>
{/* Step 2: Package Info */}
<div className={`space-y-4 transition-opacity duration-300 ${selectedProjectId ? 'opacity-100' : 'opacity-40 pointer-events-none'}`}>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${selectedProjectId ? 'bg-gradient-to-br from-violet-500 to-purple-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
<span className="text-white text-xs font-bold">2</span>
</div>
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">Package Information</p>
</div>
<div className="space-y-2">
<Label htmlFor="pkg-name" className="text-sm font-semibold flex items-center gap-1">
Package Name <span className="text-red-500">*</span>
</Label>
<Input
id="pkg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Package A - Section 1"
className="h-11"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="region" className="text-sm font-semibold flex items-center gap-1">
<Globe className="h-3.5 w-3.5 text-gray-400" />
Region <span className="text-gray-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="region"
value={region}
onChange={(e) => setRegion(e.target.value)}
placeholder="e.g., Delhi, Haryana, Punjab"
className="h-11"
/>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim() || !selectedProjectId}
className="flex-1 bg-gradient-to-r from-violet-500 via-purple-500 to-fuchsia-600 hover:from-violet-600 hover:via-purple-600 hover:to-fuchsia-700 text-white"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</span>
) : (
<span className="flex items-center gap-2">
<Package className="h-4 w-4" />
Create Package
</span>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

376
app/create-project/page.tsx Normal file
View File

@@ -0,0 +1,376 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Loader2, CheckCircle2, FolderPlus, MapPin, Building2, Route, X } from "lucide-react"
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { DataTable } from "@/components/data-table"
import { createProject, fetchProjects, type ProjectCreate, type Project } from "@/lib/api"
export default function CreateProjectPage() {
const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
// Form fields
const [name, setName] = useState("")
const [state, setState] = useState("")
const [corridorName, setCorridorName] = useState("")
const [startLat, setStartLat] = useState("")
const [startLng, setStartLng] = useState("")
const [endLat, setEndLat] = useState("")
const [endLng, setEndLng] = useState("")
// Load projects
const loadProjects = async () => {
try {
setIsLoading(true)
setError(null)
const data = await fetchProjects()
setProjects(data)
} catch (err) {
setError("Failed to load projects. Please check if the backend is running.")
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadProjects()
}, [])
const resetForm = () => {
setName("")
setState("")
setCorridorName("")
setStartLat("")
setStartLng("")
setEndLat("")
setEndLng("")
setError(null)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError("Project name is required")
return
}
setIsSubmitting(true)
setError(null)
try {
const data: ProjectCreate = {
name: name.trim(),
state: state.trim() || null,
corridor_name: corridorName.trim() || null,
start_lat: startLat ? parseFloat(startLat) : null,
start_lng: startLng ? parseFloat(startLng) : null,
end_lat: endLat ? parseFloat(endLat) : null,
end_lng: endLng ? parseFloat(endLng) : null,
}
await createProject(data)
setSuccess(true)
// Refresh projects list
await loadProjects()
setTimeout(() => {
resetForm()
setSuccess(false)
setIsModalOpen(false)
}, 2000)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create project")
} finally {
setIsSubmitting(false)
}
}
const columns = [
{
key: "name",
header: "Project Name",
render: (project: Project) => (
<div className="font-semibold text-gray-900 dark:text-gray-100">{project.name}</div>
)
},
{
key: "state",
header: "State",
render: (project: Project) => {
if (!project.state) return <span className="text-gray-400"></span>
return (
<div className="flex flex-wrap gap-1.5">
{project.state.split(',').map((item, idx) => (
<span
key={idx}
className="px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-[11px] font-semibold border border-blue-100 dark:border-blue-800 shadow-sm"
>
{item.trim()}
</span>
))}
</div>
)
}
},
{
key: "corridor_name",
header: "Corridor",
},
{
key: "created_at",
header: "Created",
render: (project: Project) => new Date(project.created_at).toLocaleDateString()
},
]
return (
<div className="min-h-screen bg-mesh-gradient text-gray-900 dark:text-gray-100">
<SidebarNavigation />
<main className="ml-16 min-h-screen relative overflow-hidden">
<div className="mx-auto px-4 py-8 max-w-6xl relative z-10">
{/* Header */}
<div className="mb-8 animate-in fade-in slide-in-from-left duration-700">
<div className="flex items-center gap-6">
<div className="flex-shrink-0 w-16 h-16 rounded-2xl bg-white shadow-xl border-2 border-blue-800 transition-transform duration-300 hover:scale-110 flex items-center justify-center">
<FolderPlus className="h-8 w-8 text-blue-600" />
</div>
<div className="flex flex-col">
<h1 className="text-3xl md:text-4xl font-extrabold text-blue-600 tracking-tight">
Projects
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm font-medium italic">
Manage road infrastructure projects
</p>
</div>
</div>
</div>
{/* Error Message */}
{error && !isModalOpen && (
<div className="mb-6 flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top duration-300">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Data Table */}
<div className="animate-in fade-in slide-in-from-bottom duration-700 delay-150">
<DataTable
title="All Projects"
data={projects}
columns={columns}
onAddNew={() => setIsModalOpen(true)}
addButtonText="Add New Project"
isLoading={isLoading}
/>
</div>
{/* Footer */}
<div className="mt-8 text-center animate-in fade-in duration-700 delay-300">
<p className="text-xs text-gray-400 dark:text-gray-500">
Sentient Geeks Pvt. Ltd.
</p>
</div>
</div>
</main>
{/* Modal Dialog */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderPlus className="h-5 w-5 text-blue-500" />
Create New Project
</DialogTitle>
<DialogDescription>
Fill in the details for your new road infrastructure project
</DialogDescription>
</DialogHeader>
{/* Success Message in Modal */}
{success && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800 animate-in fade-in slide-in-from-top duration-300">
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
<p className="text-sm font-medium text-blue-700 dark:text-blue-400">Project created successfully!</p>
</div>
)}
{/* Error Message in Modal */}
{error && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 animate-in fade-in slide-in-from-top duration-300">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-bold text-red-500">!</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-semibold flex items-center gap-1">
Project Name <span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Delhi-Chandigarh Highway"
className="h-11"
required
/>
</div>
{/* State & Corridor Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="state" className="text-sm font-semibold">
State <span className="text-gray-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="state"
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="e.g., Haryana"
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="corridor" className="text-sm font-semibold flex items-center gap-1">
<Route className="h-3.5 w-3.5 text-gray-400" />
Corridor Name <span className="text-gray-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="corridor"
value={corridorName}
onChange={(e) => setCorridorName(e.target.value)}
placeholder="e.g., National Highway 44"
className="h-11"
/>
</div>
</div>
{/* GPS Coordinates Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-blue-400 to-indigo-500 shadow-md shadow-blue-500/20">
<MapPin className="h-4 w-4 text-white" />
</div>
<h3 className="text-sm font-bold text-gray-700 dark:text-gray-300">GPS Coordinates</h3>
<span className="text-xs text-gray-400">(Optional)</span>
</div>
{/* Start Point */}
<div className="p-4 rounded-xl bg-gradient-to-r from-blue-50/50 to-indigo-50/50 dark:from-blue-950/20 dark:to-indigo-950/20 border border-blue-100 dark:border-blue-900/50">
<p className="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-3 uppercase tracking-wide">Start Point</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="start-lat" className="text-xs text-gray-500">Latitude</Label>
<Input
id="start-lat"
type="number"
step="any"
value={startLat}
onChange={(e) => setStartLat(e.target.value)}
placeholder="-90 to 90"
className="h-10 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="start-lng" className="text-xs text-gray-500">Longitude</Label>
<Input
id="start-lng"
type="number"
step="any"
value={startLng}
onChange={(e) => setStartLng(e.target.value)}
placeholder="-180 to 180"
className="h-10 text-sm"
/>
</div>
</div>
</div>
{/* End Point */}
<div className="p-4 rounded-xl bg-gradient-to-r from-blue-50/50 to-indigo-50/50 dark:from-blue-950/20 dark:to-indigo-950/20 border border-blue-100 dark:border-blue-900/50">
<p className="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-3 uppercase tracking-wide">End Point</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="end-lat" className="text-xs text-gray-500">Latitude</Label>
<Input
id="end-lat"
type="number"
step="any"
value={endLat}
onChange={(e) => setEndLat(e.target.value)}
placeholder="-90 to 90"
className="h-10 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="end-lng" className="text-xs text-gray-500">Longitude</Label>
<Input
id="end-lng"
type="number"
step="any"
value={endLng}
onChange={(e) => setEndLng(e.target.value)}
placeholder="-180 to 180"
className="h-10 text-sm"
/>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setIsModalOpen(false)
resetForm()
}}
disabled={isSubmitting}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-blue-700 hover:from-blue-600 hover:via-indigo-600 hover:to-blue-800 text-white"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</span>
) : (
<span className="flex items-center gap-2">
<FolderPlus className="h-4 w-4" />
Create Project
</span>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,12 +1,8 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Plus,
Loader2,
AlertCircle,
TrendingUp,
MapPin as MapPinIcon,
@@ -17,6 +13,7 @@ import {
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { GradientStatsCard } from "@/components/dashboard/gradient-stats-card"
import { CompactProjectSelector } from "@/components/dashboard/compact-project-selector"
import { FilterSelector } from "@/components/dashboard/filter-selector"
import { DetectionDonutChart } from "@/components/dashboard/detection-donut-chart"
import { LocationBarChart } from "@/components/dashboard/location-bar-chart"
import { DashboardMap } from "@/components/dashboard/dashboard-map"
@@ -114,7 +111,6 @@ function calculateStats(summary: ProjectSummary | null): DetectionStats {
}
export default function DashboardPage() {
const router = useRouter()
const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
@@ -150,11 +146,31 @@ export default function DashboardPage() {
loadProjects()
}, [])
const selectedProject = projects.find(p => p.id === selectedProjectId)
// Extract packages from project summary
const packages = projectSummary
? Object.keys(projectSummary.packages || {}).map(pkgName => ({
id: pkgName,
name: pkgName
}))
: []
// Extract locations from selected package
const [selectedPackageId, setSelectedPackageId] = useState<string | null>(null)
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(null)
const locations = projectSummary && selectedPackageId && selectedPackageId !== "all"
? Object.keys(projectSummary.packages[selectedPackageId]?.locations || {}).map(locName => ({
id: locName,
name: locName
}))
: []
// Load project summary when project changes
useEffect(() => {
if (!selectedProjectId) {
setProjectSummary(null)
setStats({ totalPotholes: 0, totalSignboards: 0, totalDetections: 0, locationData: [] })
return
}
@@ -176,12 +192,10 @@ export default function DashboardPage() {
const summary: ProjectSummary = await response.json()
setProjectSummary(summary)
setStats(calculateStats(summary))
} catch (err) {
console.error("Failed to load project summary:", err)
setError("Failed to load project summary.")
setProjectSummary(null)
setStats({ totalPotholes: 0, totalSignboards: 0, totalDetections: 0, locationData: [] })
} finally {
setIsLoading(false)
}
@@ -190,14 +204,73 @@ export default function DashboardPage() {
loadProjectSummary()
}, [selectedProjectId])
const handleNewAnalysis = () => {
router.push("/")
}
// Reset package and location when project changes
useEffect(() => {
setSelectedPackageId(null)
setSelectedLocationId(null)
}, [selectedProjectId])
const selectedProject = projects.find(p => p.id === selectedProjectId)
// Reset location when package changes
useEffect(() => {
setSelectedLocationId(null)
}, [selectedPackageId])
// Filter stats based on selections
useEffect(() => {
if (!projectSummary) {
setStats({ totalPotholes: 0, totalSignboards: 0, totalDetections: 0, locationData: [] })
return
}
let totalPotholes = 0
let totalSignboards = 0
const locationData: DetectionStats["locationData"] = []
const packagesToProcess = selectedPackageId && selectedPackageId !== "all"
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
: projectSummary.packages || {}
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
const locationsToProcess = selectedLocationId && selectedLocationId !== "all"
? { [selectedLocationId]: pkg.locations[selectedLocationId] }
: pkg.locations || {}
for (const [locName, loc] of Object.entries(locationsToProcess)) {
let locPotholes = 0
let locSignboards = 0
for (const detection of loc.detections || []) {
if (detection.type.toLowerCase().includes("pothole")) {
locPotholes++
totalPotholes++
} else {
locSignboards++
totalSignboards++
}
}
if (locPotholes > 0 || locSignboards > 0) {
const shortName = locName.length > 20 ? locName.substring(0, 20) + "..." : locName
locationData.push({
name: shortName,
potholes: locPotholes,
signboards: locSignboards,
total: locPotholes + locSignboards
})
}
}
}
setStats({
totalPotholes,
totalSignboards,
totalDetections: totalPotholes + totalSignboards,
locationData
})
}, [projectSummary, selectedPackageId, selectedLocationId])
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="min-h-screen bg-mesh-gradient text-gray-900 dark:text-gray-100">
{/* Sidebar Navigation */}
<SidebarNavigation />
@@ -206,22 +279,13 @@ export default function DashboardPage() {
<div className="p-6 max-w-[1600px] mx-auto">
{/* Header */}
<div className="mb-6 animate-in fade-in slide-in-from-top duration-500">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-gray-900 via-indigo-800 to-indigo-600 dark:from-white dark:via-indigo-200 dark:to-indigo-400 bg-clip-text text-transparent">
VisionRoad Analytics Dashboard
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
A Comprehensive Overview Of Your Road Infrastructure Analysis
</p>
</div>
<Button
onClick={handleNewAnalysis}
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white shadow-lg shadow-indigo-500/25 hover:shadow-indigo-500/40 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
New Analysis
</Button>
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-gray-900 via-indigo-800 to-indigo-600 dark:from-white dark:via-indigo-200 dark:to-indigo-400 bg-clip-text text-transparent">
VisionRoad Analytics Dashboard
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
A Comprehensive Overview Of Your Road Infrastructure Analysis
</p>
</div>
</div>
@@ -261,67 +325,74 @@ export default function DashboardPage() {
/>
</div>
{/* Project Selector - Above main content */}
{/* Filter Selector - Above main content */}
<div className="mb-6 animate-in fade-in slide-in-from-bottom duration-500 delay-100">
<CompactProjectSelector
<FilterSelector
projects={projects}
selectedProjectId={selectedProjectId}
selectedPackageId={selectedPackageId}
selectedLocationId={selectedLocationId}
onProjectChange={setSelectedProjectId}
selectedProject={selectedProject}
onPackageChange={setSelectedPackageId}
onLocationChange={setSelectedLocationId}
packages={packages}
locations={locations}
isLoading={isLoading}
/>
</div>
{/* Main Content Grid - Map Left, Charts Right */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 animate-in fade-in slide-in-from-bottom duration-500 delay-200">
{/* Left Side - Map */}
<div>
<DashboardMap className="h-auto" />
</div>
{/* Charts Row - Side by Side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4 animate-in fade-in slide-in-from-bottom duration-500 delay-200">
{/* Left Chart - Detection Distribution */}
<Card className="rounded-xl overflow-hidden">
<CardHeader className="pb-2 border-b border-[var(--border)]">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-[#2563eb]/10 shadow-sm">
<BarChart3 className="h-4 w-4 text-[#2563eb]" />
</div>
<span className="text-[#2563eb]">
Detection Distribution
</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<DetectionDonutChart
potholes={stats.totalPotholes}
signboards={stats.totalSignboards}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Right Side - Stacked Charts */}
<div className="space-y-4">
{/* Detection Distribution */}
<Card className="bg-white/70 dark:bg-gray-800/70 backdrop-blur-sm border-0 shadow-lg shadow-gray-200/50 dark:shadow-gray-900/50 rounded-xl overflow-hidden">
<CardHeader className="pb-2 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 shadow-md shadow-emerald-500/30">
<BarChart3 className="h-4 w-4 text-white" />
</div>
<span className="bg-gradient-to-r from-emerald-600 via-teal-500 to-emerald-600 dark:from-emerald-400 dark:via-teal-400 dark:to-emerald-400 bg-clip-text text-transparent">
Detection Distribution
</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<DetectionDonutChart
potholes={stats.totalPotholes}
signboards={stats.totalSignboards}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Right Chart - Location Bar Chart */}
<Card className="rounded-xl overflow-hidden">
<CardHeader className="pb-2 border-b border-[var(--border)]">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-[#2563eb]/10 shadow-sm">
<MapPinIcon className="h-4 w-4 text-[#2563eb]" />
</div>
<span className="text-[#2563eb]">
Detections by Location
</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<LocationBarChart
data={stats.locationData}
isLoading={isLoading}
/>
</CardContent>
</Card>
</div>
{/* Location Bar Chart */}
<Card className="bg-white/70 dark:bg-gray-800/70 backdrop-blur-sm border-0 shadow-lg shadow-gray-200/50 dark:shadow-gray-900/50 rounded-xl overflow-hidden">
<CardHeader className="pb-2 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-blue-400 to-indigo-500 shadow-md shadow-blue-500/30">
<MapPinIcon className="h-4 w-4 text-white" />
</div>
<span className="bg-gradient-to-r from-blue-600 via-indigo-500 to-blue-600 dark:from-blue-400 dark:via-indigo-400 dark:to-blue-400 bg-clip-text text-transparent">
Detections by Location
</span>
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<LocationBarChart
data={stats.locationData}
isLoading={isLoading}
/>
</CardContent>
</Card>
</div>
{/* Map - Full Width Below Charts */}
<div className="animate-in fade-in slide-in-from-bottom duration-500 delay-300">
<DashboardMap
selectedProjectId={selectedProjectId}
selectedPackageId={selectedPackageId}
selectedLocationId={selectedLocationId}
projectSummary={projectSummary}
/>
</div>
{/* Footer */}

View File

@@ -7,45 +7,49 @@
/* Primary - Indigo */
--primary: oklch(0.55 0.24 264);
--primary-foreground: oklch(0.98 0.01 264);
/* Background & Surfaces */
--background: oklch(0.98 0.01 280);
--background: hsla(210, 100%, 98%, 1);
--foreground: oklch(0.15 0.02 280);
--card: oklch(0.99 0.005 280);
--card: oklch(1 0 0);
--card-foreground: oklch(0.15 0.02 280);
--popover: oklch(0.99 0.005 280);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0.02 280);
/* Secondary */
--secondary: oklch(0.95 0.02 264);
--secondary-foreground: oklch(0.25 0.05 264);
/* Muted */
--muted: oklch(0.94 0.02 280);
--muted-foreground: oklch(0.45 0.03 280);
/* Accent - Cyan */
--accent: oklch(0.92 0.04 200);
--accent-foreground: oklch(0.25 0.08 200);
/* Destructive */
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(0.98 0.01 25);
/* Borders & Inputs */
--border: oklch(0.90 0.02 280);
--border: hsla(210, 100%, 85%, 1);
--input: oklch(0.92 0.02 280);
--ring: oklch(0.55 0.24 264);
--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.20 200);
--chart-3: oklch(0.60 0.18 160);
--chart-4: oklch(0.70 0.20 85);
--chart-5: oklch(0.60 0.22 320);
--radius: 0.75rem;
/* Sidebar */
--sidebar: oklch(0.98 0.01 280);
--sidebar-foreground: oklch(0.15 0.02 280);
@@ -61,7 +65,7 @@
/* Primary - Light Indigo */
--primary: oklch(0.70 0.20 264);
--primary-foreground: oklch(0.15 0.02 264);
/* Background & Surfaces */
--background: oklch(0.12 0.02 280);
--foreground: oklch(0.95 0.01 280);
@@ -69,35 +73,35 @@
--card-foreground: oklch(0.95 0.01 280);
--popover: oklch(0.16 0.02 280);
--popover-foreground: oklch(0.95 0.01 280);
/* Secondary */
--secondary: oklch(0.22 0.03 264);
--secondary-foreground: oklch(0.90 0.02 264);
/* Muted */
--muted: oklch(0.20 0.02 280);
--muted-foreground: oklch(0.65 0.02 280);
/* Accent */
--accent: oklch(0.22 0.04 200);
--accent-foreground: oklch(0.85 0.08 200);
/* Destructive */
--destructive: oklch(0.45 0.18 25);
--destructive-foreground: oklch(0.90 0.08 25);
/* Borders & Inputs */
--border: oklch(0.25 0.02 280);
--input: oklch(0.22 0.02 280);
--ring: oklch(0.70 0.20 264);
/* Chart Colors */
--chart-1: oklch(0.65 0.22 264);
--chart-2: oklch(0.70 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.20 320);
/* Sidebar */
--sidebar: oklch(0.14 0.02 280);
--sidebar-foreground: oklch(0.95 0.01 280);
@@ -154,6 +158,7 @@
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
@@ -162,44 +167,37 @@
/* Premium Background with Animated Mesh Gradient */
@layer utilities {
.bg-mesh-gradient {
background:
radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.75 0.15 264 / 0.15), transparent),
radial-gradient(ellipse 60% 40% at 80% 80%, oklch(0.70 0.12 200 / 0.12), transparent),
radial-gradient(ellipse 50% 30% at 50% 50%, oklch(0.65 0.10 320 / 0.08), transparent),
var(--background);
background: hsla(210, 100%, 98%, 1);
background: linear-gradient(135deg, hsla(210, 100%, 98%, 1) 0%, hsla(220, 100%, 95%, 1) 100%);
}
.dark .bg-mesh-gradient {
background:
radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.40 0.18 264 / 0.20), transparent),
radial-gradient(ellipse 60% 40% at 80% 80%, oklch(0.35 0.15 200 / 0.15), transparent),
radial-gradient(ellipse 50% 30% at 50% 50%, oklch(0.30 0.12 320 / 0.10), transparent),
var(--background);
background: hsla(210, 30%, 15%, 1);
}
/* Glassmorphism Card */
.glass-card {
background: oklch(1 0 0 / 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid oklch(1 0 0 / 0.2);
box-shadow:
box-shadow:
0 4px 6px -1px oklch(0 0 0 / 0.05),
0 10px 15px -3px oklch(0 0 0 / 0.08),
0 20px 25px -5px oklch(0 0 0 / 0.05),
inset 0 1px 0 oklch(1 0 0 / 0.5);
}
.dark .glass-card {
background: oklch(0.18 0.02 280 / 0.7);
border: 1px solid oklch(1 0 0 / 0.08);
box-shadow:
box-shadow:
0 4px 6px -1px oklch(0 0 0 / 0.15),
0 10px 15px -3px oklch(0 0 0 / 0.20),
0 20px 25px -5px oklch(0 0 0 / 0.15),
inset 0 1px 0 oklch(1 0 0 / 0.05);
}
/* Gradient Text */
.text-gradient {
background: linear-gradient(135deg, oklch(0.55 0.24 264), oklch(0.60 0.20 200));
@@ -207,42 +205,42 @@
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .text-gradient {
background: linear-gradient(135deg, oklch(0.75 0.20 264), oklch(0.70 0.18 200));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient Button */
.btn-gradient {
background: linear-gradient(135deg, oklch(0.55 0.24 264), oklch(0.50 0.22 280));
box-shadow:
box-shadow:
0 4px 14px 0 oklch(0.55 0.24 264 / 0.35),
inset 0 1px 0 oklch(1 0 0 / 0.15);
transition: all 0.3s ease;
}
.btn-gradient:hover {
box-shadow:
box-shadow:
0 6px 20px 0 oklch(0.55 0.24 264 / 0.45),
inset 0 1px 0 oklch(1 0 0 / 0.2);
transform: translateY(-1px);
}
.btn-gradient:active {
transform: translateY(0);
box-shadow:
box-shadow:
0 2px 8px 0 oklch(0.55 0.24 264 / 0.30),
inset 0 1px 0 oklch(1 0 0 / 0.15);
}
/* Card Glow Border */
.card-glow {
position: relative;
}
.card-glow::before {
content: '';
position: absolute;
@@ -253,55 +251,67 @@
opacity: 0;
transition: opacity 0.3s ease;
}
.card-glow:hover::before {
opacity: 1;
}
/* Progress Bar Gradient */
.progress-gradient {
background: linear-gradient(90deg, oklch(0.55 0.24 264), oklch(0.60 0.20 200), oklch(0.65 0.18 160));
background-size: 200% 100%;
animation: progress-shimmer 2s ease infinite;
}
@keyframes progress-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Subtle Float Animation */
.float-subtle {
animation: float-subtle 6s ease-in-out infinite;
}
@keyframes float-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
/* Input Focus Glow */
.input-glow:focus-within {
box-shadow: 0 0 0 3px oklch(0.55 0.24 264 / 0.15);
}
/* Step Indicator */
.step-active {
background: linear-gradient(135deg, oklch(0.55 0.24 264), oklch(0.50 0.22 280));
color: white;
box-shadow: 0 2px 8px oklch(0.55 0.24 264 / 0.3);
}
.step-completed {
background: oklch(0.65 0.18 160);
color: white;
}
.step-pending {
background: oklch(0.90 0.02 280);
color: oklch(0.50 0.02 280);
}
.dark .step-pending {
background: oklch(0.25 0.02 280);
color: oklch(0.60 0.02 280);

View File

@@ -9,13 +9,6 @@ export default function MapPage() {
{/* Navigation Menu */}
<NavigationMenu />
{/* Decorative background elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -left-40 w-80 h-80 bg-primary/10 rounded-full blur-3xl float-subtle" />
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-accent/10 rounded-full blur-3xl float-subtle" style={{ animationDelay: '-3s' }} />
<div className="absolute top-1/2 left-1/4 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
</div>
<div className="container mx-auto px-4 py-8 max-w-7xl relative z-10">
{/* Header */}
<div className="mb-8 animate-in fade-in slide-in-from-top duration-700">

50
app/new-analysis/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client"
import { useRouter } from "next/navigation"
import { ProjectSelectionSection } from "@/components/project-selection-section"
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { type SessionContext, saveSession } from "@/lib/api"
export default function NewAnalysisPage() {
const router = useRouter()
const handleSelectionComplete = (session: SessionContext) => {
// Save session to storage and navigate to upload page
saveSession(session)
router.push("/upload")
}
return (
<div className="min-h-screen bg-mesh-gradient text-gray-900 dark:text-gray-100">
{/* Sidebar Navigation */}
<SidebarNavigation />
{/* Main Content */}
<main className="ml-16 min-h-screen relative overflow-hidden">
<div className="container mx-auto px-4 py-12 max-w-5xl relative z-10">
{/* Premium Header */}
<div className="mb-10 animate-in fade-in slide-in-from-top duration-700">
<div className="text-center max-w-2xl mx-auto">
<h1 className="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-gray-900 via-indigo-800 to-indigo-600 dark:from-white dark:via-indigo-200 dark:to-indigo-400 bg-clip-text text-transparent leading-tight">
VisionRoad Detection System
</h1>
</div>
</div>
{/* Project Selection Section */}
<div className="animate-in fade-in slide-in-from-bottom duration-700 delay-150">
<ProjectSelectionSection onSelectionComplete={handleSelectionComplete} />
</div>
{/* Footer */}
<div className="mt-12 text-center animate-in fade-in duration-700 delay-300">
<p className="text-sm text-gray-400 dark:text-gray-500">
Sentient Geeks Pvt. Ltd.
</p>
</div>
</div>
</main>
</div>
)
}

View File

@@ -1,56 +1,5 @@
"use client"
import { redirect } from "next/navigation"
import { useRouter } from "next/navigation"
import { ProjectSelectionSection } from "@/components/project-selection-section"
import { SidebarNavigation } from "@/components/sidebar-navigation"
import { type SessionContext, saveSession } from "@/lib/api"
export default function SelectionPage() {
const router = useRouter()
const handleSelectionComplete = (session: SessionContext) => {
// Save session to storage and navigate to upload page
saveSession(session)
router.push("/upload")
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
{/* Sidebar Navigation */}
<SidebarNavigation />
{/* Main Content */}
<main className="ml-16 min-h-screen relative overflow-hidden">
{/* Decorative background elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -left-40 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl" />
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-blue-500/5 rounded-full blur-3xl" />
</div>
<div className="container mx-auto px-4 py-12 max-w-5xl relative z-10">
{/* Premium Header */}
<div className="mb-10 animate-in fade-in slide-in-from-top duration-700">
<div className="text-center max-w-2xl mx-auto">
<h1 className="text-4xl md:text-5xl font-bold mb-4 bg-gradient-to-r from-gray-900 via-indigo-800 to-indigo-600 dark:from-white dark:via-indigo-200 dark:to-indigo-400 bg-clip-text text-transparent leading-tight">
VisionRoad Detection System
</h1>
</div>
</div>
{/* Project Selection Section */}
<div className="animate-in fade-in slide-in-from-bottom duration-700 delay-150">
<ProjectSelectionSection onSelectionComplete={handleSelectionComplete} />
</div>
{/* Footer */}
<div className="mt-12 text-center animate-in fade-in duration-700 delay-300">
<p className="text-sm text-gray-400 dark:text-gray-500">
Sentient Geeks Pvt. Ltd.
</p>
</div>
</div>
</main>
</div>
)
export default function HomePage() {
redirect("/dashboard")
}

View File

@@ -95,7 +95,7 @@ export default function ResultsPage() {
const videoData = loadVideoData()
if (!isSessionValid(storedSession) || !videoData) {
router.replace("/")
router.replace("/new-analysis")
return
}
@@ -143,7 +143,7 @@ export default function ResultsPage() {
}
}
clearSession()
router.push("/")
router.push("/new-analysis")
}
const handleBackToUpload = () => {
@@ -158,7 +158,7 @@ export default function ResultsPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center">
<div className="min-h-screen bg-mesh-gradient 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>
@@ -169,7 +169,7 @@ export default function ResultsPage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center">
<div className="min-h-screen bg-mesh-gradient 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>
@@ -179,7 +179,7 @@ export default function ResultsPage() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="min-h-screen bg-mesh-gradient text-gray-900 dark:text-gray-100">
{/* Sidebar Navigation */}
<SidebarNavigation />
@@ -199,7 +199,7 @@ export default function ResultsPage() {
{/* Session Info Bar */}
{session && (
<div className="mb-6 animate-in fade-in slide-in-from-top duration-500">
<div className="flex items-center justify-between p-4 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 items-center justify-between p-4 rounded-xl bg-card border border-[var(--border)] shadow-sm">
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-indigo-400 to-purple-500 shadow-md shadow-indigo-500/30">
@@ -209,11 +209,11 @@ export default function ResultsPage() {
<span className="font-bold text-gray-900 dark:text-white bg-gradient-to-r from-indigo-600 to-purple-600 dark:from-indigo-400 dark:to-purple-400 bg-clip-text text-transparent">{session.projectName}</span>
</div>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 shadow-md shadow-emerald-500/30">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-blue-400 to-indigo-500 shadow-md shadow-blue-500/30">
<Package className="h-4 w-4 text-white" />
</div>
<span className="text-gray-500 dark:text-gray-400 text-xs uppercase font-semibold tracking-wide">Package:</span>
<span className="font-bold text-gray-900 dark:text-white bg-gradient-to-r from-emerald-600 to-teal-600 dark:from-emerald-400 dark:to-teal-400 bg-clip-text text-transparent">{session.packageName}</span>
<span className="font-bold text-gray-900 dark:text-white bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400 bg-clip-text text-transparent">{session.packageName}</span>
</div>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-gradient-to-br from-purple-400 to-pink-500 shadow-md shadow-purple-500/30">

View File

@@ -44,7 +44,7 @@ export default function UploadPage() {
useEffect(() => {
const storedSession = loadSession()
if (!isSessionValid(storedSession)) {
router.replace("/")
router.replace("/new-analysis")
return
}
setSession(storedSession)
@@ -147,7 +147,7 @@ export default function UploadPage() {
const handleBackToSelection = () => {
clearSession()
router.push("/")
router.push("/new-analysis")
}
const getTitle = () => {
@@ -158,7 +158,7 @@ export default function UploadPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 flex items-center justify-center">
<div className="min-h-screen bg-mesh-gradient flex items-center justify-center">
<div className="p-8 rounded-2xl bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
</div>
@@ -167,17 +167,12 @@ export default function UploadPage() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/50 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="min-h-screen bg-mesh-gradient text-gray-900 dark:text-gray-100">
{/* Sidebar Navigation */}
<SidebarNavigation />
{/* Main Content */}
<main className="ml-16 min-h-screen relative overflow-hidden flex flex-col">
{/* Decorative background elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -left-40 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl" />
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl" />
</div>
<div className="flex-1 container mx-auto px-4 py-6 max-w-6xl relative z-10 flex flex-col">
{/* Compact Header */}
@@ -224,7 +219,7 @@ export default function UploadPage() {
)}
{/* Upload Card */}
<Card className="bg-white/70 dark:bg-gray-800/70 backdrop-blur-sm border-0 shadow-lg shadow-gray-200/50 dark:shadow-gray-900/50 rounded-xl overflow-hidden animate-in fade-in slide-in-from-bottom duration-700 delay-150 flex-1">
<Card className="rounded-xl overflow-hidden animate-in fade-in slide-in-from-bottom duration-700 delay-150 flex-1">
<CardHeader className="pb-4 border-b border-gray-100 dark:border-gray-700 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-950/30 dark:to-purple-950/30">
<CardTitle className="text-xl font-bold">
<span className="bg-gradient-to-r from-indigo-600 via-purple-500 to-indigo-600 dark:from-indigo-400 dark:via-purple-400 dark:to-indigo-400 bg-clip-text text-transparent">