Switch to Vercel AI Gateway for multi-provider OCR

Replace individual provider SDKs (@ai-sdk/openai, @ai-sdk/google,
@ai-sdk/anthropic) with the Vercel AI Gateway. Cloud models are now
accessed via a single AI_GATEWAY_API_KEY with provider/model strings
(e.g. openai/gpt-4o-mini, google/gemini-2.5-flash). Ollama remains
available as a local fallback via @ai-sdk/openai-compatible.

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-03-11 11:05:38 -05:00
parent 1ec50d9033
commit 2db14b7336
20 changed files with 878 additions and 586 deletions

View file

@ -1,14 +1,11 @@
# Database (shared PostgreSQL on CT 102) # Database (shared PostgreSQL on CT 102)
DATABASE_URL="postgresql://echos_ocr:YOUR_PASSWORD@192.168.68.102:5432/echos_ocr" DATABASE_URL="postgresql://echos_ocr:YOUR_PASSWORD@192.168.68.102:5432/echos_ocr"
# AI Provider API Keys (set the ones you need) # Vercel AI Gateway (routes to OpenAI, Google, Anthropic, etc.)
OPENAI_API_KEY="" AI_GATEWAY_API_KEY=""
GOOGLE_GENERATIVE_AI_API_KEY=""
ANTHROPIC_API_KEY=""
# Ollama (only needed if using Ollama as the AI provider) # Ollama (only needed if using Ollama as the AI provider)
OLLAMA_BASE_URL="http://192.168.68.108:11434" OLLAMA_BASE_URL="http://192.168.68.108:11434"
OLLAMA_MODEL="llava:7b"
# MinIO S3 Storage (CT 105) # MinIO S3 Storage (CT 105)
MINIO_ENDPOINT="192.168.68.105" MINIO_ENDPOINT="192.168.68.105"

51
package-lock.json generated
View file

@ -9,9 +9,6 @@
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "^2.0.35", "@ai-sdk/openai-compatible": "^2.0.35",
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
@ -53,22 +50,6 @@
"typescript": "^5" "typescript": "^5"
} }
}, },
"node_modules/@ai-sdk/anthropic": {
"version": "3.0.58",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.58.tgz",
"integrity": "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": { "node_modules/@ai-sdk/gateway": {
"version": "3.0.66", "version": "3.0.66",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz",
@ -86,38 +67,6 @@
"zod": "^3.25.76 || ^4.1.8" "zod": "^3.25.76 || ^4.1.8"
} }
}, },
"node_modules/@ai-sdk/google": {
"version": "3.0.43",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.43.tgz",
"integrity": "sha512-NGCgP5g8HBxrNdxvF8Dhww+UKfqAkZAmyYBvbu9YLoBkzAmGKDBGhVptN/oXPB5Vm0jggMdoLycZ8JReQM8Zqg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.41",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.41.tgz",
"integrity": "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai-compatible": { "node_modules/@ai-sdk/openai-compatible": {
"version": "2.0.35", "version": "2.0.35",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.35.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.35.tgz",

View file

@ -13,9 +13,6 @@
"postinstall": "npx prisma generate" "postinstall": "npx prisma generate"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "^2.0.35", "@ai-sdk/openai-compatible": "^2.0.35",
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",

View file

@ -83,6 +83,6 @@ model AppSettings {
sourceRetentionDays Int @default(30) sourceRetentionDays Int @default(30)
imageRetentionDays Int @default(180) imageRetentionDays Int @default(180)
aiProvider String @default("ollama") aiProvider String @default("gateway")
aiModel String @default("") aiModel String @default("")
} }

View file

@ -1,46 +1,23 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { generateText } from "ai"; import { generateText } from "ai";
import { openai } from "@ai-sdk/openai"; import { gateway } from "@ai-sdk/gateway";
import { google } from "@ai-sdk/google";
import { anthropic } from "@ai-sdk/anthropic";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai"; import type { LanguageModel } from "ai";
const DEFAULT_MODELS: Record<string, string> = { function getTestModel(provider: string, modelId: string, ollamaUrl: string): LanguageModel {
openai: "gpt-4o-mini", if (provider === "ollama") {
google: "gemini-2.5-flash", const baseURL = (ollamaUrl || process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434") + "/v1";
anthropic: "claude-sonnet-4-20250514", const ollama = createOpenAICompatible({ name: "ollama", baseURL });
ollama: "llava:7b", return ollama.chatModel(modelId || "llava:7b");
};
function getTestModel(
provider: string,
modelId: string,
ollamaUrl: string
): LanguageModel {
const resolvedModel = modelId || DEFAULT_MODELS[provider] || DEFAULT_MODELS.ollama;
switch (provider) {
case "openai":
return openai(resolvedModel);
case "google":
return google(resolvedModel);
case "anthropic":
return anthropic(resolvedModel);
case "ollama": {
const baseURL = (ollamaUrl || process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434") + "/v1";
const ollama = createOpenAICompatible({ name: "ollama", baseURL });
return ollama.chatModel(resolvedModel);
}
default:
throw new Error(`Unknown provider: ${provider}`);
} }
return gateway(modelId || "openai/gpt-4o-mini");
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json().catch(() => ({})); const body = await request.json().catch(() => ({}));
const provider = body.provider || "ollama"; const provider = body.provider || "gateway";
const modelId = body.model || ""; const modelId = body.model || "";
const ollamaUrl = body.ollamaUrl || ""; const ollamaUrl = body.ollamaUrl || "";

View file

@ -4,7 +4,19 @@ import * as React from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { toast } from "sonner"; import { toast } from "sonner";
import { ArrowLeft, ArrowRight, Check, Download, ImageIcon, ChevronDown, ChevronUp } from "lucide-react"; import {
ArrowLeft,
ArrowRight,
Check,
Download,
ImageIcon,
ChevronDown,
ChevronUp,
ScanLine,
User,
ClipboardList,
Code,
} from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -140,10 +152,10 @@ export default function CardDetailPage() {
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-10 w-64" /> <Skeleton className="h-10 w-64 rounded-xl" />
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-4 sm:gap-6 grid-cols-1 lg:grid-cols-2">
<Skeleton className="h-96" /> <Skeleton className="h-96 rounded-2xl" />
<Skeleton className="h-96" /> <Skeleton className="h-96 rounded-2xl" />
</div> </div>
</div> </div>
); );
@ -152,8 +164,8 @@ export default function CardDetailPage() {
if (!card) { if (!card) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header title="Card Not Found" /> <Header title="Card Not Found" icon={ScanLine} />
<Button variant="outline" onClick={() => router.push("/")}> <Button variant="outline" className="rounded-xl" onClick={() => router.push("/")}>
<ArrowLeft className="mr-2 size-4" /> Back to Dashboard <ArrowLeft className="mr-2 size-4" /> Back to Dashboard
</Button> </Button>
</div> </div>
@ -167,32 +179,33 @@ export default function CardDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<Button variant="ghost" size="sm" onClick={() => router.push("/")}> <Button variant="ghost" size="sm" className="w-fit rounded-xl" onClick={() => router.push("/")}>
<ArrowLeft className="mr-1 size-4" /> Back <ArrowLeft className="mr-1 size-4" /> Back
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className={cn( <Badge variant="secondary" className={cn(
"capitalize", "capitalize",
ocrStatus === "complete" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", ocrStatus === "complete" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
ocrStatus === "error" && "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", ocrStatus === "error" && "bg-red-500/10 text-red-700 dark:text-red-300",
ocrStatus === "processing" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", ocrStatus === "processing" && "bg-primary/10 text-primary",
)}> )}>
OCR: {ocrStatus} OCR: {ocrStatus}
</Badge> </Badge>
<Badge variant="secondary" className={cn( <Badge variant="secondary" className={cn(
"capitalize", "capitalize",
reviewStatus === "reviewed" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", reviewStatus === "reviewed" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
reviewStatus === "exported" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", reviewStatus === "exported" && "bg-primary/10 text-primary",
reviewStatus === "unreviewed" && "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300", reviewStatus === "unreviewed" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
)}> )}>
{reviewStatus} {reviewStatus}
</Badge> </Badge>
{confidence != null && ( {confidence != null && (
<Badge variant="outline" className={cn( <Badge variant="outline" className={cn(
confidence < 50 && "text-red-600 border-red-200", "font-medium",
confidence >= 50 && confidence <= 75 && "text-amber-600 border-amber-200", confidence < 50 && "text-red-600 border-red-300 dark:border-red-800",
confidence > 75 && "text-green-600 border-green-200", confidence >= 50 && confidence <= 75 && "text-amber-600 border-amber-300 dark:border-amber-800",
confidence > 75 && "text-emerald-600 border-emerald-300 dark:border-emerald-800",
)}> )}>
{Math.round(confidence)}% confidence {Math.round(confidence)}% confidence
</Badge> </Badge>
@ -200,47 +213,51 @@ export default function CardDetailPage() {
</div> </div>
</div> </div>
<Header title={String(card.name || "Unnamed Card")}> <Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{reviewStatus !== "reviewed" && ( {reviewStatus !== "reviewed" && (
<Button variant="outline" size="sm" onClick={handleMarkReviewed}> <Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
<Check className="mr-1 size-4" /> Mark Reviewed <Check className="mr-1 size-4" /> Mark Reviewed
</Button> </Button>
)} )}
{reviewStatus !== "exported" && ( {reviewStatus !== "exported" && (
<Button variant="outline" size="sm" onClick={handleExport}> <Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
<Download className="mr-1 size-4" /> Export <Download className="mr-1 size-4" /> Export
</Button> </Button>
)} )}
{hasEdits && ( {hasEdits && (
<Button size="sm" onClick={handleSave} disabled={saving}> <Button size="sm" className="rounded-xl" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Changes"} {saving ? "Saving..." : "Save Changes"}
</Button> </Button>
)} )}
</div> </div>
</Header> </Header>
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-4 sm:gap-6 grid-cols-1 xl:grid-cols-2">
{/* Scanned Images */} <Card variant="glass">
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Scanned Images</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<ImageIcon className="size-4 text-primary" />
Scanned Images
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<ImagePanel label="Response Card (Back)" url={card.backImageUrl as string | null} /> <ImagePanel label="Response Card (Back)" url={card.backImageUrl as string | null} />
<ImagePanel label="Survey (Front)" url={card.frontImageUrl as string | null} /> <ImagePanel label="Survey (Front)" url={card.frontImageUrl as string | null} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Personal Info */} <Card variant="glass">
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Personal Information</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<User className="size-4 text-primary" />
Personal Information
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} /> <Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} />
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} /> <Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} />
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} /> <Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} />
@ -250,15 +267,15 @@ export default function CardDetailPage() {
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} /> <SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} />
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} /> <SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} />
</div> </div>
<Separator /> <Separator className="opacity-50" />
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} /> <Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} />
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} /> <Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} />
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} /> <Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} />
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} /> <Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} />
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} /> <Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} />
</div> </div>
<Separator /> <Separator className="opacity-50" />
<div> <div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Prayer Requests</Label> <Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Prayer Requests</Label>
<Textarea <Textarea
@ -270,13 +287,15 @@ export default function CardDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Survey Info */} <Card variant="glass" className="xl:col-span-2">
<Card className="xl:col-span-2">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Survey Responses</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<ClipboardList className="size-4 text-primary" />
Survey Responses
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<div> <div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Message Topics</Label> <Label className="mb-2 block text-xs font-medium text-muted-foreground">Message Topics</Label>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -323,21 +342,23 @@ export default function CardDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Raw OCR Response */}
{card.rawOcrResponse && ( {card.rawOcrResponse && (
<Card className="xl:col-span-2"> <Card variant="glass" className="xl:col-span-2">
<CardHeader> <CardHeader>
<button <button
className="flex w-full items-center justify-between text-left" className="flex w-full items-center justify-between text-left"
onClick={() => setShowRawOcr(!showRawOcr)} onClick={() => setShowRawOcr(!showRawOcr)}
> >
<CardTitle className="text-base">Raw OCR Response</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<Code className="size-4 text-primary" />
Raw OCR Response
</CardTitle>
{showRawOcr ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />} {showRawOcr ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
</button> </button>
</CardHeader> </CardHeader>
{showRawOcr && ( {showRawOcr && (
<CardContent> <CardContent>
<pre className="max-h-96 overflow-auto rounded-lg bg-muted p-4 text-xs"> <pre className="max-h-96 overflow-auto rounded-xl bg-muted/50 p-4 text-xs">
{JSON.stringify(card.rawOcrResponse, null, 2)} {JSON.stringify(card.rawOcrResponse, null, 2)}
</pre> </pre>
</CardContent> </CardContent>
@ -346,16 +367,15 @@ export default function CardDetailPage() {
)} )}
</div> </div>
{/* Navigation */} <div className="flex flex-col gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center justify-between border-t pt-4"> <Button variant="outline" size="sm" className="w-fit rounded-xl" onClick={() => router.push("/")}>
<Button variant="outline" size="sm" onClick={() => router.push("/")}>
<ArrowLeft className="mr-1 size-4" /> All Cards <ArrowLeft className="mr-1 size-4" /> All Cards
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" disabled> <Button variant="outline" size="sm" className="rounded-xl" disabled>
<ArrowLeft className="mr-1 size-4" /> Previous <ArrowLeft className="mr-1 size-4" /> Previous
</Button> </Button>
<Button variant="outline" size="sm" disabled> <Button variant="outline" size="sm" className="rounded-xl" disabled>
Next <ArrowRight className="ml-1 size-4" /> Next <ArrowRight className="ml-1 size-4" />
</Button> </Button>
</div> </div>
@ -369,12 +389,12 @@ function ImagePanel({ label, url }: { label: string; url: string | null }) {
<div> <div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label> <Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
{url ? ( {url ? (
<div className="relative aspect-[3/4] overflow-hidden rounded-lg border bg-muted"> <div className="relative aspect-[3/4] overflow-hidden rounded-xl border border-border/50 bg-muted/30">
<Image src={url} alt={label} fill className="object-contain" unoptimized /> <Image src={url} alt={label} fill className="object-contain" unoptimized />
</div> </div>
) : ( ) : (
<div className="flex aspect-[3/4] items-center justify-center rounded-lg border bg-muted/50"> <div className="flex aspect-[3/4] items-center justify-center rounded-xl border border-border/50 bg-muted/20">
<ImageIcon className="size-12 text-muted-foreground/30" /> <ImageIcon className="size-12 text-muted-foreground/20" />
</div> </div>
)} )}
</div> </div>

View file

@ -48,72 +48,72 @@
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.97 0.005 270);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.17 0.02 270);
--card: oklch(1 0 0); --card: oklch(1 0 0 / 65%);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.17 0.02 270);
--popover: oklch(1 0 0); --popover: oklch(0.99 0.002 270 / 90%);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.17 0.02 270);
--primary: oklch(0.205 0 0); --primary: oklch(0.488 0.243 264);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.93 0.02 270 / 60%);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.30 0.05 270);
--muted: oklch(0.97 0 0); --muted: oklch(0.94 0.01 270 / 50%);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.50 0.02 270);
--accent: oklch(0.97 0 0); --accent: oklch(0.90 0.05 280 / 40%);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.30 0.08 270);
--destructive: oklch(0.58 0.22 27); --destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0); --border: oklch(0.80 0.02 270 / 30%);
--input: oklch(0.922 0 0); --input: oklch(0.92 0.01 270 / 50%);
--ring: oklch(0.708 0 0); --ring: oklch(0.55 0.20 264);
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.65 0.20 264);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.60 0.18 280);
--chart-3: oklch(0.546 0.245 262.881); --chart-3: oklch(0.55 0.22 300);
--chart-4: oklch(0.488 0.243 264.376); --chart-4: oklch(0.70 0.15 250);
--chart-5: oklch(0.424 0.199 265.638); --chart-5: oklch(0.50 0.20 270);
--radius: 0.625rem; --radius: 0.75rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(1 0 0 / 50%);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.20 0.02 270);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.488 0.243 264);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.90 0.06 270 / 40%);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.25 0.06 270);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.80 0.02 270 / 25%);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.55 0.20 264);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.14 0.02 270);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.93 0.01 270);
--card: oklch(0.205 0 0); --card: oklch(1 0 0 / 6%);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.93 0.01 270);
--popover: oklch(0.205 0 0); --popover: oklch(0.20 0.02 270 / 85%);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.93 0.01 270);
--primary: oklch(0.87 0.00 0); --primary: oklch(0.65 0.20 264);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(1 0 0 / 8%);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.90 0.01 270);
--muted: oklch(0.269 0 0); --muted: oklch(1 0 0 / 6%);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.65 0.02 270);
--accent: oklch(0.371 0 0); --accent: oklch(0.50 0.15 270 / 20%);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.90 0.02 270);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0); --ring: oklch(0.65 0.20 264);
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.70 0.18 264);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.65 0.16 280);
--chart-3: oklch(0.546 0.245 262.881); --chart-3: oklch(0.60 0.20 300);
--chart-4: oklch(0.488 0.243 264.376); --chart-4: oklch(0.75 0.12 250);
--chart-5: oklch(0.424 0.199 265.638); --chart-5: oklch(0.55 0.18 270);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(1 0 0 / 5%);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.90 0.01 270);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.65 0.20 264);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(1 0 0 / 8%);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.90 0.02 270);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.65 0.20 264);
} }
@layer base { @layer base {
@ -127,3 +127,98 @@
@apply font-sans; @apply font-sans;
} }
} }
@layer utilities {
.glass {
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
}
.glass-card {
backdrop-filter: blur(24px) saturate(1.8);
-webkit-backdrop-filter: blur(24px) saturate(1.8);
background: oklch(1 0 0 / 55%);
border: 1px solid oklch(1 0 0 / 30%);
box-shadow:
0 1px 2px oklch(0 0 0 / 4%),
0 4px 16px oklch(0 0 0 / 6%),
inset 0 1px 0 oklch(1 0 0 / 50%);
}
.dark .glass-card {
background: oklch(1 0 0 / 5%);
border-color: oklch(1 0 0 / 10%);
box-shadow:
0 1px 2px oklch(0 0 0 / 20%),
0 4px 16px oklch(0 0 0 / 30%),
inset 0 1px 0 oklch(1 0 0 / 5%);
}
.glass-sidebar {
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%);
}
.dark .glass-sidebar {
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%);
}
.glass-input {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: oklch(1 0 0 / 50%);
border: 1px solid oklch(0 0 0 / 8%);
}
.dark .glass-input {
background: oklch(1 0 0 / 6%);
border-color: oklch(1 0 0 / 10%);
}
.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%);
}
.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%);
}
.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%);
}
.gradient-stat {
background: linear-gradient(135deg, oklch(1 0 0 / 70%) 0%, oklch(0.97 0.01 270 / 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%);
box-shadow:
0 1px 3px oklch(0 0 0 / 5%),
0 8px 24px oklch(0 0 0 / 6%),
inset 0 1px 0 oklch(1 0 0 / 60%);
}
.dark .gradient-stat {
background: linear-gradient(135deg, oklch(1 0 0 / 8%) 0%, oklch(1 0 0 / 4%) 100%);
border-color: oklch(1 0 0 / 10%);
box-shadow:
0 1px 3px oklch(0 0 0 / 20%),
0 8px 24px oklch(0 0 0 / 25%),
inset 0 1px 0 oklch(1 0 0 / 5%);
}
}

View file

@ -30,10 +30,11 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`} className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
> >
<Providers> <Providers>
<div className="flex min-h-screen"> <div className="relative flex min-h-screen">
<div className="gradient-mesh pointer-events-none fixed inset-0 z-0" />
<Sidebar /> <Sidebar />
<main className="flex-1 pt-14 md:pt-0 md:pl-[240px]"> <main className="relative z-10 flex-1 pt-16 md:pt-0 md:pl-[260px]">
<div className="p-6 md:p-8">{children}</div> <div className="p-4 sm:p-6 md:p-8">{children}</div>
</main> </main>
</div> </div>
</Providers> </Providers>

View file

@ -1,25 +1,32 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { Header } from "@/components/layout/header";
import { DashboardContent } from "@/components/cards/dashboard-content"; 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"; import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6 sm:space-y-8">
<Header <DashboardHero />
title="Response Cards" <StatCards />
description="Manage scanned response cards from Echo Life Church" <QuickActions />
/>
<Suspense <div>
fallback={ <h2 className="mb-4 text-lg font-semibold tracking-tight">
<div className="space-y-4"> All Response Cards
<Skeleton className="h-10 w-full" /> </h2>
<Skeleton className="h-[400px] w-full" /> <Suspense
</div> fallback={
} <div className="space-y-4">
> <Skeleton className="h-10 w-full rounded-xl" />
<DashboardContent /> <Skeleton className="h-[400px] w-full rounded-xl" />
</Suspense> </div>
}
>
<DashboardContent />
</Suspense>
</div>
</div> </div>
); );
} }

View file

@ -2,7 +2,18 @@
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Save, Wifi, WifiOff, Trash2, Brain } from "lucide-react"; import {
Loader2,
Save,
Wifi,
WifiOff,
Trash2,
Brain,
FolderSearch,
HardDrive,
Plug,
Settings,
} from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -19,13 +30,21 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
const AI_PROVIDERS = [ const AI_PROVIDERS = [
{ value: "openai", label: "OpenAI", defaultModel: "gpt-4o-mini", hint: "GPT-4o, GPT-4o-mini" }, {
{ value: "google", label: "Google Gemini", defaultModel: "gemini-2.5-flash", hint: "Gemini 2.5 Flash, Gemini 2.5 Pro" }, value: "gateway",
{ value: "anthropic", label: "Anthropic", defaultModel: "claude-sonnet-4-20250514", hint: "Claude Sonnet, Claude Haiku" }, label: "Vercel AI Gateway",
{ value: "ollama", label: "Ollama (Local)", defaultModel: "llava:7b", hint: "llava:7b, moondream, llama3.2-vision" }, defaultModel: "openai/gpt-4o-mini",
hint: "openai/gpt-4o-mini, google/gemini-2.5-flash, anthropic/claude-sonnet-4-20250514",
},
{
value: "ollama",
label: "Ollama (Local)",
defaultModel: "llava:7b",
hint: "llava:7b, moondream, llama3.2-vision",
},
] as const; ] as const;
type Settings = { type SettingsData = {
ollamaUrl: string; ollamaUrl: string;
model: string; model: string;
watchDir: string; watchDir: string;
@ -37,14 +56,14 @@ type Settings = {
}; };
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = React.useState<Settings>({ const [settings, setSettings] = React.useState<SettingsData>({
ollamaUrl: "", ollamaUrl: "",
model: "", model: "",
watchDir: "", watchDir: "",
watching: false, watching: false,
sourceRetentionDays: 30, sourceRetentionDays: 30,
imageRetentionDays: 180, imageRetentionDays: 180,
aiProvider: "ollama", aiProvider: "gateway",
aiModel: "", aiModel: "",
}); });
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
@ -177,21 +196,20 @@ export default function SettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Header title="Settings" description="Configure OCR and app settings"> <Header title="Settings" description="Configure OCR and app settings" icon={Settings}>
<Button onClick={handleSave} disabled={saving}> <Button className="rounded-xl" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />} {saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />}
Save Settings Save Settings
</Button> </Button>
</Header> </Header>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-4 sm:gap-6 grid-cols-1 lg:grid-cols-2">
{/* AI Provider */} <Card variant="glass">
<Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Brain className="size-4" /> <Brain className="size-4 text-primary" />
AI Provider AI Provider
</CardTitle> </CardTitle>
<CardDescription>Choose the AI model for OCR processing</CardDescription> <CardDescription>Choose the AI model for OCR processing</CardDescription>
@ -200,9 +218,9 @@ export default function SettingsPage() {
variant="secondary" variant="secondary"
className={ className={
aiTestStatus === "success" aiTestStatus === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300" ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: aiTestStatus === "error" : aiTestStatus === "error"
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300" ? "bg-red-500/10 text-red-700 dark:text-red-300"
: "" : ""
} }
> >
@ -258,22 +276,16 @@ export default function SettingsPage() {
</div> </div>
)} )}
{settings.aiProvider !== "ollama" && ( {settings.aiProvider === "gateway" && (
<div className="rounded-lg border bg-muted/50 p-3"> <div className="rounded-xl border border-border/50 bg-muted/30 p-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
API key is read from the environment variable:{" "} Uses the <code className="rounded-md bg-muted px-1.5 py-0.5 text-foreground/80">AI_GATEWAY_API_KEY</code> env
<code className="rounded bg-muted px-1 py-0.5"> var. Model format: <code className="rounded-md bg-muted px-1.5 py-0.5 text-foreground/80">provider/model</code>
{settings.aiProvider === "openai"
? "OPENAI_API_KEY"
: settings.aiProvider === "google"
? "GOOGLE_GENERATIVE_AI_API_KEY"
: "ANTHROPIC_API_KEY"}
</code>
</p> </p>
</div> </div>
)} )}
<Button variant="outline" size="sm" onClick={testAiProvider} disabled={aiTestStatus === "testing"}> <Button variant="outline" size="sm" className="rounded-xl" onClick={testAiProvider} disabled={aiTestStatus === "testing"}>
{aiTestStatus === "testing" ? ( {aiTestStatus === "testing" ? (
<Loader2 className="mr-2 size-3 animate-spin" /> <Loader2 className="mr-2 size-3 animate-spin" />
) : ( ) : (
@ -284,10 +296,12 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Folder Watch */} <Card variant="glass">
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Folder Monitoring</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<FolderSearch className="size-4 text-primary" />
Folder Monitoring
</CardTitle>
<CardDescription>Automatically process new PDFs dropped into a folder</CardDescription> <CardDescription>Automatically process new PDFs dropped into a folder</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -302,17 +316,18 @@ export default function SettingsPage() {
Absolute path on the server. Mount a host folder into the container. Absolute path on the server. Mount a host folder into the container.
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button <Button
variant={settings.watching ? "destructive" : "outline"} variant={settings.watching ? "destructive" : "outline"}
size="sm" size="sm"
className="rounded-xl"
onClick={toggleWatch} onClick={toggleWatch}
disabled={!settings.watchDir} disabled={!settings.watchDir}
> >
{settings.watching ? "Stop Watching" : "Start Watching"} {settings.watching ? "Stop Watching" : "Start Watching"}
</Button> </Button>
{settings.watching && ( {settings.watching && (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"> <Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-300">
Active Active
</Badge> </Badge>
)} )}
@ -320,10 +335,12 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Storage & Cleanup */} <Card variant="glass">
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Storage & Cleanup</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<HardDrive className="size-4 text-primary" />
Storage & Cleanup
</CardTitle>
<CardDescription>Auto-purge uploaded files and images to save storage</CardDescription> <CardDescription>Auto-purge uploaded files and images to save storage</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -365,8 +382,8 @@ export default function SettingsPage() {
Scanned card images are removed after this many days. Card data is kept. Scanned card images are removed after this many days. Card data is kept.
</p> </p>
</div> </div>
<div className="rounded-lg border bg-muted/50 p-3"> <div className="rounded-xl border border-border/50 bg-muted/30 p-3">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm"> <div className="text-sm">
{cleanupStatus ? ( {cleanupStatus ? (
<> <>
@ -381,6 +398,7 @@ export default function SettingsPage() {
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
className="shrink-0 rounded-xl"
onClick={runCleanup} onClick={runCleanup}
disabled={cleaning || !cleanupStatus || (cleanupStatus.sourcesEligible === 0 && cleanupStatus.imagesEligible === 0)} disabled={cleaning || !cleanupStatus || (cleanupStatus.sourcesEligible === 0 && cleanupStatus.imagesEligible === 0)}
> >
@ -392,19 +410,21 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Monday.com Integration (placeholder) */} <Card variant="glass">
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Monday.com Integration</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<Plug className="size-4 text-primary" />
Monday.com Integration
</CardTitle>
<CardDescription>Push scanned card data to Monday.com boards (coming soon)</CardDescription> <CardDescription>Push scanned card data to Monday.com boards (coming soon)</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-lg border-2 border-dashed border-muted-foreground/20 p-8 text-center"> <div className="rounded-2xl border-2 border-dashed border-muted-foreground/15 p-6 sm:p-8 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
API endpoints are ready. Configure Monday.com connection in a future update. API endpoints are ready. Configure Monday.com connection in a future update.
</p> </p>
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
Use the REST API at <code className="rounded bg-muted px-1 py-0.5">/api/cards</code> to Use the REST API at <code className="rounded-md bg-muted px-1.5 py-0.5 text-foreground/80">/api/cards</code> to
integrate with n8n, Zapier, or Monday.com directly. integrate with n8n, Zapier, or Monday.com directly.
</p> </p>
</div> </div>

View file

@ -108,9 +108,9 @@ export default function UploadPage() {
case "queued": case "queued":
return <Loader2 className="size-4 animate-spin text-muted-foreground" />; return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
case "processing": case "processing":
return <Loader2 className="size-4 animate-spin text-blue-500" />; return <Loader2 className="size-4 animate-spin text-primary" />;
case "complete": case "complete":
return <CheckCircle className="size-4 text-green-500" />; return <CheckCircle className="size-4 text-emerald-500" />;
case "error": case "error":
return <AlertCircle className="size-4 text-red-500" />; return <AlertCircle className="size-4 text-red-500" />;
default: default:
@ -123,9 +123,10 @@ export default function UploadPage() {
<Header <Header
title="Upload" title="Upload"
description="Upload scanned response cards for OCR processing" description="Upload scanned response cards for OCR processing"
icon={Upload}
/> />
<Card> <Card variant="glass">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div <div
onDragOver={(e) => { onDragOver={(e) => {
@ -135,16 +136,16 @@ export default function UploadPage() {
onDragLeave={() => setDragOver(false)} onDragLeave={() => setDragOver(false)}
onDrop={handleDrop} onDrop={handleDrop}
className={cn( className={cn(
"flex flex-col items-center justify-center rounded-xl border-2 border-dashed p-12 text-center transition-colors", "flex flex-col items-center justify-center rounded-2xl border-2 border-dashed p-8 sm:p-12 text-center transition-all",
dragOver dragOver
? "border-primary bg-primary/5" ? "border-primary bg-primary/5 scale-[1.01]"
: "border-muted-foreground/25 hover:border-muted-foreground/50" : "border-muted-foreground/20 hover:border-primary/40 hover:bg-primary/[2%]"
)} )}
> >
<div className="mb-4 rounded-full bg-muted p-4"> <div className="mb-4 flex size-16 items-center justify-center rounded-2xl bg-primary/10">
<Upload className="size-8 text-muted-foreground" /> <Upload className="size-7 text-primary" />
</div> </div>
<h3 className="mb-1 text-lg font-medium"> <h3 className="mb-1 text-base font-semibold sm:text-lg">
Drop files here or click to browse Drop files here or click to browse
</h3> </h3>
<p className="mb-4 text-sm text-muted-foreground"> <p className="mb-4 text-sm text-muted-foreground">
@ -152,6 +153,7 @@ export default function UploadPage() {
</p> </p>
<Button <Button
variant="outline" variant="outline"
className="rounded-xl"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
<FolderOpen className="mr-2 size-4" /> <FolderOpen className="mr-2 size-4" />
@ -169,7 +171,7 @@ export default function UploadPage() {
{files.length > 0 && ( {files.length > 0 && (
<div className="mt-6 space-y-3"> <div className="mt-6 space-y-3">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="text-sm font-medium"> <h4 className="text-sm font-medium">
{files.length} file{files.length !== 1 ? "s" : ""} selected {files.length} file{files.length !== 1 ? "s" : ""} selected
</h4> </h4>
@ -183,6 +185,7 @@ export default function UploadPage() {
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="rounded-xl"
onClick={handleUpload} onClick={handleUpload}
disabled={uploading} disabled={uploading}
> >
@ -204,15 +207,15 @@ export default function UploadPage() {
{files.map((file, i) => ( {files.map((file, i) => (
<div <div
key={`${file.name}-${i}`} key={`${file.name}-${i}`}
className="flex items-center gap-3 rounded-lg border px-3 py-2" className="flex items-center gap-3 rounded-xl border border-border/50 bg-muted/30 px-3 py-2.5"
> >
{file.type === "application/pdf" ? ( {file.type === "application/pdf" ? (
<FileText className="size-4 text-red-500" /> <FileText className="size-4 shrink-0 text-red-500" />
) : ( ) : (
<ImageIcon className="size-4 text-blue-500" /> <ImageIcon className="size-4 shrink-0 text-blue-500" />
)} )}
<span className="flex-1 truncate text-sm">{file.name}</span> <span className="flex-1 min-w-0 truncate text-sm">{file.name}</span>
<span className="text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{(file.size / 1024).toFixed(0)} KB {(file.size / 1024).toFixed(0)} KB
</span> </span>
<Button <Button
@ -230,10 +233,12 @@ export default function UploadPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Processing Queue */} <Card variant="glass">
<Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Processing Queue</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<Loader2 className="size-4 text-primary" />
Processing Queue
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{jobs.length === 0 ? ( {jobs.length === 0 ? (
@ -245,29 +250,29 @@ export default function UploadPage() {
{jobs.map((job) => ( {jobs.map((job) => (
<div <div
key={job.id} key={job.id}
className="flex items-center gap-3 rounded-lg border px-4 py-3" className="flex flex-col gap-2 rounded-xl border border-border/50 bg-muted/20 px-4 py-3 sm:flex-row sm:items-center sm:gap-3"
> >
{getStatusIcon(job.status)} <div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-1 min-w-0"> {getStatusIcon(job.status)}
<div className="flex items-center gap-2"> <div className="flex-1 min-w-0">
<span className="truncate text-sm font-medium"> <div className="flex flex-wrap items-center gap-2">
{job.fileName} <span className="truncate text-sm font-medium">
</span> {job.fileName}
<Badge </span>
variant="secondary" <Badge
className={cn( variant="secondary"
"capitalize text-xs", className={cn(
job.status === "complete" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300", "capitalize text-xs",
job.status === "error" && "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300", job.status === "complete" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
job.status === "processing" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", job.status === "error" && "bg-red-500/10 text-red-700 dark:text-red-300",
)} job.status === "processing" && "bg-primary/10 text-primary",
> )}
{job.status} >
</Badge> {job.status}
</div> </Badge>
{job.totalPages > 0 && ( </div>
<div className="mt-1"> {job.totalPages > 0 && (
<div className="flex items-center gap-2"> <div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"> <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div <div
className="h-full rounded-full bg-primary transition-all" className="h-full rounded-full bg-primary transition-all"
@ -276,17 +281,17 @@ export default function UploadPage() {
}} }}
/> />
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{job.processed}/{job.totalPages} pages {job.processed}/{job.totalPages}
</span> </span>
</div> </div>
</div> )}
)} {job.error && (
{job.error && ( <p className="mt-1 text-xs text-red-500">{job.error}</p>
<p className="mt-1 text-xs text-red-500">{job.error}</p> )}
)} </div>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleTimeString()} {new Date(job.createdAt).toLocaleTimeString()}
</span> </span>
</div> </div>

View file

@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { Upload, ScanLine } from "lucide-react";
export function DashboardHero() {
return (
<div className="relative overflow-hidden rounded-2xl gradient-banner p-6 sm:p-8 text-white">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_60%_at_80%_20%,oklch(1_0_0/12%),transparent)]" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-2">
<ScanLine className="size-5 opacity-80" />
<span className="text-sm font-medium text-white/80">Echo OCR</span>
</div>
<h1 className="text-xl font-bold tracking-tight sm:text-2xl lg:text-3xl">
Document Processing Center
</h1>
<p className="mt-1.5 max-w-xl text-sm text-white/75 sm:text-base">
Upload your response cards and let AI-powered OCR extract data with high accuracy.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href="/upload"
className="inline-flex items-center gap-2 rounded-xl bg-white/20 px-4 py-2 text-sm font-medium text-white backdrop-blur-sm border border-white/20 transition-colors hover:bg-white/30"
>
<Upload className="size-4" />
Upload Documents
</Link>
</div>
</div>
</div>
);
}

View file

@ -29,7 +29,6 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { ResponseCard } from "./columns"; import type { ResponseCard } from "./columns";
type DataTableProps<TData> = { type DataTableProps<TData> = {
@ -135,15 +134,16 @@ export function DataTable<TData extends ResponseCard>({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{selectedCount > 0 && ( {selectedCount > 0 && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 px-4 py-2.5"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 glass-card rounded-2xl px-4 py-3">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{selectedCount} row{selectedCount !== 1 ? "s" : ""} selected {selectedCount} row{selectedCount !== 1 ? "s" : ""} selected
</span> </span>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{onBulkMarkReviewed && ( {onBulkMarkReviewed && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-xl"
onClick={() => onBulkMarkReviewed(selectedIds)} onClick={() => onBulkMarkReviewed(selectedIds)}
> >
<CheckCircle className="mr-1.5 size-4" /> <CheckCircle className="mr-1.5 size-4" />
@ -154,6 +154,7 @@ export function DataTable<TData extends ResponseCard>({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-xl"
onClick={() => onBulkMarkExported(selectedIds)} onClick={() => onBulkMarkExported(selectedIds)}
> >
<Download className="mr-1.5 size-4" /> <Download className="mr-1.5 size-4" />
@ -164,7 +165,7 @@ export function DataTable<TData extends ResponseCard>({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive" className="rounded-xl text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onBulkDelete(selectedIds)} onClick={() => onBulkDelete(selectedIds)}
> >
<Trash2 className="mr-1.5 size-4" /> <Trash2 className="mr-1.5 size-4" />
@ -174,77 +175,80 @@ export function DataTable<TData extends ResponseCard>({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="rounded-xl"
onClick={() => table.toggleAllPageRowsSelected(false)} onClick={() => table.toggleAllPageRowsSelected(false)}
> >
Clear selection Clear
</Button> </Button>
</div> </div>
</div> </div>
)} )}
<div className="rounded-lg border border-border bg-card"> <div className="glass-card overflow-hidden rounded-2xl">
<Table> <div className="overflow-x-auto">
<TableHeader> <Table>
{table.getHeaderGroups().map((headerGroup) => ( <TableHeader>
<TableRow key={headerGroup.id}> {table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => ( <TableRow key={headerGroup.id}>
<TableHead key={header.id} className="px-4 py-3"> {headerGroup.headers.map((header) => (
{header.isPlaceholder <TableHead key={header.id} className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
? null {header.isPlaceholder
: flexRender( ? null
header.column.columnDef.header, : flexRender(
header.getContext() header.column.columnDef.header,
)} header.getContext()
</TableHead> )}
))} </TableHead>
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4 py-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))} ))}
</TableRow> </TableRow>
)) ))}
) : ( </TableHeader>
<TableRow> <TableBody>
<TableCell {table.getRowModel().rows?.length ? (
colSpan={columns.length} table.getRowModel().rows.map((row) => (
className="h-24 text-center text-muted-foreground" <TableRow
> key={row.id}
No results. data-state={row.getIsSelected() && "selected"}
</TableCell> className="transition-colors hover:bg-muted/30"
</TableRow> >
)} {row.getVisibleCells().map((cell) => (
</TableBody> <TableCell key={cell.id} className="px-4 py-3">
</Table> {flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div> </div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span> <span>
{totalCount === 0 {totalCount === 0
? "0 results" ? "0 results"
: `${start}${end} of ${totalCount}`} : `${start}${end} of ${totalCount}`}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>Rows per page</span> <span className="text-xs">Rows</span>
<select <select
value={limit} value={limit}
onChange={(e) => onLimitChange?.(Number(e.target.value))} onChange={(e) => onLimitChange?.(Number(e.target.value))}
className="h-8 rounded-md border border-input bg-transparent px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" className="h-8 rounded-xl border border-input bg-transparent px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{PAGE_SIZES.map((size) => ( {PAGE_SIZES.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
@ -254,15 +258,16 @@ export function DataTable<TData extends ResponseCard>({
</select> </select>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-xl"
onClick={() => onPageChange?.(page - 1)} onClick={() => onPageChange?.(page - 1)}
disabled={!canPrev} disabled={!canPrev}
> >
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
Previous <span className="hidden sm:inline ml-1">Prev</span>
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -281,7 +286,7 @@ export function DataTable<TData extends ResponseCard>({
key={p} key={p}
variant={p === page ? "default" : "ghost"} variant={p === page ? "default" : "ghost"}
size="sm" size="sm"
className="size-8 p-0" className="size-8 rounded-xl p-0"
onClick={() => onPageChange?.(p)} onClick={() => onPageChange?.(p)}
> >
{p} {p}
@ -292,10 +297,11 @@ export function DataTable<TData extends ResponseCard>({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="rounded-xl"
onClick={() => onPageChange?.(page + 1)} onClick={() => onPageChange?.(page + 1)}
disabled={!canNext} disabled={!canNext}
> >
Next <span className="hidden sm:inline mr-1">Next</span>
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
</Button> </Button>
</div> </div>

View file

@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Search, Download, Columns3 } from "lucide-react"; import { Search, Download, Columns3, SlidersHorizontal } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -127,150 +127,154 @@ export function Filters({
}; };
return ( return (
<div className="flex flex-col gap-4"> <div className="glass-card rounded-2xl p-4 sm:p-5">
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-3"> <div className="flex flex-col gap-4">
<div className="relative flex-1"> <form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <div className="relative w-full sm:max-w-sm">
<Input <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
type="search" <Input
placeholder="Search name, email, phone, city..." type="search"
value={search} placeholder="Search name, email, phone..."
onChange={handleSearchChange} value={search}
className="pl-9" onChange={handleSearchChange}
/> className="pl-9 rounded-xl"
</div> />
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
Review:
</span>
{REVIEW_OPTIONS.map((opt) => (
<Badge
key={opt.value || "all"}
variant={reviewStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors",
reviewStatus === opt.value && "bg-primary text-primary-foreground"
)}
onClick={() => updateParams({ reviewStatus: opt.value || undefined })}
>
{opt.label}
</Badge>
))}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2 overflow-x-auto pb-1 sm:pb-0">
<span className="text-xs font-medium text-muted-foreground"> <SlidersHorizontal className="size-3.5 shrink-0 text-muted-foreground" />
OCR: <div className="flex items-center gap-1.5 shrink-0">
</span> <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{OCR_OPTIONS.map((opt) => ( Review:
<Badge </span>
key={opt.value || "all"} {REVIEW_OPTIONS.map((opt) => (
variant={ocrStatus === opt.value ? "default" : "outline"} <Badge
className={cn( key={opt.value || "all"}
"cursor-pointer transition-colors", variant={reviewStatus === opt.value ? "default" : "outline"}
ocrStatus === opt.value && "bg-primary text-primary-foreground" className={cn(
)} "cursor-pointer shrink-0 transition-all min-h-[28px] px-2.5 text-xs",
onClick={() => updateParams({ ocrStatus: opt.value || undefined })} reviewStatus === opt.value && "bg-primary text-primary-foreground shadow-sm"
> )}
{opt.label} onClick={() => updateParams({ reviewStatus: opt.value || undefined })}
</Badge>
))}
</div>
</div>
</form>
<div className="flex flex-wrap items-center gap-3">
<Select
value={visitType || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
visitType: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Visit Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All visit types</SelectItem>
{visitTypeOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={attendanceDuration || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
attendanceDuration: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Attendance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All attendance</SelectItem>
{attendanceDurationOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={serviceAttended || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
serviceAttended: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All services</SelectItem>
{serviceAttendedOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" />
}
>
<Columns3 className="mr-2 size-4" />
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{TOGGLEABLE_COLUMNS.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={isColumnVisible(col.id)}
onCheckedChange={(checked) =>
toggleColumn(col.id, checked !== false)
}
> >
{col.label} {opt.label}
</DropdownMenuCheckboxItem> </Badge>
))} ))}
</DropdownMenuContent> </div>
</DropdownMenu> <div className="mx-1 h-4 w-px bg-border/50 shrink-0 hidden sm:block" />
{onExportCsv && ( <div className="flex items-center gap-1.5 shrink-0">
<Button variant="outline" size="sm" onClick={onExportCsv}> <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
<Download className="mr-2 size-4" /> OCR:
Export CSV </span>
</Button> {OCR_OPTIONS.map((opt) => (
)} <Badge
key={opt.value || "all"}
variant={ocrStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer shrink-0 transition-all min-h-[28px] px-2.5 text-xs",
ocrStatus === opt.value && "bg-primary text-primary-foreground shadow-sm"
)}
onClick={() => updateParams({ ocrStatus: opt.value || undefined })}
>
{opt.label}
</Badge>
))}
</div>
</div>
</form>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<Select
value={visitType || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
visitType: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-full sm:w-[170px] rounded-xl">
<SelectValue placeholder="Visit Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All visit types</SelectItem>
{visitTypeOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={attendanceDuration || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
attendanceDuration: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-full sm:w-[170px] rounded-xl">
<SelectValue placeholder="Attendance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All attendance</SelectItem>
{attendanceDurationOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={serviceAttended || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
serviceAttended: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-full sm:w-[160px] rounded-xl">
<SelectValue placeholder="Service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All services</SelectItem>
{serviceAttendedOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" className="rounded-xl" />
}
>
<Columns3 className="mr-2 size-4" />
<span className="hidden sm:inline">Columns</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{TOGGLEABLE_COLUMNS.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={isColumnVisible(col.id)}
onCheckedChange={(checked) =>
toggleColumn(col.id, checked !== false)
}
>
{col.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{onExportCsv && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={onExportCsv}>
<Download className="mr-2 size-4" />
<span className="hidden sm:inline">Export CSV</span>
</Button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,61 @@
"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 (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{actions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.label}
href={action.href}
className="glass-card group flex items-center gap-3 rounded-2xl p-4 transition-all hover:scale-[1.02] hover:shadow-lg sm:p-5"
>
<div className={cn("flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform group-hover:scale-110", action.accent)}>
<Icon className="size-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{action.label}</p>
<p className="text-xs text-muted-foreground hidden sm:block">{action.description}</p>
</div>
</Link>
);
})}
</div>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import * as React from "react";
import {
FileText,
CheckCircle,
AlertTriangle,
Clock,
TrendingUp,
TrendingDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Stats = {
total: number;
byOcrStatus: Record<string, number>;
byReviewStatus: Record<string, number>;
};
type StatCardData = {
label: string;
value: number;
icon: React.ElementType;
trend?: string;
trendUp?: boolean;
accentClass: string;
};
export function StatCards() {
const [stats, setStats] = React.useState<Stats | null>(null);
React.useEffect(() => {
fetch("/api/stats")
.then((r) => r.json())
.then(setStats)
.catch(() => {});
}, []);
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" },
];
}
return [
{
label: "Total Cards",
value: stats.total,
icon: FileText,
accentClass: "text-primary bg-primary/10",
},
{
label: "OCR Complete",
value: stats.byOcrStatus["complete"] || 0,
icon: CheckCircle,
accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400",
trendUp: true,
},
{
label: "Errors",
value: stats.byOcrStatus["error"] || 0,
icon: AlertTriangle,
accentClass: "text-red-600 bg-red-500/10 dark:text-red-400",
trendUp: false,
},
{
label: "Pending Review",
value: stats.byReviewStatus["unreviewed"] || 0,
icon: Clock,
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
},
];
}, [stats]);
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
{cards.map((card) => {
const Icon = card.icon;
return (
<div
key={card.label}
className="gradient-stat flex flex-col gap-3 rounded-2xl p-4 sm:p-5"
>
<div className="flex items-center justify-between">
<div className={cn("flex size-10 items-center justify-center rounded-xl", card.accentClass)}>
<Icon className="size-5" />
</div>
{card.trendUp !== undefined && card.value > 0 && (
<div className={cn(
"flex items-center gap-0.5 text-xs font-medium",
card.trendUp ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"
)}>
{card.trendUp ? <TrendingUp className="size-3" /> : <TrendingDown className="size-3" />}
</div>
)}
</div>
<div>
<p className="text-2xl font-bold tracking-tight sm:text-3xl">
{stats ? card.value.toLocaleString() : "—"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground sm:text-sm">
{card.label}
</p>
</div>
</div>
);
})}
</div>
);
}

View file

@ -1,22 +1,32 @@
import type { LucideIcon } from "lucide-react";
interface HeaderProps { interface HeaderProps {
title: string; title: string;
description?: string; description?: string;
icon?: LucideIcon;
children?: React.ReactNode; children?: React.ReactNode;
} }
export function Header({ title, description, children }: HeaderProps) { export function Header({ title, description, icon: Icon, children }: HeaderProps) {
return ( return (
<header className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div className="flex items-start gap-3">
<h1 className="text-2xl font-semibold tracking-tight text-foreground"> {Icon && (
{title} <div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
</h1> <Icon className="size-[18px]" />
{description && ( </div>
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
)} )}
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground sm:text-2xl">
{title}
</h1>
{description && (
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
)}
</div>
</div> </div>
{children && ( {children && (
<div className="mt-4 flex shrink-0 items-center gap-2 sm:mt-0"> <div className="mt-2 flex shrink-0 flex-wrap items-center gap-2 sm:mt-0">
{children} {children}
</div> </div>
)} )}

View file

@ -37,7 +37,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<nav className="flex flex-col gap-0.5 px-2"> <nav className="flex flex-col gap-1 px-3">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
@ -50,13 +50,13 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
href={item.href} href={item.href}
onClick={onNavigate} onClick={onNavigate}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all min-h-[44px]",
isActive isActive
? "bg-sidebar-accent text-sidebar-accent-foreground" ? "bg-primary/10 text-primary shadow-sm dark:bg-primary/15"
: "text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground" : "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground"
)} )}
> >
<Icon className="size-4 shrink-0 opacity-80" /> <Icon className={cn("size-[18px] shrink-0", isActive ? "text-primary" : "opacity-60")} />
{item.label} {item.label}
</Link> </Link>
); );
@ -74,14 +74,14 @@ function ThemeToggle() {
render={ render={
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon"
className="text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground" className="size-9 rounded-xl text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
/> />
} }
> >
<Sun className="size-4 dark:hidden" /> <Sun className="size-[18px] dark:hidden" />
<Moon className="hidden size-4 dark:block" /> <Moon className="hidden size-[18px] dark:block" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
@ -94,24 +94,39 @@ function ThemeToggle() {
function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
return ( return (
<> <>
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-sidebar-border px-4"> <div className="flex h-16 shrink-0 items-center gap-3 px-5">
<div className="flex items-center gap-2"> <div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-md">
<div className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> <ScanLine className="size-[18px] text-white" />
<ScanLine className="size-4" /> </div>
</div> <div className="flex flex-col">
<span className="font-semibold text-sidebar-foreground"> <span className="text-sm font-bold tracking-tight text-sidebar-foreground">
Echo OCR Echo OCR
</span> </span>
<span className="text-[11px] text-muted-foreground">
Document Scanner
</span>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col gap-4 overflow-auto py-4"> <div className="flex flex-1 flex-col gap-1 overflow-auto py-4">
<div className="mb-2 px-5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Menu
</span>
</div>
<NavLinks onNavigate={onNavigate} /> <NavLinks onNavigate={onNavigate} />
</div> </div>
<div className="shrink-0 border-t border-sidebar-border p-3"> <div className="shrink-0 border-t border-sidebar-border/50 p-3">
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between rounded-xl px-2 py-1">
<span className="text-xs text-muted-foreground">Theme</span> <div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary">
{/* Theme icon inline */}
<Sun className="size-3.5 dark:hidden" />
<Moon className="hidden size-3.5 dark:block" />
</div>
<span className="text-xs font-medium text-muted-foreground">Theme</span>
</div>
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
@ -124,14 +139,13 @@ export function Sidebar() {
return ( return (
<> <>
{/* Mobile menu trigger */}
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger <SheetTrigger
render={ render={
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="fixed left-4 top-4 z-40 md:hidden" className="fixed left-3 top-3.5 z-40 size-10 rounded-xl glass-card md:hidden"
/> />
} }
> >
@ -140,7 +154,7 @@ export function Sidebar() {
</SheetTrigger> </SheetTrigger>
<SheetContent <SheetContent
side="left" side="left"
className="w-[240px] border-sidebar-border bg-sidebar p-0" className="w-[260px] border-sidebar-border/50 glass-sidebar p-0"
showCloseButton={true} showCloseButton={true}
> >
<div className="flex h-full w-full flex-col"> <div className="flex h-full w-full flex-col">
@ -149,10 +163,9 @@ export function Sidebar() {
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Desktop sidebar */}
<aside <aside
className={cn( className={cn(
"fixed inset-y-0 left-0 z-30 hidden w-[240px] flex-col border-r border-sidebar-border bg-sidebar", "fixed inset-y-0 left-0 z-30 hidden w-[260px] flex-col glass-sidebar",
"md:flex" "md:flex"
)} )}
> >

View file

@ -5,14 +5,21 @@ import { cn } from "@/lib/utils"
function Card({ function Card({
className, className,
size = "default", size = "default",
variant = "default",
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<"div"> & {
size?: "default" | "sm"
variant?: "default" | "glass"
}) {
return ( return (
<div <div
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "group/card flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm text-card-foreground transition-shadow has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
variant === "glass"
? "glass-card"
: "bg-card ring-1 ring-foreground/10 glass",
className className
)} )}
{...props} {...props}

View file

@ -1,18 +1,12 @@
import { generateText, Output } from "ai"; import { generateText, Output } from "ai";
import { openai } from "@ai-sdk/openai"; import { gateway } from "@ai-sdk/gateway";
import { google } from "@ai-sdk/google";
import { anthropic } from "@ai-sdk/anthropic";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "./db"; import { prisma } from "./db";
import type { LanguageModel } from "ai"; import type { LanguageModel } from "ai";
const DEFAULT_MODELS: Record<string, string> = { const DEFAULT_GATEWAY_MODEL = "openai/gpt-4o-mini";
openai: "gpt-4o-mini", const DEFAULT_OLLAMA_MODEL = "llava:7b";
google: "gemini-2.5-flash",
anthropic: "claude-sonnet-4-20250514",
ollama: "llava:7b",
};
async function getSettings() { async function getSettings() {
let settings = await prisma.appSettings.findUnique({ let settings = await prisma.appSettings.findUnique({
@ -31,30 +25,15 @@ function getModelInstance(
modelId: string, modelId: string,
ollamaUrl: string ollamaUrl: string
): LanguageModel { ): LanguageModel {
const resolvedModel = modelId || DEFAULT_MODELS[provider] || DEFAULT_MODELS.ollama; if (provider === "ollama") {
const resolvedModel = modelId || DEFAULT_OLLAMA_MODEL;
switch (provider) { const baseURL = (ollamaUrl || process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434") + "/v1";
case "openai": const ollama = createOpenAICompatible({ name: "ollama", baseURL });
return openai(resolvedModel); return ollama.chatModel(resolvedModel);
case "google":
return google(resolvedModel);
case "anthropic":
return anthropic(resolvedModel);
case "ollama": {
const baseURL = (ollamaUrl || process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434") + "/v1";
const ollama = createOpenAICompatible({
name: "ollama",
baseURL,
});
return ollama.chatModel(resolvedModel);
}
default:
throw new Error(`Unknown AI provider: ${provider}`);
} }
const resolvedModel = modelId || DEFAULT_GATEWAY_MODEL;
return gateway(resolvedModel);
} }
const responseCardSchema = z.object({ const responseCardSchema = z.object({
@ -153,7 +132,7 @@ export async function ocrImage(
side: "response" | "survey" side: "response" | "survey"
): Promise<OcrResult> { ): Promise<OcrResult> {
const settings = await getSettings(); const settings = await getSettings();
const provider = settings.aiProvider || "ollama"; const provider = settings.aiProvider || "gateway";
const modelId = settings.aiModel || ""; const modelId = settings.aiModel || "";
const model = getModelInstance(provider, modelId, settings.ollamaUrl); const model = getModelInstance(provider, modelId, settings.ollamaUrl);
@ -171,5 +150,3 @@ export async function ocrImage(
return { data, confidence, raw: JSON.stringify(output), side }; return { data, confidence, raw: JSON.stringify(output), side };
} }
export { DEFAULT_MODELS };