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} + + + + +
+ +
+
+
+
+ + {uf.progress}% + +
+
+ + + + + + ))} + {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) => ( +
+
+ +
+

{uf.name}

+
+
+
+
+ + {uf.progress}% + +
+
+
+
+ ))} + + {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 ( -
-
-
-
- - -
-
- -
- - Review: - - {REVIEW_OPTIONS.map((opt) => ( - updateParams({ reviewStatus: opt.value || undefined })} - > - {opt.label} - - ))} -
-
-
- - OCR: - - {OCR_OPTIONS.map((opt) => ( - updateParams({ ocrStatus: opt.value || undefined })} - > - {opt.label} - - ))} -
-
- +
+
+
+ + +
-
+
+ + - -
- - - } - > - - 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 ( + + + + + + Upload Documents + + + Drag and drop files or browse to upload. You can close this dialog and uploads will continue in the background. + + + +
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + className={cn( + "flex flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 text-center transition-all", + dragOver + ? "border-primary bg-primary/5 scale-[1.01]" + : "border-muted-foreground/20 hover:border-primary/30" + )} + > +
+ +
+

Drop files here

+

PDF, JPEG, PNG, WebP

+ + +
+ + {(stagedFiles.length > 0 || uploadingFiles.length > 0) && ( +
+ {stagedFiles.map((file, i) => ( +
+ {file.type === "application/pdf" ? ( + + ) : ( + + )} + {file.name} + + {(file.size / 1024).toFixed(0)} KB + + +
+ ))} + + {uploadingFiles.map((uf) => ( +
+ {uf.status === "uploading" && ( + + )} + {uf.status === "complete" && ( + + )} + {uf.status === "error" && ( + + )} + {uf.name} + {uf.status === "uploading" && ( +
+
+
+
+ + {uf.progress}% + +
+ )} + {uf.status === "complete" && ( + Done + )} + {uf.status === "error" && ( + {uf.error} + )} +
+ ))} +
+ )} + + + }> + {allDone ? "Done" : "Cancel"} + + {stagedFiles.length > 0 && ( + + )} + {uploadingFiles.some((f) => f.status === "uploading") && ( + + )} + + +
+ ); +} 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 - -
-
- -
-
- - Menu - -
- -
- -
-
-
-
- {/* 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 + + + +
+
+ ); +}