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_URL="postgresql://echos_ocr:YOUR_PASSWORD@192.168.68.102:5432/echos_ocr"
# AI Provider API Keys (set the ones you need)
OPENAI_API_KEY=""
GOOGLE_GENERATIVE_AI_API_KEY=""
ANTHROPIC_API_KEY=""
# Vercel AI Gateway (routes to OpenAI, Google, Anthropic, etc.)
AI_GATEWAY_API_KEY=""
# Ollama (only needed if using Ollama as the AI provider)
OLLAMA_BASE_URL="http://192.168.68.108:11434"
OLLAMA_MODEL="llava:7b"
# MinIO S3 Storage (CT 105)
MINIO_ENDPOINT="192.168.68.105"

51
package-lock.json generated
View file

@ -9,9 +9,6 @@
"version": "0.1.0",
"hasInstallScript": true,
"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",
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
@ -53,22 +50,6 @@
"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": {
"version": "3.0.66",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz",
@ -86,38 +67,6 @@
"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": {
"version": "2.0.35",
"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"
},
"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",
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",

View file

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

View file

@ -1,46 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { google } from "@ai-sdk/google";
import { anthropic } from "@ai-sdk/anthropic";
import { gateway } from "@ai-sdk/gateway";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
const DEFAULT_MODELS: Record<string, string> = {
openai: "gpt-4o-mini",
google: "gemini-2.5-flash",
anthropic: "claude-sonnet-4-20250514",
ollama: "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}`);
function getTestModel(provider: string, modelId: string, ollamaUrl: string): LanguageModel {
if (provider === "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(modelId || "llava:7b");
}
return gateway(modelId || "openai/gpt-4o-mini");
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const provider = body.provider || "ollama";
const provider = body.provider || "gateway";
const modelId = body.model || "";
const ollamaUrl = body.ollamaUrl || "";

View file

@ -4,7 +4,19 @@ import * as React from "react";
import { useParams, useRouter } from "next/navigation";
import Image from "next/image";
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 { Button } from "@/components/ui/button";
@ -140,10 +152,10 @@ export default function CardDetailPage() {
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-96" />
<Skeleton className="h-96" />
<Skeleton className="h-10 w-64 rounded-xl" />
<div className="grid gap-4 sm:gap-6 grid-cols-1 lg:grid-cols-2">
<Skeleton className="h-96 rounded-2xl" />
<Skeleton className="h-96 rounded-2xl" />
</div>
</div>
);
@ -152,8 +164,8 @@ export default function CardDetailPage() {
if (!card) {
return (
<div className="space-y-6">
<Header title="Card Not Found" />
<Button variant="outline" onClick={() => router.push("/")}>
<Header title="Card Not Found" icon={ScanLine} />
<Button variant="outline" className="rounded-xl" onClick={() => router.push("/")}>
<ArrowLeft className="mr-2 size-4" /> Back to Dashboard
</Button>
</div>
@ -167,32 +179,33 @@ export default function CardDetailPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<Button variant="ghost" size="sm" className="w-fit rounded-xl" onClick={() => router.push("/")}>
<ArrowLeft className="mr-1 size-4" /> Back
</Button>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className={cn(
"capitalize",
ocrStatus === "complete" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
ocrStatus === "error" && "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
ocrStatus === "processing" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
ocrStatus === "complete" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
ocrStatus === "error" && "bg-red-500/10 text-red-700 dark:text-red-300",
ocrStatus === "processing" && "bg-primary/10 text-primary",
)}>
OCR: {ocrStatus}
</Badge>
<Badge variant="secondary" className={cn(
"capitalize",
reviewStatus === "reviewed" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
reviewStatus === "exported" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
reviewStatus === "unreviewed" && "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
reviewStatus === "reviewed" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
reviewStatus === "exported" && "bg-primary/10 text-primary",
reviewStatus === "unreviewed" && "bg-amber-500/10 text-amber-700 dark:text-amber-300",
)}>
{reviewStatus}
</Badge>
{confidence != null && (
<Badge variant="outline" className={cn(
confidence < 50 && "text-red-600 border-red-200",
confidence >= 50 && confidence <= 75 && "text-amber-600 border-amber-200",
confidence > 75 && "text-green-600 border-green-200",
"font-medium",
confidence < 50 && "text-red-600 border-red-300 dark:border-red-800",
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
</Badge>
@ -200,47 +213,51 @@ export default function CardDetailPage() {
</div>
</div>
<Header title={String(card.name || "Unnamed Card")}>
<div className="flex gap-2">
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
<div className="flex flex-wrap gap-2">
{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
</Button>
)}
{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
</Button>
)}
{hasEdits && (
<Button size="sm" onClick={handleSave} disabled={saving}>
<Button size="sm" className="rounded-xl" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
)}
</div>
</Header>
<div className="grid gap-6 xl:grid-cols-2">
{/* Scanned Images */}
<Card>
<div className="grid gap-4 sm:gap-6 grid-cols-1 xl:grid-cols-2">
<Card variant="glass">
<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>
<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="Survey (Front)" url={card.frontImageUrl as string | null} />
</div>
</CardContent>
</Card>
{/* Personal Info */}
<Card>
<Card variant="glass">
<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>
<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="Email" value={getValue("email")} onChange={(v) => setField("email", 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="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} />
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Separator className="opacity-50" />
<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="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", 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="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} />
</div>
<Separator />
<Separator className="opacity-50" />
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Prayer Requests</Label>
<Textarea
@ -270,13 +287,15 @@ export default function CardDetailPage() {
</CardContent>
</Card>
{/* Survey Info */}
<Card className="xl:col-span-2">
<Card variant="glass" className="xl:col-span-2">
<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>
<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>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Message Topics</Label>
<div className="flex flex-wrap gap-1.5">
@ -323,21 +342,23 @@ export default function CardDetailPage() {
</CardContent>
</Card>
{/* Raw OCR Response */}
{card.rawOcrResponse && (
<Card className="xl:col-span-2">
<Card variant="glass" className="xl:col-span-2">
<CardHeader>
<button
className="flex w-full items-center justify-between text-left"
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" />}
</button>
</CardHeader>
{showRawOcr && (
<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)}
</pre>
</CardContent>
@ -346,16 +367,15 @@ export default function CardDetailPage() {
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between border-t pt-4">
<Button variant="outline" size="sm" onClick={() => router.push("/")}>
<div className="flex flex-col gap-3 border-t border-border/50 pt-4 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" size="sm" className="w-fit rounded-xl" onClick={() => router.push("/")}>
<ArrowLeft className="mr-1 size-4" /> All Cards
</Button>
<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
</Button>
<Button variant="outline" size="sm" disabled>
<Button variant="outline" size="sm" className="rounded-xl" disabled>
Next <ArrowRight className="ml-1 size-4" />
</Button>
</div>
@ -369,12 +389,12 @@ function ImagePanel({ label, url }: { label: string; url: string | null }) {
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
{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 />
</div>
) : (
<div className="flex aspect-[3/4] items-center justify-center rounded-lg border bg-muted/50">
<ImageIcon className="size-12 text-muted-foreground/30" />
<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/20" />
</div>
)}
</div>

View file

@ -48,72 +48,72 @@
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--background: oklch(0.97 0.005 270);
--foreground: oklch(0.17 0.02 270);
--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);
--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);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--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);
--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-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);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.87 0.00 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: oklch(0.14 0.02 270);
--foreground: oklch(0.93 0.01 270);
--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);
--secondary: oklch(1 0 0 / 8%);
--secondary-foreground: oklch(0.90 0.01 270);
--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);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--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);
--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-accent: oklch(1 0 0 / 8%);
--sidebar-accent-foreground: oklch(0.90 0.02 270);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.65 0.20 264);
}
@layer base {
@ -126,4 +126,99 @@
html {
@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`}
>
<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 />
<main className="flex-1 pt-14 md:pt-0 md:pl-[240px]">
<div className="p-6 md:p-8">{children}</div>
<main className="relative z-10 flex-1 pt-16 md:pt-0 md:pl-[260px]">
<div className="p-4 sm:p-6 md:p-8">{children}</div>
</main>
</div>
</Providers>

View file

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

View file

@ -2,7 +2,18 @@
import * as React from "react";
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 { Button } from "@/components/ui/button";
@ -19,13 +30,21 @@ import {
} from "@/components/ui/select";
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: "anthropic", label: "Anthropic", defaultModel: "claude-sonnet-4-20250514", hint: "Claude Sonnet, Claude Haiku" },
{ value: "ollama", label: "Ollama (Local)", defaultModel: "llava:7b", hint: "llava:7b, moondream, llama3.2-vision" },
{
value: "gateway",
label: "Vercel AI Gateway",
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;
type Settings = {
type SettingsData = {
ollamaUrl: string;
model: string;
watchDir: string;
@ -37,14 +56,14 @@ type Settings = {
};
export default function SettingsPage() {
const [settings, setSettings] = React.useState<Settings>({
const [settings, setSettings] = React.useState<SettingsData>({
ollamaUrl: "",
model: "",
watchDir: "",
watching: false,
sourceRetentionDays: 30,
imageRetentionDays: 180,
aiProvider: "ollama",
aiProvider: "gateway",
aiModel: "",
});
const [loading, setLoading] = React.useState(true);
@ -177,21 +196,20 @@ export default function SettingsPage() {
return (
<div className="space-y-6">
<Header title="Settings" description="Configure OCR and app settings">
<Button onClick={handleSave} disabled={saving}>
<Header title="Settings" description="Configure OCR and app settings" icon={Settings}>
<Button className="rounded-xl" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />}
Save Settings
</Button>
</Header>
<div className="grid gap-6 lg:grid-cols-2">
{/* AI Provider */}
<Card>
<div className="grid gap-4 sm:gap-6 grid-cols-1 lg:grid-cols-2">
<Card variant="glass">
<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>
<CardTitle className="flex items-center gap-2 text-base">
<Brain className="size-4" />
<Brain className="size-4 text-primary" />
AI Provider
</CardTitle>
<CardDescription>Choose the AI model for OCR processing</CardDescription>
@ -200,9 +218,9 @@ export default function SettingsPage() {
variant="secondary"
className={
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"
? "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>
)}
{settings.aiProvider !== "ollama" && (
<div className="rounded-lg border bg-muted/50 p-3">
{settings.aiProvider === "gateway" && (
<div className="rounded-xl border border-border/50 bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
API key is read from the environment variable:{" "}
<code className="rounded bg-muted px-1 py-0.5">
{settings.aiProvider === "openai"
? "OPENAI_API_KEY"
: settings.aiProvider === "google"
? "GOOGLE_GENERATIVE_AI_API_KEY"
: "ANTHROPIC_API_KEY"}
</code>
Uses the <code className="rounded-md bg-muted px-1.5 py-0.5 text-foreground/80">AI_GATEWAY_API_KEY</code> env
var. Model format: <code className="rounded-md bg-muted px-1.5 py-0.5 text-foreground/80">provider/model</code>
</p>
</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" ? (
<Loader2 className="mr-2 size-3 animate-spin" />
) : (
@ -284,10 +296,12 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Folder Watch */}
<Card>
<Card variant="glass">
<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>
</CardHeader>
<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.
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button
variant={settings.watching ? "destructive" : "outline"}
size="sm"
className="rounded-xl"
onClick={toggleWatch}
disabled={!settings.watchDir}
>
{settings.watching ? "Stop Watching" : "Start Watching"}
</Button>
{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
</Badge>
)}
@ -320,10 +335,12 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Storage & Cleanup */}
<Card>
<Card variant="glass">
<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>
</CardHeader>
<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.
</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<div className="flex items-center justify-between">
<div className="rounded-xl border border-border/50 bg-muted/30 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm">
{cleanupStatus ? (
<>
@ -381,6 +398,7 @@ export default function SettingsPage() {
<Button
variant="destructive"
size="sm"
className="shrink-0 rounded-xl"
onClick={runCleanup}
disabled={cleaning || !cleanupStatus || (cleanupStatus.sourcesEligible === 0 && cleanupStatus.imagesEligible === 0)}
>
@ -392,19 +410,21 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Monday.com Integration (placeholder) */}
<Card>
<Card variant="glass">
<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>
</CardHeader>
<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">
API endpoints are ready. Configure Monday.com connection in a future update.
</p>
<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.
</p>
</div>

View file

@ -108,9 +108,9 @@ export default function UploadPage() {
case "queued":
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
case "processing":
return <Loader2 className="size-4 animate-spin text-blue-500" />;
return <Loader2 className="size-4 animate-spin text-primary" />;
case "complete":
return <CheckCircle className="size-4 text-green-500" />;
return <CheckCircle className="size-4 text-emerald-500" />;
case "error":
return <AlertCircle className="size-4 text-red-500" />;
default:
@ -123,9 +123,10 @@ export default function UploadPage() {
<Header
title="Upload"
description="Upload scanned response cards for OCR processing"
icon={Upload}
/>
<Card>
<Card variant="glass">
<CardContent className="pt-6">
<div
onDragOver={(e) => {
@ -135,16 +136,16 @@ export default function UploadPage() {
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
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
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
? "border-primary bg-primary/5 scale-[1.01]"
: "border-muted-foreground/20 hover:border-primary/40 hover:bg-primary/[2%]"
)}
>
<div className="mb-4 rounded-full bg-muted p-4">
<Upload className="size-8 text-muted-foreground" />
<div className="mb-4 flex size-16 items-center justify-center rounded-2xl bg-primary/10">
<Upload className="size-7 text-primary" />
</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
</h3>
<p className="mb-4 text-sm text-muted-foreground">
@ -152,6 +153,7 @@ export default function UploadPage() {
</p>
<Button
variant="outline"
className="rounded-xl"
onClick={() => fileInputRef.current?.click()}
>
<FolderOpen className="mr-2 size-4" />
@ -169,7 +171,7 @@ export default function UploadPage() {
{files.length > 0 && (
<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">
{files.length} file{files.length !== 1 ? "s" : ""} selected
</h4>
@ -183,6 +185,7 @@ export default function UploadPage() {
</Button>
<Button
size="sm"
className="rounded-xl"
onClick={handleUpload}
disabled={uploading}
>
@ -204,15 +207,15 @@ export default function UploadPage() {
{files.map((file, i) => (
<div
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" ? (
<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="text-xs text-muted-foreground">
<span className="flex-1 min-w-0 truncate text-sm">{file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{(file.size / 1024).toFixed(0)} KB
</span>
<Button
@ -230,10 +233,12 @@ export default function UploadPage() {
</CardContent>
</Card>
{/* Processing Queue */}
<Card>
<Card variant="glass">
<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>
<CardContent>
{jobs.length === 0 ? (
@ -245,29 +250,29 @@ export default function UploadPage() {
{jobs.map((job) => (
<div
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-1 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{job.fileName}
</span>
<Badge
variant="secondary"
className={cn(
"capitalize text-xs",
job.status === "complete" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
job.status === "error" && "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
job.status === "processing" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
)}
>
{job.status}
</Badge>
</div>
{job.totalPages > 0 && (
<div className="mt-1">
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 flex-1 min-w-0">
{getStatusIcon(job.status)}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">
{job.fileName}
</span>
<Badge
variant="secondary"
className={cn(
"capitalize text-xs",
job.status === "complete" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-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>
</div>
{job.totalPages > 0 && (
<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-full rounded-full bg-primary transition-all"
@ -276,17 +281,17 @@ export default function UploadPage() {
}}
/>
</div>
<span className="text-xs text-muted-foreground">
{job.processed}/{job.totalPages} pages
<span className="shrink-0 text-xs text-muted-foreground">
{job.processed}/{job.totalPages}
</span>
</div>
</div>
)}
{job.error && (
<p className="mt-1 text-xs text-red-500">{job.error}</p>
)}
)}
{job.error && (
<p className="mt-1 text-xs text-red-500">{job.error}</p>
)}
</div>
</div>
<span className="text-xs text-muted-foreground">
<span className="shrink-0 text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleTimeString()}
</span>
</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,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { ResponseCard } from "./columns";
type DataTableProps<TData> = {
@ -135,15 +134,16 @@ export function DataTable<TData extends ResponseCard>({
return (
<div className="space-y-4">
{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">
{selectedCount} row{selectedCount !== 1 ? "s" : ""} selected
</span>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{onBulkMarkReviewed && (
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onBulkMarkReviewed(selectedIds)}
>
<CheckCircle className="mr-1.5 size-4" />
@ -154,6 +154,7 @@ export function DataTable<TData extends ResponseCard>({
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onBulkMarkExported(selectedIds)}
>
<Download className="mr-1.5 size-4" />
@ -164,7 +165,7 @@ export function DataTable<TData extends ResponseCard>({
<Button
variant="outline"
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)}
>
<Trash2 className="mr-1.5 size-4" />
@ -174,77 +175,80 @@ export function DataTable<TData extends ResponseCard>({
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => table.toggleAllPageRowsSelected(false)}
>
Clear selection
Clear
</Button>
</div>
</div>
)}
<div className="rounded-lg border border-border bg-card">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-4 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</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>
<div className="glass-card overflow-hidden rounded-2xl">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="transition-colors hover:bg-muted/30"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4 py-3">
{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 className="flex flex-col gap-4 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-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>
{totalCount === 0
? "0 results"
: `${start}${end} of ${totalCount}`}
</span>
<div className="flex items-center gap-2">
<span>Rows per page</span>
<span className="text-xs">Rows</span>
<select
value={limit}
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) => (
<option key={size} value={size}>
@ -254,15 +258,16 @@ export function DataTable<TData extends ResponseCard>({
</select>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onPageChange?.(page - 1)}
disabled={!canPrev}
>
<ChevronLeft className="size-4" />
Previous
<span className="hidden sm:inline ml-1">Prev</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
@ -281,7 +286,7 @@ export function DataTable<TData extends ResponseCard>({
key={p}
variant={p === page ? "default" : "ghost"}
size="sm"
className="size-8 p-0"
className="size-8 rounded-xl p-0"
onClick={() => onPageChange?.(p)}
>
{p}
@ -292,10 +297,11 @@ export function DataTable<TData extends ResponseCard>({
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onPageChange?.(page + 1)}
disabled={!canNext}
>
Next
<span className="hidden sm:inline mr-1">Next</span>
<ChevronRight className="size-4" />
</Button>
</div>

View file

@ -2,7 +2,7 @@
import * as React from "react";
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 { Button } from "@/components/ui/button";
@ -127,150 +127,154 @@ export function Filters({
};
return (
<div className="flex flex-col gap-4">
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search name, email, phone, city..."
value={search}
onChange={handleSearchChange}
className="pl-9"
/>
</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 className="glass-card rounded-2xl p-4 sm:p-5">
<div className="flex flex-col gap-4">
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search name, email, phone..."
value={search}
onChange={handleSearchChange}
className="pl-9 rounded-xl"
/>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
OCR:
</span>
{OCR_OPTIONS.map((opt) => (
<Badge
key={opt.value || "all"}
variant={ocrStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors",
ocrStatus === opt.value && "bg-primary text-primary-foreground"
)}
onClick={() => updateParams({ ocrStatus: opt.value || undefined })}
>
{opt.label}
</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)
}
<div className="flex items-center gap-2 overflow-x-auto pb-1 sm:pb-0">
<SlidersHorizontal className="size-3.5 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Review:
</span>
{REVIEW_OPTIONS.map((opt) => (
<Badge
key={opt.value || "all"}
variant={reviewStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer shrink-0 transition-all min-h-[28px] px-2.5 text-xs",
reviewStatus === opt.value && "bg-primary text-primary-foreground shadow-sm"
)}
onClick={() => updateParams({ reviewStatus: opt.value || undefined })}
>
{col.label}
</DropdownMenuCheckboxItem>
{opt.label}
</Badge>
))}
</DropdownMenuContent>
</DropdownMenu>
{onExportCsv && (
<Button variant="outline" size="sm" onClick={onExportCsv}>
<Download className="mr-2 size-4" />
Export CSV
</Button>
)}
</div>
<div className="mx-1 h-4 w-px bg-border/50 shrink-0 hidden sm:block" />
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
OCR:
</span>
{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>

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 {
title: string;
description?: string;
icon?: LucideIcon;
children?: React.ReactNode;
}
export function Header({ title, description, children }: HeaderProps) {
export function Header({ title, description, icon: Icon, children }: HeaderProps) {
return (
<header className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
{title}
</h1>
{description && (
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
{Icon && (
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Icon className="size-[18px]" />
</div>
)}
<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>
{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}
</div>
)}

View file

@ -37,7 +37,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-0.5 px-2">
<nav className="flex flex-col gap-1 px-3">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
@ -50,13 +50,13 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
href={item.href}
onClick={onNavigate}
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
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
? "bg-primary/10 text-primary shadow-sm dark:bg-primary/15"
: "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}
</Link>
);
@ -74,14 +74,14 @@ function ThemeToggle() {
render={
<Button
variant="ghost"
size="icon-sm"
className="text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
size="icon"
className="size-9 rounded-xl text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
/>
}
>
<Sun className="size-4 dark:hidden" />
<Moon className="hidden size-4 dark:block" />
<Sun className="size-[18px] dark:hidden" />
<Moon className="hidden size-[18px] dark:block" />
<span className="sr-only">Toggle theme</span>
</TooltipTrigger>
<TooltipContent side="right">
@ -94,24 +94,39 @@ function ThemeToggle() {
function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
return (
<>
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-sidebar-border px-4">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<ScanLine className="size-4" />
</div>
<span className="font-semibold text-sidebar-foreground">
<div className="flex h-16 shrink-0 items-center gap-3 px-5">
<div className="flex size-9 items-center justify-center rounded-xl gradient-banner shadow-md">
<ScanLine className="size-[18px] text-white" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold tracking-tight text-sidebar-foreground">
Echo OCR
</span>
<span className="text-[11px] text-muted-foreground">
Document Scanner
</span>
</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} />
</div>
<div className="shrink-0 border-t border-sidebar-border p-3">
<div className="flex items-center justify-between px-2">
<span className="text-xs text-muted-foreground">Theme</span>
<div className="shrink-0 border-t border-sidebar-border/50 p-3">
<div className="flex items-center justify-between rounded-xl px-2 py-1">
<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 />
</div>
</div>
@ -124,14 +139,13 @@ export function Sidebar() {
return (
<>
{/* Mobile menu trigger */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button
variant="ghost"
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>
<SheetContent
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}
>
<div className="flex h-full w-full flex-col">
@ -149,10 +163,9 @@ export function Sidebar() {
</SheetContent>
</Sheet>
{/* Desktop sidebar */}
<aside
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"
)}
>

View file

@ -5,14 +5,21 @@ import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
}: React.ComponentProps<"div"> & {
size?: "default" | "sm"
variant?: "default" | "glass"
}) {
return (
<div
data-slot="card"
data-size={size}
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
)}
{...props}

View file

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