| import { useState, useCallback } from "react"; |
| import { |
| Car, |
| Scan, |
| RefreshCw, |
| FileArchive, |
| ChevronRight, |
| Layers, |
| CheckCircle2, |
| XCircle, |
| } from "lucide-react"; |
| import { ZipUploader } from "@/components/ZipUploader"; |
| import { CarAngleDiagram } from "@/components/CarAngleDiagram"; |
| import { AngleCard } from "@/components/AngleCard"; |
| import { ImageResultCard } from "@/components/ImageResultCard"; |
| import { ProgressBar } from "@/components/ProgressBar"; |
| import { classifyImage } from "@/services/api"; |
| import type { |
| ImageResult, |
| AngleSummary, |
| ClassificationSession, |
| ProcessingStatus, |
| AngleLabel, |
| } from "@/types"; |
| import { ALL_ANGLES } from "@/types"; |
|
|
| export default function Index() { |
| const [status, setStatus] = useState<ProcessingStatus>("idle"); |
| const [session, setSession] = useState<ClassificationSession | null>(null); |
| const [progress, setProgress] = useState({ current: 0, total: 0 }); |
| const [activeTab, setActiveTab] = useState<"diagram" | "images">("diagram"); |
| const [errorMsg, setErrorMsg] = useState<string | null>(null); |
|
|
| const buildSummary = (results: ImageResult[]): AngleSummary[] => { |
| return ALL_ANGLES.map((angle) => { |
| const matching = results.filter((r) => r.angles.includes(angle)); |
| const bestScore = |
| matching.length > 0 |
| ? Math.max( |
| ...matching.map((r) => { |
| const idx = r.angles.indexOf(angle); |
| return idx >= 0 ? (r.scores[idx] ?? 0) : 0; |
| }) |
| ) |
| : 0; |
| return { |
| angle, |
| detected: matching.length > 0, |
| bestScore, |
| detectedIn: matching.map((r) => r.filename), |
| previewUrls: matching.map((r) => r.previewUrl).filter((u): u is string => !!u), |
| }; |
| }); |
| }; |
|
|
| const handleFilesReady = useCallback( |
| async (files: File[], zipName: string) => { |
| setStatus("processing"); |
| setErrorMsg(null); |
| setProgress({ current: 0, total: files.length }); |
|
|
| const results: ImageResult[] = []; |
|
|
| for (let i = 0; i < files.length; i++) { |
| const file = files[i]; |
| const previewUrl = URL.createObjectURL(file); |
| try { |
| const result = await classifyImage(file); |
| results.push({ ...result, previewUrl }); |
| } catch (err) { |
| results.push({ |
| filename: file.name, |
| angles: [], |
| scores: [], |
| review: false, |
| error: err instanceof Error ? err.message : "Request failed", |
| fileDetails: null, |
| previewUrl, |
| }); |
| } |
| setProgress({ current: i + 1, total: files.length }); |
| } |
|
|
| const angleSummary = buildSummary(results); |
|
|
| setSession({ |
| zipFilename: zipName, |
| processedAt: Date.now(), |
| totalImages: files.length, |
| angleSummary, |
| imageResults: results, |
| }); |
| setStatus("done"); |
| }, |
| [] |
| ); |
|
|
| const handleReset = () => { |
| setStatus("idle"); |
| setSession(null); |
| setProgress({ current: 0, total: 0 }); |
| setErrorMsg(null); |
| }; |
|
|
| const detectedCount = session?.angleSummary.filter((s) => s.detected).length ?? 0; |
| const missingCount = (session?.angleSummary.length ?? 0) - detectedCount; |
|
|
| |
| const allScores = session?.imageResults.flatMap((r) => r.scores).filter((s) => s > 0) ?? []; |
| const avgScore = allScores.length > 0 ? allScores.reduce((a, b) => a + b, 0) / allScores.length : 0; |
| const accuracyLabel = !session |
| ? "—" |
| : avgScore >= 0.85 |
| ? "High" |
| : avgScore >= 0.65 |
| ? "Medium" |
| : "Low"; |
| const accuracyColor = !session |
| ? "text-slate-500" |
| : avgScore >= 0.85 |
| ? "text-emerald-400" |
| : avgScore >= 0.65 |
| ? "text-amber-400" |
| : "text-red-400"; |
|
|
| const headerStats = [ |
| { |
| label: session ? "Angles Found" : "Angles Tracked", |
| value: session ? `${detectedCount}/8` : "8", |
| color: session |
| ? detectedCount === 8 |
| ? "text-emerald-400" |
| : detectedCount > 0 |
| ? "text-amber-400" |
| : "text-red-400" |
| : "text-brand-400", |
| }, |
| { |
| label: "Images Processed", |
| value: session ? String(session.totalImages) : "—", |
| color: session ? "text-brand-400" : "text-slate-500", |
| }, |
| { |
| label: "Avg Confidence", |
| value: session ? `${Math.round(avgScore * 100)}%` : "—", |
| color: accuracyColor, |
| }, |
| ]; |
|
|
| return ( |
| <div className="min-h-screen bg-surface-900 text-white font-sans"> |
| {/* ── Hero Header ── */} |
| <header className="relative overflow-hidden border-b border-surface-700"> |
| <div className="absolute inset-0 bg-hero-glow pointer-events-none" /> |
| <div className="absolute inset-0 bg-grid-pattern bg-[size:40px_40px] opacity-10 pointer-events-none" /> |
| |
| <div className="relative max-w-7xl mx-auto px-6 py-8 flex items-center justify-between"> |
| {/* Logo */} |
| <div className="flex items-center gap-4"> |
| <div className="w-12 h-12 rounded-2xl bg-brand-500/20 border border-brand-500/30 flex items-center justify-center"> |
| <Car className="w-6 h-6 text-brand-400" /> |
| </div> |
| <div> |
| <h1 className="text-xl font-bold text-white leading-none">CarLens AI</h1> |
| <p className="text-slate-400 text-xs mt-0.5">Vehicle Angle Classifier</p> |
| </div> |
| </div> |
| |
| {/* Stats strip */} |
| <div className="hidden md:flex items-center gap-8"> |
| {headerStats.map(({ label, value, color }) => ( |
| <div key={label} className="text-center"> |
| <p className={`text-lg font-bold tabular-nums transition-all duration-500 ${color}`}> |
| {value} |
| </p> |
| <p className="text-xs text-slate-500">{label}</p> |
| </div> |
| ))} |
| </div> |
| |
| {/* Reset button */} |
| {session && ( |
| <button |
| onClick={handleReset} |
| className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface-700 hover:bg-surface-600 border border-surface-500 hover:border-surface-400 transition-all text-sm font-medium text-slate-300 hover:text-white" |
| > |
| <RefreshCw className="w-4 h-4" /> |
| New Analysis |
| </button> |
| )} |
| </div> |
| </header> |
| |
| <main className="max-w-7xl mx-auto px-6 py-10 space-y-10"> |
| {/* ── Upload Section ── */} |
| {status === "idle" && ( |
| <section className="animate-fade-in space-y-6"> |
| {/* Hero copy */} |
| <div className="text-center space-y-4 py-4"> |
| <div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-brand-500/10 border border-brand-500/20 text-brand-400 text-sm font-medium mb-2"> |
| <Scan className="w-4 h-4" /> |
| AI-Powered Angle Detection |
| </div> |
| <h2 className="text-4xl md:text-5xl font-extrabold text-white leading-tight"> |
| Which car angles are{" "} |
| <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-400 to-cyan-400"> |
| missing? |
| </span> |
| </h2> |
| <p className="text-slate-400 text-lg max-w-2xl mx-auto"> |
| Upload a ZIP of car photos. Our AI scans each image, classifies its viewing angle, |
| and tells you exactly which angles you still need to capture. |
| </p> |
| </div> |
| |
| {/* How it works */} |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> |
| {[ |
| { |
| step: "01", |
| title: "Upload ZIP", |
| desc: "Drop a ZIP archive containing all your car images", |
| icon: FileArchive, |
| color: "text-brand-400", |
| bg: "bg-brand-500/10 border-brand-500/20", |
| }, |
| { |
| step: "02", |
| title: "AI Analysis", |
| desc: "YOLO models detect parts and determine viewing angle per image", |
| icon: Scan, |
| color: "text-cyan-400", |
| bg: "bg-cyan-500/10 border-cyan-500/20", |
| }, |
| { |
| step: "03", |
| title: "Gap Report", |
| desc: "See a visual map of detected vs. missing angles at a glance", |
| icon: Layers, |
| color: "text-emerald-400", |
| bg: "bg-emerald-500/10 border-emerald-500/20", |
| }, |
| ].map(({ step, title, desc, icon: Icon, color, bg }) => ( |
| <div |
| key={step} |
| className={`rounded-2xl border p-6 ${bg} flex gap-4 items-start`} |
| > |
| <div className={`text-4xl font-black opacity-20 ${color} leading-none`}> |
| {step} |
| </div> |
| <div className="flex-1"> |
| <div className={`flex items-center gap-2 mb-2 ${color}`}> |
| <Icon className="w-5 h-5" /> |
| <span className="font-semibold">{title}</span> |
| </div> |
| <p className="text-slate-400 text-sm">{desc}</p> |
| </div> |
| </div> |
| ))} |
| </div> |
| |
| <ZipUploader onFilesReady={handleFilesReady} /> |
| </section> |
| )} |
| |
| {/* ── Processing ── */} |
| {status === "processing" && ( |
| <section className="animate-fade-in flex flex-col items-center justify-center py-20 gap-8"> |
| <div className="relative"> |
| <div className="w-24 h-24 rounded-full border-4 border-brand-500/20 border-t-brand-500 animate-spin" /> |
| <div className="absolute inset-0 flex items-center justify-center"> |
| <Car className="w-8 h-8 text-brand-400 animate-pulse-slow" /> |
| </div> |
| </div> |
| <div className="text-center space-y-2"> |
| <h3 className="text-2xl font-bold text-white">Classifying Images</h3> |
| <p className="text-slate-400"> |
| Running YOLO detection on each photo in your ZIP… |
| </p> |
| </div> |
| <div className="w-full max-w-md"> |
| <ProgressBar current={progress.current} total={progress.total} /> |
| </div> |
| </section> |
| )} |
| |
| {/* ── Results ── */} |
| {status === "done" && session && ( |
| <section className="animate-slide-up space-y-8"> |
| {/* Summary header */} |
| <div className="rounded-2xl bg-surface-700 border border-surface-600 p-6"> |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> |
| <div> |
| <div className="flex items-center gap-3 mb-1"> |
| <FileArchive className="w-5 h-5 text-slate-400" /> |
| <span className="font-mono text-slate-300 text-sm">{session.zipFilename}</span> |
| </div> |
| <h3 className="text-2xl font-bold text-white"> |
| Analysis Complete |
| </h3> |
| <p className="text-slate-400 text-sm mt-1"> |
| Processed {session.totalImages} image{session.totalImages !== 1 ? "s" : ""} |
| {" "}·{" "} |
| {new Date(session.processedAt).toLocaleString()} |
| </p> |
| </div> |
| |
| {/* Quick badges */} |
| <div className="flex items-center gap-3"> |
| <div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20"> |
| <CheckCircle2 className="w-4 h-4 text-emerald-400" /> |
| <span className="text-emerald-400 font-bold">{detectedCount}</span> |
| <span className="text-slate-400 text-sm">detected</span> |
| </div> |
| {missingCount > 0 && ( |
| <div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-red-500/10 border border-red-500/20"> |
| <XCircle className="w-4 h-4 text-red-400" /> |
| <span className="text-red-400 font-bold">{missingCount}</span> |
| <span className="text-slate-400 text-sm">missing</span> |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| |
| {/* Tabs */} |
| <div className="flex gap-2 border-b border-surface-600 pb-0"> |
| {(["diagram", "images"] as const).map((tab) => ( |
| <button |
| key={tab} |
| onClick={() => setActiveTab(tab)} |
| className={[ |
| "px-5 py-3 text-sm font-medium rounded-t-xl border-b-2 transition-all", |
| activeTab === tab |
| ? "border-brand-500 text-white bg-brand-500/10" |
| : "border-transparent text-slate-400 hover:text-white hover:bg-surface-700", |
| ].join(" ")} |
| > |
| {tab === "diagram" ? "Angle Coverage" : `Per-Image Results (${session.totalImages})`} |
| </button> |
| ))} |
| </div> |
| |
| {/* Tab content */} |
| {activeTab === "diagram" && ( |
| <div className="animate-fade-in grid grid-cols-1 lg:grid-cols-2 gap-8"> |
| {/* Diagram */} |
| <div className="rounded-2xl bg-surface-800 border border-surface-600 p-6"> |
| <h4 className="font-semibold text-white mb-6 flex items-center gap-2"> |
| <Car className="w-5 h-5 text-brand-400" /> |
| Car Coverage Map |
| </h4> |
| <CarAngleDiagram summary={session.angleSummary} /> |
| </div> |
| |
| {/* Angle list */} |
| <div className="space-y-3"> |
| <h4 className="font-semibold text-white flex items-center gap-2"> |
| <Layers className="w-5 h-5 text-brand-400" /> |
| Angle Breakdown |
| </h4> |
| <div className="space-y-2"> |
| {session.angleSummary.map((s) => ( |
| <AngleCard key={s.angle} summary={s} /> |
| ))} |
| </div> |
| </div> |
| </div> |
| )} |
| |
| {activeTab === "images" && ( |
| <div className="animate-fade-in space-y-3"> |
| <h4 className="font-semibold text-white mb-2 text-sm text-slate-400 uppercase tracking-wider"> |
| Individual Image Results |
| </h4> |
| {session.imageResults.map((result, i) => ( |
| <ImageResultCard key={result.filename + i} result={result} index={i} /> |
| ))} |
| </div> |
| )} |
| |
| {/* Missing angles callout */} |
| {missingCount > 0 && ( |
| <div className="rounded-2xl bg-amber-500/5 border border-amber-500/20 p-5 flex gap-4 items-start"> |
| <div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center flex-shrink-0"> |
| <XCircle className="w-5 h-5 text-amber-400" /> |
| </div> |
| <div> |
| <h4 className="font-semibold text-amber-300 mb-1">Missing Angles</h4> |
| <p className="text-slate-400 text-sm mb-3"> |
| The following views were not found in any of the uploaded images: |
| </p> |
| <div className="flex flex-wrap gap-2"> |
| {session.angleSummary |
| .filter((s) => !s.detected) |
| .map((s) => ( |
| <span |
| key={s.angle} |
| className="text-sm px-3 py-1 rounded-full bg-red-500/15 text-red-300 border border-red-500/25 font-medium" |
| > |
| {s.angle} |
| </span> |
| ))} |
| </div> |
| </div> |
| </div> |
| )} |
| </section> |
| )} |
| </main> |
| |
| {/* Footer */} |
| <footer className="border-t border-surface-700 mt-16 py-6 text-center"> |
| <p className="text-slate-500 text-sm"> |
| CarLens AI · Powered by YOLO & FastAPI · Built by Atliq |
| </p> |
| </footer> |
| </div> |
| ); |
| } |
|
|