diff --git a/src/app/globals.css b/src/app/globals.css
index 567e87f..c959b51 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -8,7 +8,7 @@
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
- --font-mono: var(--font-geist-mono);
+ --font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -48,72 +48,72 @@
}
:root {
- --background: oklch(0.97 0.005 270);
- --foreground: oklch(0.17 0.02 270);
+ --background: oklch(0.97 0.003 90);
+ --foreground: oklch(0.18 0.005 90);
--card: oklch(1 0 0 / 65%);
- --card-foreground: oklch(0.17 0.02 270);
- --popover: oklch(0.99 0.002 270 / 90%);
- --popover-foreground: oklch(0.17 0.02 270);
- --primary: oklch(0.488 0.243 264);
+ --card-foreground: oklch(0.18 0.005 90);
+ --popover: oklch(0.99 0.002 90 / 92%);
+ --popover-foreground: oklch(0.18 0.005 90);
+ --primary: oklch(0.20 0.005 90);
--primary-foreground: oklch(0.99 0 0);
- --secondary: oklch(0.93 0.02 270 / 60%);
- --secondary-foreground: oklch(0.30 0.05 270);
- --muted: oklch(0.94 0.01 270 / 50%);
- --muted-foreground: oklch(0.50 0.02 270);
- --accent: oklch(0.90 0.05 280 / 40%);
- --accent-foreground: oklch(0.30 0.08 270);
+ --secondary: oklch(0.94 0.005 90 / 60%);
+ --secondary-foreground: oklch(0.25 0.005 90);
+ --muted: oklch(0.94 0.003 90 / 50%);
+ --muted-foreground: oklch(0.50 0.008 90);
+ --accent: oklch(0.92 0.01 80 / 40%);
+ --accent-foreground: oklch(0.25 0.01 90);
--destructive: oklch(0.58 0.22 27);
- --border: oklch(0.80 0.02 270 / 30%);
- --input: oklch(0.92 0.01 270 / 50%);
- --ring: oklch(0.55 0.20 264);
- --chart-1: oklch(0.65 0.20 264);
- --chart-2: oklch(0.60 0.18 280);
- --chart-3: oklch(0.55 0.22 300);
- --chart-4: oklch(0.70 0.15 250);
- --chart-5: oklch(0.50 0.20 270);
+ --border: oklch(0.82 0.005 90 / 30%);
+ --input: oklch(0.93 0.003 90 / 50%);
+ --ring: oklch(0.35 0.005 90);
+ --chart-1: oklch(0.55 0.12 160);
+ --chart-2: oklch(0.60 0.10 80);
+ --chart-3: oklch(0.50 0.08 45);
+ --chart-4: oklch(0.65 0.06 200);
+ --chart-5: oklch(0.45 0.10 140);
--radius: 0.75rem;
--sidebar: oklch(1 0 0 / 50%);
- --sidebar-foreground: oklch(0.20 0.02 270);
- --sidebar-primary: oklch(0.488 0.243 264);
+ --sidebar-foreground: oklch(0.20 0.005 90);
+ --sidebar-primary: oklch(0.20 0.005 90);
--sidebar-primary-foreground: oklch(0.99 0 0);
- --sidebar-accent: oklch(0.90 0.06 270 / 40%);
- --sidebar-accent-foreground: oklch(0.25 0.06 270);
- --sidebar-border: oklch(0.80 0.02 270 / 25%);
- --sidebar-ring: oklch(0.55 0.20 264);
+ --sidebar-accent: oklch(0.92 0.005 90 / 40%);
+ --sidebar-accent-foreground: oklch(0.25 0.005 90);
+ --sidebar-border: oklch(0.82 0.005 90 / 25%);
+ --sidebar-ring: oklch(0.35 0.005 90);
}
.dark {
- --background: oklch(0.14 0.02 270);
- --foreground: oklch(0.93 0.01 270);
+ --background: oklch(0.14 0.005 90);
+ --foreground: oklch(0.93 0.005 90);
--card: oklch(1 0 0 / 6%);
- --card-foreground: oklch(0.93 0.01 270);
- --popover: oklch(0.20 0.02 270 / 85%);
- --popover-foreground: oklch(0.93 0.01 270);
- --primary: oklch(0.65 0.20 264);
- --primary-foreground: oklch(0.99 0 0);
+ --card-foreground: oklch(0.93 0.005 90);
+ --popover: oklch(0.20 0.005 90 / 85%);
+ --popover-foreground: oklch(0.93 0.005 90);
+ --primary: oklch(0.88 0.005 90);
+ --primary-foreground: oklch(0.14 0.005 90);
--secondary: oklch(1 0 0 / 8%);
- --secondary-foreground: oklch(0.90 0.01 270);
+ --secondary-foreground: oklch(0.90 0.005 90);
--muted: oklch(1 0 0 / 6%);
- --muted-foreground: oklch(0.65 0.02 270);
- --accent: oklch(0.50 0.15 270 / 20%);
- --accent-foreground: oklch(0.90 0.02 270);
+ --muted-foreground: oklch(0.65 0.005 90);
+ --accent: oklch(0.50 0.01 80 / 20%);
+ --accent-foreground: oklch(0.90 0.005 90);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 10%);
- --ring: oklch(0.65 0.20 264);
- --chart-1: oklch(0.70 0.18 264);
- --chart-2: oklch(0.65 0.16 280);
- --chart-3: oklch(0.60 0.20 300);
- --chart-4: oklch(0.75 0.12 250);
- --chart-5: oklch(0.55 0.18 270);
+ --ring: oklch(0.70 0.005 90);
+ --chart-1: oklch(0.60 0.10 160);
+ --chart-2: oklch(0.65 0.08 80);
+ --chart-3: oklch(0.55 0.06 45);
+ --chart-4: oklch(0.70 0.05 200);
+ --chart-5: oklch(0.50 0.08 140);
--sidebar: oklch(1 0 0 / 5%);
- --sidebar-foreground: oklch(0.90 0.01 270);
- --sidebar-primary: oklch(0.65 0.20 264);
- --sidebar-primary-foreground: oklch(0.99 0 0);
+ --sidebar-foreground: oklch(0.90 0.005 90);
+ --sidebar-primary: oklch(0.88 0.005 90);
+ --sidebar-primary-foreground: oklch(0.14 0.005 90);
--sidebar-accent: oklch(1 0 0 / 8%);
- --sidebar-accent-foreground: oklch(0.90 0.02 270);
+ --sidebar-accent-foreground: oklch(0.90 0.005 90);
--sidebar-border: oklch(1 0 0 / 8%);
- --sidebar-ring: oklch(0.65 0.20 264);
+ --sidebar-ring: oklch(0.70 0.005 90);
}
@layer base {
@@ -130,13 +130,13 @@
@layer utilities {
.glass {
- backdrop-filter: blur(20px) saturate(1.8);
- -webkit-backdrop-filter: blur(20px) saturate(1.8);
+ backdrop-filter: blur(20px) saturate(1.6);
+ -webkit-backdrop-filter: blur(20px) saturate(1.6);
}
.glass-card {
- backdrop-filter: blur(24px) saturate(1.8);
- -webkit-backdrop-filter: blur(24px) saturate(1.8);
+ backdrop-filter: blur(24px) saturate(1.6);
+ -webkit-backdrop-filter: blur(24px) saturate(1.6);
background: oklch(1 0 0 / 55%);
border: 1px solid oklch(1 0 0 / 30%);
box-shadow:
@@ -154,22 +154,18 @@
inset 0 1px 0 oklch(1 0 0 / 5%);
}
- .glass-sidebar {
+ .glass-panel {
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
background: oklch(1 0 0 / 60%);
- border-right: 1px solid oklch(1 0 0 / 30%);
- box-shadow:
- 4px 0 24px oklch(0 0 0 / 4%),
- inset -1px 0 0 oklch(1 0 0 / 20%);
+ border-bottom: 1px solid oklch(1 0 0 / 30%);
+ box-shadow: 0 1px 8px oklch(0 0 0 / 4%);
}
- .dark .glass-sidebar {
+ .dark .glass-panel {
background: oklch(1 0 0 / 4%);
- border-right-color: oklch(1 0 0 / 8%);
- box-shadow:
- 4px 0 24px oklch(0 0 0 / 20%),
- inset -1px 0 0 oklch(1 0 0 / 3%);
+ border-bottom-color: oklch(1 0 0 / 8%);
+ box-shadow: 0 1px 8px oklch(0 0 0 / 20%);
}
.glass-input {
@@ -186,24 +182,24 @@
.gradient-mesh {
background:
- radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.80 0.12 264 / 15%) 0%, transparent 60%),
- radial-gradient(ellipse 60% 40% at 80% 80%, oklch(0.75 0.15 300 / 12%) 0%, transparent 50%),
- radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.85 0.08 250 / 8%) 0%, transparent 60%);
+ radial-gradient(ellipse 80% 50% at 15% 15%, oklch(0.85 0.04 80 / 18%) 0%, transparent 60%),
+ radial-gradient(ellipse 60% 40% at 85% 75%, oklch(0.80 0.04 140 / 12%) 0%, transparent 50%),
+ radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.90 0.02 60 / 10%) 0%, transparent 60%);
}
.dark .gradient-mesh {
background:
- radial-gradient(ellipse 80% 50% at 20% 20%, oklch(0.35 0.15 264 / 20%) 0%, transparent 60%),
- radial-gradient(ellipse 60% 40% at 80% 80%, oklch(0.30 0.18 300 / 15%) 0%, transparent 50%),
- radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.25 0.10 250 / 10%) 0%, transparent 60%);
+ radial-gradient(ellipse 80% 50% at 15% 15%, oklch(0.30 0.03 80 / 20%) 0%, transparent 60%),
+ radial-gradient(ellipse 60% 40% at 85% 75%, oklch(0.25 0.04 140 / 15%) 0%, transparent 50%),
+ radial-gradient(ellipse 50% 60% at 50% 50%, oklch(0.22 0.02 60 / 10%) 0%, transparent 60%);
}
.gradient-banner {
- background: linear-gradient(135deg, oklch(0.488 0.243 264) 0%, oklch(0.55 0.22 285) 50%, oklch(0.50 0.20 300) 100%);
+ background: linear-gradient(135deg, oklch(0.20 0.005 90) 0%, oklch(0.28 0.01 80) 50%, oklch(0.22 0.008 100) 100%);
}
.gradient-stat {
- background: linear-gradient(135deg, oklch(1 0 0 / 70%) 0%, oklch(0.97 0.01 270 / 50%) 100%);
+ background: linear-gradient(135deg, oklch(1 0 0 / 70%) 0%, oklch(0.97 0.003 90 / 50%) 100%);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
border: 1px solid oklch(1 0 0 / 40%);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 3823ba8..8748872 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,17 +1,13 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Quicksand } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
-import { Sidebar } from "@/components/layout/sidebar";
+import { TopBar } from "@/components/layout/top-bar";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const quicksand = Quicksand({
+ variable: "--font-sans",
subsets: ["latin"],
+ weight: ["300", "400", "500", "600", "700"],
});
export const metadata: Metadata = {
@@ -26,15 +22,15 @@ export default function RootLayout({
}>) {
return (
-
+
-
+
-
-
- {children}
+
+
+
+ {children}
+
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 55c1afd..6055b08 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,32 +1,27 @@
import { Suspense } from "react";
import { DashboardContent } from "@/components/cards/dashboard-content";
-import { DashboardHero } from "@/components/cards/dashboard-hero";
-import { StatCards } from "@/components/cards/stat-cards";
-import { QuickActions } from "@/components/cards/quick-actions";
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardPage() {
return (
-
-
-
-
-
-
-
- All Response Cards
-
-
-
-
-
- }
- >
-
-
-
-
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ }
+ >
+
+
);
}
diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx
deleted file mode 100644
index 0418f31..0000000
--- a/src/app/upload/page.tsx
+++ /dev/null
@@ -1,341 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { toast } from "sonner";
-import {
- Upload,
- FileText,
- Image as ImageIcon,
- X,
- Loader2,
- CheckCircle,
- AlertCircle,
- FolderOpen,
- RefreshCw,
-} from "lucide-react";
-
-import { Header } from "@/components/layout/header";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { cn } from "@/lib/utils";
-
-type ProcessingJob = {
- id: string;
- fileName: string;
- status: string;
- totalPages: number;
- processed: number;
- error: string | null;
- createdAt: string;
-};
-
-export default function UploadPage() {
- const [files, setFiles] = React.useState([]);
- const [uploading, setUploading] = React.useState(false);
- const [jobs, setJobs] = React.useState([]);
- const [dragOver, setDragOver] = React.useState(false);
- const [retryingJobId, setRetryingJobId] = React.useState(null);
- const fileInputRef = React.useRef(null);
-
- const fetchJobs = React.useCallback(async () => {
- try {
- const res = await fetch("/api/jobs?limit=20");
- if (res.ok) {
- const data = await res.json();
- setJobs(data.jobs);
- }
- } catch {
- /* ignore */
- }
- }, []);
-
- React.useEffect(() => {
- fetchJobs();
- const interval = setInterval(fetchJobs, 3000);
- return () => clearInterval(interval);
- }, [fetchJobs]);
-
- const handleDrop = (e: React.DragEvent) => {
- e.preventDefault();
- setDragOver(false);
- const dropped = Array.from(e.dataTransfer.files).filter(
- (f) =>
- f.type === "application/pdf" ||
- f.type.startsWith("image/")
- );
- setFiles((prev) => [...prev, ...dropped]);
- };
-
- const handleFileSelect = (e: React.ChangeEvent) => {
- const selected = Array.from(e.target.files || []);
- setFiles((prev) => [...prev, ...selected]);
- if (fileInputRef.current) fileInputRef.current.value = "";
- };
-
- const removeFile = (index: number) => {
- setFiles((prev) => prev.filter((_, i) => i !== index));
- };
-
- const handleUpload = async () => {
- if (files.length === 0) return;
- setUploading(true);
- try {
- const formData = new FormData();
- files.forEach((f) => formData.append("files", f));
-
- const res = await fetch("/api/upload", {
- method: "POST",
- body: formData,
- });
-
- if (!res.ok) {
- const err = await res.json();
- throw new Error(err.error || "Upload failed");
- }
-
- const data = await res.json();
- toast.success(`Uploaded ${files.length} file(s). Processing ${data.jobIds.length} job(s).`);
- setFiles([]);
- fetchJobs();
- } catch (err) {
- toast.error(err instanceof Error ? err.message : "Upload failed");
- } finally {
- setUploading(false);
- }
- };
-
- const getStatusIcon = (status: string) => {
- switch (status) {
- case "queued":
- return ;
- case "processing":
- return ;
- case "complete":
- return ;
- case "error":
- return ;
- default:
- return null;
- }
- };
-
- const handleRetryJob = async (jobId: string) => {
- setRetryingJobId(jobId);
- try {
- const res = await fetch(`/api/jobs/${jobId}/reprocess`, { method: "POST" });
- if (!res.ok) {
- const err = await res.json();
- throw new Error(err.error || "Failed to retry job");
- }
- toast.success("Job reprocessing started");
- fetchJobs();
- } catch (err) {
- toast.error(err instanceof Error ? err.message : "Failed to retry job");
- } finally {
- setRetryingJobId(null);
- }
- };
-
- return (
-
-
-
-
-
- {
- e.preventDefault();
- setDragOver(true);
- }}
- onDragLeave={() => setDragOver(false)}
- onDrop={handleDrop}
- className={cn(
- "flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-8 sm:p-12 text-center transition-all",
- dragOver
- ? "border-primary bg-primary/5 scale-[1.01]"
- : "border-muted-foreground/20 hover:border-primary/40 hover:bg-primary/[2%]"
- )}
- >
-
-
-
-
- Drop files here or click to browse
-
-
- Supports PDF, JPEG, PNG, and WebP files
-
-
-
-
-
- {files.length > 0 && (
-
-
-
- {files.length} file{files.length !== 1 ? "s" : ""} selected
-
-
-
-
-
-
-
- {files.map((file, i) => (
-
- {file.type === "application/pdf" ? (
-
- ) : (
-
- )}
- {file.name}
-
- {(file.size / 1024).toFixed(0)} KB
-
-
-
- ))}
-
-
- )}
-
-
-
-
-
-
-
- Processing Queue
-
-
-
- {jobs.length === 0 ? (
-
- No processing jobs yet. Upload files to get started.
-
- ) : (
-
- {jobs.map((job) => (
-
-
- {getStatusIcon(job.status)}
-
-
-
- {job.fileName}
-
-
- {job.status}
-
-
- {job.totalPages > 0 && (
-
-
-
- {job.processed}/{job.totalPages}
-
-
- )}
- {job.error && (
-
{job.error}
- )}
-
-
-
- {job.status === "error" && (
-
- )}
-
- {new Date(job.createdAt).toLocaleTimeString()}
-
-
-
- ))}
-
- )}
-
-
-
- );
-}
diff --git a/src/components/cards/dashboard-content.tsx b/src/components/cards/dashboard-content.tsx
index d5a4172..271d3ed 100644
--- a/src/components/cards/dashboard-content.tsx
+++ b/src/components/cards/dashboard-content.tsx
@@ -3,8 +3,12 @@
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
-import { DataTable } from "./data-table";
+
+import { StatCards, type StatFilter } from "./stat-cards";
import { Filters } from "./filters";
+import { DataTable } from "./data-table";
+import { SelectionToolbar } from "./selection-toolbar";
+import { UploadModal, type UploadingFile } from "./upload-modal";
import { createColumns, type ResponseCard } from "./columns";
const VISIT_TYPE_OPTIONS = [
@@ -35,6 +39,11 @@ export function DashboardContent() {
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [columnVisibility, setColumnVisibility] = React.useState>({});
+ const [selectedIds, setSelectedIds] = React.useState([]);
+ const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
+ const [uploadingRows, setUploadingRows] = React.useState([]);
+ const [activeStatFilter, setActiveStatFilter] = React.useState(null);
+ const [globalDragOver, setGlobalDragOver] = React.useState(false);
const fetchCards = React.useCallback(async () => {
setLoading(true);
@@ -71,13 +80,42 @@ export function DashboardContent() {
fetchCards();
}, [fetchCards]);
- const updateUrl = (updates: Record) => {
- const params = new URLSearchParams(searchParams.toString());
- for (const [key, value] of Object.entries(updates)) {
- params.set(key, String(value));
- }
- router.push(`/?${params.toString()}`);
- };
+ const updateUrl = React.useCallback(
+ (updates: Record) => {
+ const params = new URLSearchParams(searchParams.toString());
+ for (const [key, value] of Object.entries(updates)) {
+ if (value === undefined || value === "") {
+ params.delete(key);
+ } else {
+ params.set(key, String(value));
+ }
+ }
+ router.push(`/?${params.toString()}`);
+ },
+ [router, searchParams]
+ );
+
+ const handleStatFilterChange = React.useCallback(
+ (filter: StatFilter) => {
+ setActiveStatFilter(filter);
+ const params = new URLSearchParams(searchParams.toString());
+
+ params.delete("ocrStatus");
+ params.delete("reviewStatus");
+
+ if (filter === "complete") {
+ params.set("ocrStatus", "complete");
+ } else if (filter === "error") {
+ params.set("ocrStatus", "error");
+ } else if (filter === "unreviewed") {
+ params.set("reviewStatus", "unreviewed");
+ }
+
+ params.set("page", "1");
+ router.push(`/?${params.toString()}`);
+ },
+ [router, searchParams]
+ );
const handleBulkAction = async (
ids: string[],
@@ -114,20 +152,20 @@ export function DashboardContent() {
}
const headers = [
- "Name", "Email", "Phone", "City", "State", "Gender", "DOB",
+ "Name", "Email", "Phone", "City", "State", "Gender",
"Visit Type", "Attendance", "Service", "OCR Status", "Review Status",
"Confidence",
];
const rows = data.map((c) => [
c.name || "", c.email || "", c.cellPhone || "", c.city || "",
- c.state || "", c.gender || "", "", c.visitType || "",
+ c.state || "", c.gender || "", c.visitType || "",
c.attendanceDuration || "", c.serviceAttended || "", c.ocrStatus,
c.reviewStatus, c.ocrConfidence?.toString() || "",
]);
- const csv = [headers, ...rows].map((r) =>
- r.map((v) => `"${v.replace(/"/g, '""')}"`).join(",")
- ).join("\n");
+ const csv = [headers, ...rows]
+ .map((r) => r.map((v) => `"${v.replace(/"/g, '""')}"`).join(","))
+ .join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
@@ -161,16 +199,110 @@ export function DashboardContent() {
[router, fetchCards]
);
+ const handleUploadStart = (files: UploadingFile[]) => {
+ setUploadingRows((prev) => [...prev, ...files]);
+ };
+
+ const handleUploadProgress = (fileId: string, progress: number) => {
+ setUploadingRows((prev) =>
+ prev.map((f) => (f.id === fileId ? { ...f, progress } : f))
+ );
+ };
+
+ const handleUploadComplete = (fileId: string) => {
+ setUploadingRows((prev) => prev.filter((f) => f.id !== fileId));
+ fetchCards();
+ };
+
+ const handleUploadError = (fileId: string, error: string) => {
+ setUploadingRows((prev) =>
+ prev.map((f) =>
+ f.id === fileId ? { ...f, status: "error" as const, error } : f
+ )
+ );
+ };
+
+ React.useEffect(() => {
+ const handleOpenUpload = () => setUploadModalOpen(true);
+ window.addEventListener("open-upload-modal", handleOpenUpload);
+ return () => window.removeEventListener("open-upload-modal", handleOpenUpload);
+ }, []);
+
+ React.useEffect(() => {
+ const handleDragOver = (e: DragEvent) => {
+ e.preventDefault();
+ if (e.dataTransfer?.types.includes("Files")) {
+ setGlobalDragOver(true);
+ }
+ };
+ const handleDragLeave = (e: DragEvent) => {
+ if (e.relatedTarget === null) {
+ setGlobalDragOver(false);
+ }
+ };
+ const handleDrop = (e: DragEvent) => {
+ e.preventDefault();
+ setGlobalDragOver(false);
+ if (e.dataTransfer?.files.length) {
+ setUploadModalOpen(true);
+ }
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+ document.addEventListener("dragleave", handleDragLeave);
+ document.addEventListener("drop", handleDrop);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ document.removeEventListener("dragleave", handleDragLeave);
+ document.removeEventListener("drop", handleDrop);
+ };
+ }, []);
+
+ const openUpload = React.useCallback(() => {
+ setUploadModalOpen(true);
+ }, []);
+
return (
+ {/* Global drag overlay */}
+ {globalDragOver && (
+
+
+
Drop files to upload
+
+ PDF, JPEG, PNG, WebP
+
+
+
+ )}
+
+ {/* Page title */}
+
+
+ Response Cards
+
+
+ Manage and review scanned response cards
+
+
+
+ {/* Stat cards as filters */}
+
+
+ {/* Filter toolbar */}
+
+ {/* Data table */}
updateUrl({ page: p })}
onLimitChange={(l) => updateUrl({ limit: l, page: 1 })}
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
- onBulkMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
- onBulkMarkExported={(ids) => handleBulkAction(ids, "exported")}
- onBulkDelete={(ids) => handleBulkAction(ids, "delete")}
+ uploadingRows={uploadingRows.filter((f) => f.status === "uploading")}
+ onSelectionChange={setSelectedIds}
/>
+
+ {/* Floating selection toolbar */}
+
handleBulkAction(ids, "reviewed")}
+ onMarkExported={(ids) => handleBulkAction(ids, "exported")}
+ onDelete={(ids) => handleBulkAction(ids, "delete")}
+ onClear={() => setSelectedIds([])}
+ />
+
+ {/* Upload modal */}
+
);
}
diff --git a/src/components/cards/dashboard-hero.tsx b/src/components/cards/dashboard-hero.tsx
deleted file mode 100644
index c6f7890..0000000
--- a/src/components/cards/dashboard-hero.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { Upload, ScanLine } from "lucide-react";
-
-export function DashboardHero() {
- return (
-
-
-
-
-
- Echo OCR
-
-
- Document Processing Center
-
-
- Upload your response cards and let AI-powered OCR extract data with high accuracy.
-
-
-
-
- Upload Documents
-
-
-
-
- );
-}
diff --git a/src/components/cards/data-table.tsx b/src/components/cards/data-table.tsx
index 1157614..b82e5dd 100644
--- a/src/components/cards/data-table.tsx
+++ b/src/components/cards/data-table.tsx
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
+import { useRouter } from "next/navigation";
import {
type ColumnDef,
type SortingState,
@@ -15,9 +16,7 @@ import {
import {
ChevronLeft,
ChevronRight,
- CheckCircle,
- Download,
- Trash2,
+ Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -29,7 +28,31 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
import type { ResponseCard } from "./columns";
+import type { UploadingFile } from "./upload-modal";
+
+const TOGGLEABLE_COLUMNS = [
+ { id: "thumbnail", label: "Thumbnail" },
+ { id: "name", label: "Name" },
+ { id: "email", label: "Email" },
+ { id: "cellPhone", label: "Phone" },
+ { id: "location", label: "Location" },
+ { id: "visitType", label: "Visit Type" },
+ { id: "attendanceDuration", label: "Attendance" },
+ { id: "serviceAttended", label: "Service" },
+ { id: "ocrStatus", label: "OCR Status" },
+ { id: "reviewStatus", label: "Review" },
+ { id: "ocrConfidence", label: "Confidence" },
+] as const;
type DataTableProps = {
data: TData[];
@@ -39,14 +62,13 @@ type DataTableProps = {
limit: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
- onBulkMarkReviewed?: (ids: string[]) => void;
- onBulkMarkExported?: (ids: string[]) => void;
- onBulkDelete?: (ids: string[]) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
sortBy?: string;
sortOrder?: "asc" | "desc";
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
+ uploadingRows?: UploadingFile[];
+ onSelectionChange?: (selectedIds: string[]) => void;
};
const PAGE_SIZES = [10, 20, 50, 100];
@@ -59,15 +81,16 @@ export function DataTable({
limit,
onPageChange,
onLimitChange,
- onBulkMarkReviewed,
- onBulkMarkExported,
- onBulkDelete,
columnVisibility: controlledVisibility,
onColumnVisibilityChange,
sortBy: controlledSortBy,
sortOrder: controlledSortOrder,
onSortChange,
+ uploadingRows = [],
+ onSelectionChange,
}: DataTableProps) {
+ const router = useRouter();
+
const sorting: SortingState =
controlledSortBy && controlledSortOrder
? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }]
@@ -93,6 +116,11 @@ export function DataTable({
const setVisibility =
onColumnVisibilityChange ?? setInternalVisibility;
+ const [contextMenu, setContextMenu] = React.useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
const table = useReactTable({
data,
columns,
@@ -122,8 +150,14 @@ export function DataTable({
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
- const selectedCount = selectedRows.length;
- const selectedIds = selectedRows.map((r) => r.original.id);
+ const selectedIds = React.useMemo(
+ () => selectedRows.map((r) => r.original.id),
+ [selectedRows]
+ );
+
+ React.useEffect(() => {
+ onSelectionChange?.(selectedIds);
+ }, [selectedIds, onSelectionChange]);
const totalPages = Math.ceil(totalCount / limit);
const canPrev = page > 1;
@@ -131,67 +165,54 @@ export function DataTable({
const start = (page - 1) * limit + 1;
const end = Math.min(page * limit, totalCount);
+ const handleRowClick = (e: React.MouseEvent, rowId: string) => {
+ const target = e.target as HTMLElement;
+ if (
+ target.closest("button") ||
+ target.closest("input") ||
+ target.closest('[data-slot="checkbox"]') ||
+ target.closest('[data-slot="dropdown-menu"]')
+ ) {
+ return;
+ }
+ router.push(`/cards/${rowId}`);
+ };
+
+ const handleHeaderContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setContextMenu({ x: e.clientX, y: e.clientY });
+ };
+
+ React.useEffect(() => {
+ if (!contextMenu) return;
+ const close = () => setContextMenu(null);
+ window.addEventListener("click", close);
+ window.addEventListener("contextmenu", close);
+ return () => {
+ window.removeEventListener("click", close);
+ window.removeEventListener("contextmenu", close);
+ };
+ }, [contextMenu]);
+
+ const isColumnVisible = (id: string) => visibility[id] !== false;
+ const toggleColumn = (id: string, visible: boolean) => {
+ setVisibility({ ...visibility, [id]: visible });
+ };
+
return (
- {selectedCount > 0 && (
-
-
- {selectedCount} row{selectedCount !== 1 ? "s" : ""} selected
-
-
- {onBulkMarkReviewed && (
-
- )}
- {onBulkMarkExported && (
-
- )}
- {onBulkDelete && (
-
- )}
-
-
-
- )}
-
-
+ {/* Desktop table */}
+
-
+
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => (
-
+
{header.isPlaceholder
? null
: flexRender(
@@ -204,12 +225,45 @@ export function DataTable({
))}
+ {uploadingRows.map((uf) => (
+
+
+
+
+
+ {uf.name}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
handleRowClick(e, row.original.id)}
>
{row.getVisibleCells().map((cell) => (
@@ -222,20 +276,120 @@ export function DataTable({
))
) : (
-
-
- No results.
-
-
+ uploadingRows.length === 0 && (
+
+
+ No results.
+
+
+ )
)}
+ {/* Mobile card layout */}
+
+ {uploadingRows.map((uf) => (
+
+ ))}
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
handleRowClick(e, row.original.id)}
+ >
+
+
+
+ {row.original.name ?? "Unnamed"}
+
+ {row.original.email && (
+
+ {row.original.email}
+
+ )}
+
+
+
+ {row.original.ocrStatus}
+
+
+
+
+ {row.original.cellPhone && (
+ {row.original.cellPhone}
+ )}
+ {row.original.visitType && (
+ {row.original.visitType}
+ )}
+ {row.original.ocrConfidence != null && (
+ = 75
+ ? "text-green-600 dark:text-green-400"
+ : row.original.ocrConfidence >= 50
+ ? "text-amber-600 dark:text-amber-400"
+ : "text-red-600 dark:text-red-400"
+ )}
+ >
+ {Math.round(row.original.ocrConfidence)}%
+
+ )}
+
+
+ ))
+ ) : (
+ uploadingRows.length === 0 && (
+
+ No results.
+
+ )
+ )}
+
+
+ {/* Pagination */}
@@ -306,6 +460,39 @@ export function DataTable({
+
+ {/* Right-click column visibility context menu */}
+ {contextMenu && (
+
{
+ if (!open) setContextMenu(null);
+ }}
+ >
+
+ Toggle Columns
+
+ {TOGGLEABLE_COLUMNS.map((col) => (
+
+ toggleColumn(col.id, checked !== false)
+ }
+ >
+ {col.label}
+
+ ))}
+
+
+ )}
);
}
diff --git a/src/components/cards/filters.tsx b/src/components/cards/filters.tsx
index 776905c..aba1e1f 100644
--- a/src/components/cards/filters.tsx
+++ b/src/components/cards/filters.tsx
@@ -2,16 +2,9 @@
import * as React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { Search, Download, Columns3, SlidersHorizontal } from "lucide-react";
+import { Search, Download, Upload } from "lucide-react";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -20,72 +13,35 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { cn } from "@/lib/utils";
-
-const REVIEW_OPTIONS = [
- { value: "", label: "All" },
- { value: "unreviewed", label: "Unreviewed" },
- { value: "reviewed", label: "Reviewed" },
- { value: "exported", label: "Exported" },
-] as const;
-
-const OCR_OPTIONS = [
- { value: "", label: "All" },
- { value: "complete", label: "Complete" },
- { value: "error", label: "Error" },
- { value: "pending", label: "Pending" },
-] as const;
-
-const TOGGLEABLE_COLUMNS = [
- { id: "thumbnail", label: "Thumbnail" },
- { id: "name", label: "Name" },
- { id: "email", label: "Email" },
- { id: "cellPhone", label: "Phone" },
- { id: "location", label: "Location" },
- { id: "visitType", label: "Visit Type" },
- { id: "attendanceDuration", label: "Attendance" },
- { id: "serviceAttended", label: "Service" },
- { id: "ocrStatus", label: "OCR Status" },
- { id: "reviewStatus", label: "Review" },
- { id: "ocrConfidence", label: "Confidence" },
-] as const;
export type FiltersProps = {
search?: string;
- reviewStatus?: string;
- ocrStatus?: string;
visitType?: string;
attendanceDuration?: string;
serviceAttended?: string;
visitTypeOptions?: string[];
attendanceDurationOptions?: string[];
serviceAttendedOptions?: string[];
- columnVisibility?: Record
;
- onColumnVisibilityChange?: (visibility: Record) => void;
onExportCsv?: () => void;
+ onUploadClick?: () => void;
};
export function Filters({
search: initialSearch = "",
- reviewStatus: initialReviewStatus = "",
- ocrStatus: initialOcrStatus = "",
visitType: initialVisitType = "",
attendanceDuration: initialAttendanceDuration = "",
serviceAttended: initialServiceAttended = "",
visitTypeOptions = [],
attendanceDurationOptions = [],
serviceAttendedOptions = [],
- columnVisibility = {},
- onColumnVisibilityChange,
onExportCsv,
+ onUploadClick,
}: FiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const search = searchParams.get("search") ?? initialSearch;
- const reviewStatus = searchParams.get("reviewStatus") ?? initialReviewStatus;
- const ocrStatus = searchParams.get("ocrStatus") ?? initialOcrStatus;
const visitType = searchParams.get("visitType") ?? initialVisitType;
const attendanceDuration =
searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
@@ -113,89 +69,32 @@ export function Filters({
updateParams({ search: v || undefined });
};
- const handleSearchSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- };
-
- const isColumnVisible = (id: string) => columnVisibility[id] !== false;
-
- const toggleColumn = (id: string, visible: boolean) => {
- onColumnVisibilityChange?.({
- ...columnVisibility,
- [id]: visible,
- });
- };
-
return (
-
-
-
+
+
+
+
+
+
-
+
+
+
-
-
-
-
- }
- >
-
- Columns
-
-
- {TOGGLEABLE_COLUMNS.map((col) => (
-
- toggleColumn(col.id, checked !== false)
- }
- >
- {col.label}
-
- ))}
-
-
- {onExportCsv && (
-
- )}
-
+
+
+ {onExportCsv && (
+
+ )}
+ {onUploadClick && (
+
+ )}
+
);
}
diff --git a/src/components/cards/quick-actions.tsx b/src/components/cards/quick-actions.tsx
deleted file mode 100644
index 505999d..0000000
--- a/src/components/cards/quick-actions.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { Upload, FileDown, Clock, Settings } from "lucide-react";
-import { cn } from "@/lib/utils";
-
-const actions = [
- {
- label: "Upload Documents",
- description: "PDF, JPG, PNG formats",
- href: "/upload",
- icon: Upload,
- accent: "text-primary bg-primary/10",
- },
- {
- label: "Export Results",
- description: "TXT, DOCX, CSV formats",
- href: "/#",
- icon: FileDown,
- accent: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400",
- },
- {
- label: "Recent Files",
- description: "View processing history",
- href: "/",
- icon: Clock,
- accent: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
- },
- {
- label: "Settings",
- description: "Customize your experience",
- href: "/settings",
- icon: Settings,
- accent: "text-violet-600 bg-violet-500/10 dark:text-violet-400",
- },
-];
-
-export function QuickActions() {
- return (
-
- {actions.map((action) => {
- const Icon = action.icon;
- return (
-
-
-
-
-
-
{action.label}
-
{action.description}
-
-
- );
- })}
-
- );
-}
diff --git a/src/components/cards/selection-toolbar.tsx b/src/components/cards/selection-toolbar.tsx
new file mode 100644
index 0000000..ad41359
--- /dev/null
+++ b/src/components/cards/selection-toolbar.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import {
+ CheckCircle,
+ Download,
+ Trash2,
+ X,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface SelectionToolbarProps {
+ selectedIds: string[];
+ onMarkReviewed?: (ids: string[]) => void;
+ onMarkExported?: (ids: string[]) => void;
+ onDelete?: (ids: string[]) => void;
+ onClear: () => void;
+}
+
+export function SelectionToolbar({
+ selectedIds,
+ onMarkReviewed,
+ onMarkExported,
+ onDelete,
+ onClear,
+}: SelectionToolbarProps) {
+ const count = selectedIds.length;
+
+ return (
+
0
+ ? "translate-y-0 opacity-100"
+ : "pointer-events-none translate-y-4 opacity-0"
+ )}
+ >
+
+
+ {count} selected
+
+
+
+ {onMarkReviewed && (
+
+ )}
+ {onMarkExported && (
+
+ )}
+ {onDelete && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/cards/stat-cards.tsx b/src/components/cards/stat-cards.tsx
index 8392784..9bf6686 100644
--- a/src/components/cards/stat-cards.tsx
+++ b/src/components/cards/stat-cards.tsx
@@ -6,8 +6,6 @@ import {
CheckCircle,
AlertTriangle,
Clock,
- TrendingUp,
- TrendingDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -17,16 +15,23 @@ type Stats = {
byReviewStatus: Record
;
};
+export type StatFilter = "all" | "complete" | "error" | "unreviewed" | null;
+
type StatCardData = {
label: string;
value: number;
icon: React.ElementType;
- trend?: string;
- trendUp?: boolean;
+ filterKey: StatFilter;
accentClass: string;
+ activeRing: string;
};
-export function StatCards() {
+interface StatCardsProps {
+ activeFilter: StatFilter;
+ onFilterChange: (filter: StatFilter) => void;
+}
+
+export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
const [stats, setStats] = React.useState(null);
React.useEffect(() => {
@@ -39,10 +44,10 @@ export function StatCards() {
const cards: StatCardData[] = React.useMemo(() => {
if (!stats) {
return [
- { label: "Total Cards", value: 0, icon: FileText, accentClass: "text-primary bg-primary/10" },
- { label: "OCR Complete", value: 0, icon: CheckCircle, accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400" },
- { label: "Errors", value: 0, icon: AlertTriangle, accentClass: "text-red-600 bg-red-500/10 dark:text-red-400" },
- { label: "Pending Review", value: 0, icon: Clock, accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400" },
+ { label: "Total Cards", value: 0, icon: FileText, filterKey: "all", accentClass: "text-primary bg-primary/10", activeRing: "ring-primary/40" },
+ { label: "OCR Complete", value: 0, icon: CheckCircle, filterKey: "complete", accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", activeRing: "ring-emerald-500/40" },
+ { label: "Errors", value: 0, icon: AlertTriangle, filterKey: "error", accentClass: "text-red-600 bg-red-500/10 dark:text-red-400", activeRing: "ring-red-500/40" },
+ { label: "Pending Review", value: 0, icon: Clock, filterKey: "unreviewed", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40" },
];
}
return [
@@ -50,52 +55,70 @@ export function StatCards() {
label: "Total Cards",
value: stats.total,
icon: FileText,
+ filterKey: "all" as StatFilter,
accentClass: "text-primary bg-primary/10",
+ activeRing: "ring-primary/40",
},
{
label: "OCR Complete",
value: stats.byOcrStatus["complete"] || 0,
icon: CheckCircle,
+ filterKey: "complete" as StatFilter,
accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400",
- trendUp: true,
+ activeRing: "ring-emerald-500/40",
},
{
label: "Errors",
value: stats.byOcrStatus["error"] || 0,
icon: AlertTriangle,
+ filterKey: "error" as StatFilter,
accentClass: "text-red-600 bg-red-500/10 dark:text-red-400",
- trendUp: false,
+ activeRing: "ring-red-500/40",
},
{
label: "Pending Review",
value: stats.byReviewStatus["unreviewed"] || 0,
icon: Clock,
+ filterKey: "unreviewed" as StatFilter,
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
+ activeRing: "ring-amber-500/40",
},
];
}, [stats]);
+ const handleClick = (filterKey: StatFilter) => {
+ if (activeFilter === filterKey) {
+ onFilterChange(null);
+ } else {
+ onFilterChange(filterKey);
+ }
+ };
+
return (
{cards.map((card) => {
const Icon = card.icon;
+ const isActive = activeFilter === card.filterKey;
return (
-
handleClick(card.filterKey)}
+ className={cn(
+ "gradient-stat flex flex-col gap-3 rounded-2xl p-4 text-left transition-all sm:p-5",
+ "hover:scale-[1.02] hover:shadow-lg active:scale-[0.98]",
+ isActive && `ring-2 ${card.activeRing} shadow-lg`
+ )}
>
-
+
- {card.trendUp !== undefined && card.value > 0 && (
-
- {card.trendUp ? : }
-
- )}
@@ -105,7 +128,7 @@ export function StatCards() {
{card.label}
-
+
);
})}
diff --git a/src/components/cards/upload-modal.tsx b/src/components/cards/upload-modal.tsx
new file mode 100644
index 0000000..0023e5e
--- /dev/null
+++ b/src/components/cards/upload-modal.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import * as React from "react";
+import { toast } from "sonner";
+import {
+ Upload,
+ FileText,
+ Image as ImageIcon,
+ X,
+ Loader2,
+ CheckCircle,
+ AlertCircle,
+ FolderOpen,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogClose,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+
+export type UploadingFile = {
+ id: string;
+ name: string;
+ size: number;
+ progress: number;
+ status: "uploading" | "complete" | "error";
+ error?: string;
+};
+
+interface UploadModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onUploadStart?: (files: UploadingFile[]) => void;
+ onUploadProgress?: (fileId: string, progress: number) => void;
+ onUploadComplete?: (fileId: string) => void;
+ onUploadError?: (fileId: string, error: string) => void;
+}
+
+export function UploadModal({
+ open,
+ onOpenChange,
+ onUploadStart,
+ onUploadProgress,
+ onUploadComplete,
+ onUploadError,
+}: UploadModalProps) {
+ const [stagedFiles, setStagedFiles] = React.useState
([]);
+ const [uploadingFiles, setUploadingFiles] = React.useState([]);
+ const [dragOver, setDragOver] = React.useState(false);
+ const fileInputRef = React.useRef(null);
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragOver(false);
+ const dropped = Array.from(e.dataTransfer.files).filter(
+ (f) => f.type === "application/pdf" || f.type.startsWith("image/")
+ );
+ if (dropped.length) {
+ setStagedFiles((prev) => [...prev, ...dropped]);
+ }
+ };
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const selected = Array.from(e.target.files || []);
+ setStagedFiles((prev) => [...prev, ...selected]);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ const removeStaged = (index: number) => {
+ setStagedFiles((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const uploadFiles = async () => {
+ if (stagedFiles.length === 0) return;
+
+ const newUploading: UploadingFile[] = stagedFiles.map((f, i) => ({
+ id: `upload-${Date.now()}-${i}`,
+ name: f.name,
+ size: f.size,
+ progress: 0,
+ status: "uploading" as const,
+ }));
+
+ setUploadingFiles((prev) => [...prev, ...newUploading]);
+ onUploadStart?.(newUploading);
+
+ const filesToUpload = [...stagedFiles];
+ setStagedFiles([]);
+
+ for (let i = 0; i < filesToUpload.length; i++) {
+ const file = filesToUpload[i];
+ const uploadEntry = newUploading[i];
+
+ try {
+ const xhr = new XMLHttpRequest();
+ const formData = new FormData();
+ formData.append("files", file);
+
+ await new Promise((resolve, reject) => {
+ xhr.upload.addEventListener("progress", (e) => {
+ if (e.lengthComputable) {
+ const pct = Math.round((e.loaded / e.total) * 100);
+ setUploadingFiles((prev) =>
+ prev.map((uf) =>
+ uf.id === uploadEntry.id ? { ...uf, progress: pct } : uf
+ )
+ );
+ onUploadProgress?.(uploadEntry.id, pct);
+ }
+ });
+
+ xhr.addEventListener("load", () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ setUploadingFiles((prev) =>
+ prev.map((uf) =>
+ uf.id === uploadEntry.id
+ ? { ...uf, progress: 100, status: "complete" }
+ : uf
+ )
+ );
+ onUploadComplete?.(uploadEntry.id);
+ resolve();
+ } else {
+ const errMsg = "Upload failed";
+ setUploadingFiles((prev) =>
+ prev.map((uf) =>
+ uf.id === uploadEntry.id
+ ? { ...uf, status: "error", error: errMsg }
+ : uf
+ )
+ );
+ onUploadError?.(uploadEntry.id, errMsg);
+ reject(new Error(errMsg));
+ }
+ });
+
+ xhr.addEventListener("error", () => {
+ const errMsg = "Network error";
+ setUploadingFiles((prev) =>
+ prev.map((uf) =>
+ uf.id === uploadEntry.id
+ ? { ...uf, status: "error", error: errMsg }
+ : uf
+ )
+ );
+ onUploadError?.(uploadEntry.id, errMsg);
+ reject(new Error(errMsg));
+ });
+
+ xhr.open("POST", "/api/upload");
+ xhr.send(formData);
+ });
+ } catch {
+ // error already handled in XHR callbacks
+ }
+ }
+
+ const completeCount = filesToUpload.length;
+ toast.success(`Uploaded ${completeCount} file(s). Processing started.`);
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ setTimeout(() => {
+ setStagedFiles([]);
+ setUploadingFiles((prev) => prev.filter((f) => f.status === "uploading"));
+ }, 200);
+ };
+
+ const allDone = uploadingFiles.length > 0 && uploadingFiles.every((f) => f.status !== "uploading");
+
+ return (
+
+ );
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
deleted file mode 100644
index d9b0193..0000000
--- a/src/components/layout/sidebar.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useTheme } from "next-themes";
-import {
- LayoutDashboard,
- Upload,
- Settings,
- Moon,
- Sun,
- ScanLine,
- Menu,
-} from "lucide-react";
-import { useState } from "react";
-
-import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/button";
-import {
- Sheet,
- SheetContent,
- SheetTrigger,
-} from "@/components/ui/sheet";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
-const navItems = [
- { href: "/", label: "Dashboard", icon: LayoutDashboard },
- { href: "/upload", label: "Upload", icon: Upload },
- { href: "/settings", label: "Settings", icon: Settings },
-];
-
-function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
- const pathname = usePathname();
-
- return (
-
- );
-}
-
-function ThemeToggle() {
- const { theme, setTheme } = useTheme();
-
- return (
-
- setTheme(theme === "dark" ? "light" : "dark")}
- />
- }
- >
-
-
- Toggle theme
-
-
- {theme === "dark" ? "Switch to light" : "Switch to dark"}
-
-
- );
-}
-
-function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
- return (
- <>
-
-
-
-
-
-
- Echo OCR
-
-
- Document Scanner
-
-
-
-
-
-
-
-
-
-
- {/* Theme icon inline */}
-
-
-
-
Theme
-
-
-
-
- >
- );
-}
-
-export function Sidebar() {
- const [open, setOpen] = useState(false);
-
- return (
- <>
-
-
- }
- >
-
- Open menu
-
-
-
- setOpen(false)} />
-
-
-
-
-
- >
- );
-}
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx
new file mode 100644
index 0000000..67a5c94
--- /dev/null
+++ b/src/components/layout/top-bar.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import Link from "next/link";
+import { useTheme } from "next-themes";
+import {
+ Upload,
+ Bell,
+ Moon,
+ Sun,
+ Settings,
+ LogOut,
+ ScanLine,
+ User,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuLabel,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export function TopBar() {
+ const { theme, setTheme } = useTheme();
+
+ const handleUploadClick = () => {
+ window.dispatchEvent(new CustomEvent("open-upload-modal"));
+ };
+
+ return (
+
+
+
+
+
+ Echo OCR
+
+
+
+
+
+ }
+ >
+
+ Upload
+
+ Upload documents
+
+
+
+
+ }
+ >
+
+
+ Upload documents
+
+
+
+
+ }
+ >
+
+ Notifications
+
+ Notifications
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+ My Account
+
+ }
+ >
+
+ Settings
+
+ setTheme(theme === "dark" ? "light" : "dark")}
+ >
+
+
+ {theme === "dark" ? "Light Mode" : "Dark Mode"}
+
+
+
+
+ Sign Out
+
+
+
+
+
+ );
+}