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:
parent
1ec50d9033
commit
2db14b7336
20 changed files with 878 additions and 586 deletions
|
|
@ -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
51
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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("")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 || "";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -126,4 +126,99 @@
|
||||||
html {
|
html {
|
||||||
@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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
33
src/components/cards/dashboard-hero.tsx
Normal file
33
src/components/cards/dashboard-hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
61
src/components/cards/quick-actions.tsx
Normal file
61
src/components/cards/quick-actions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/cards/stat-cards.tsx
Normal file
113
src/components/cards/stat-cards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue