Build Echo OCR app: full-stack response card scanner

- Next.js 15 + Tailwind v4 + shadcn/ui + TanStack Table
- Prisma + PostgreSQL schema for ResponseCard, ProcessingJob, AppSettings
- Ollama vision integration with structured extraction prompts
- PDF-to-image pipeline with pdf2pic + sharp
- MinIO S3 storage for uploaded files and extracted images
- Chokidar-based folder monitoring for automatic processing
- REST API (9 endpoints) ready for Monday.com integration
- Dashboard with filterable data table, chip filters, bulk actions, CSV export
- Card detail page with editable fields and side-by-side scanned images
- Upload page with drag-and-drop and real-time processing queue
- Settings page for Ollama, folder watch, and Monday.com config
- Dockerfile (multi-stage) + docker-compose for local dev
- Configured for Coolify deployment at echo.stillwell.cloud

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-03-10 07:17:45 -05:00
parent 6a6a140139
commit 7e423a3e50
65 changed files with 9354 additions and 107 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
.next
.git
.env
.env.local
*.md

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Database (shared PostgreSQL on CT 102)
DATABASE_URL="postgresql://echos_ocr:YOUR_PASSWORD@192.168.68.102:5432/echos_ocr"
# Ollama Vision LLM (CT 108)
OLLAMA_BASE_URL="http://192.168.68.108:11434"
OLLAMA_MODEL="llava:7b"
# MinIO S3 Storage (CT 105)
MINIO_ENDPOINT="192.168.68.105"
MINIO_PORT="9000"
MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="YOUR_MINIO_SECRET"
MINIO_BUCKET="echos-ocr"
# Folder Watch (optional, mount a host path into the container)
WATCH_DIR=""

8
.gitignore vendored
View file

@ -30,8 +30,10 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# env files
.env
.env.local
.env.*.local
# vercel
.vercel
@ -39,3 +41,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

41
Dockerfile Normal file
View file

@ -0,0 +1,41 @@
FROM node:18-alpine AS base
RUN apk add --no-cache graphicsmagick ghostscript
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npx prisma generate && npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
RUN mkdir -p /data/watch && chown -R nextjs:nodejs /data
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -1,36 +1,72 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Echo OCR
## Getting Started
OCR-powered response card scanner and management tool for Echo Life Church.
First, run the development server:
Scans front/back of paper response cards (PDF or images), extracts structured data using a local Ollama vision model, stores results in PostgreSQL, and serves them through a modern filterable table UI.
## Stack
- **Next.js 15** (App Router) + **Tailwind CSS v4** + **shadcn/ui**
- **TanStack Table** for data tables with filtering, sorting, pagination
- **Prisma** + **PostgreSQL** for data storage
- **Ollama** (local LLM) for OCR via vision models (LLaVA 7B recommended)
- **MinIO** (S3-compatible) for PDF/image storage
- **Docker** for deployment via Coolify
## Local Development
### Prerequisites
- Node.js 18+
- Docker + Docker Compose (for PostgreSQL + MinIO)
- Ollama running locally with a vision model (`ollama pull llava:7b`)
- GraphicsMagick (`brew install graphicsmagick`) for PDF-to-image conversion
### Setup
```bash
# Install dependencies
npm install
# Start local PostgreSQL + MinIO
docker compose up -d postgres minio
# Create MinIO bucket (visit http://localhost:9001, login minioadmin/minioadmin)
# Push database schema
npm run db:push
# Start dev server
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://localhost:3000](http://localhost:3000).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### Environment Variables
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Copy `.env.example` to `.env` and configure:
## Learn More
```bash
cp .env.example .env
```
To learn more about Next.js, take a look at the following resources:
## Deployment (Coolify)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
See the deployment section in the project plan for step-by-step Coolify setup instructions including PostgreSQL, MinIO bucket creation, Ollama configuration, and Traefik routing.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## API Endpoints
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/cards` | List cards (filters, pagination, search) |
| POST | `/api/cards` | Create a card |
| GET | `/api/cards/[id]` | Get card with presigned image URLs |
| PUT | `/api/cards/[id]` | Update card fields |
| DELETE | `/api/cards/[id]` | Delete card and images |
| POST | `/api/cards/[id]/export` | Mark card as exported |
| POST | `/api/upload` | Upload PDF/images for OCR |
| GET | `/api/jobs` | List processing jobs |
| GET | `/api/stats` | Card count statistics |
| GET/PUT | `/api/settings` | App settings |
| POST | `/api/watch` | Start/stop folder monitoring |
| GET | `/api/images/[...path]` | Proxy images from MinIO |

55
docker-compose.yml Normal file
View file

@ -0,0 +1,55 @@
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://echos_ocr:localdev@postgres:5432/echos_ocr
- OLLAMA_BASE_URL=http://host.docker.internal:11434
- OLLAMA_MODEL=llava:7b
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_BUCKET=echos-ocr
- WATCH_DIR=/data/watch
volumes:
- watch-data:/data/watch
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_started
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=echos_ocr
- POSTGRES_PASSWORD=localdev
- POSTGRES_DB=echos_ocr
ports:
- "5432:5432"
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U echos_ocr"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio-data:/data
volumes:
pg-data:
minio-data:
watch-data:

View file

@ -1,7 +1,20 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
images: {
remotePatterns: [
{
protocol: "http",
hostname: "**",
},
{
protocol: "https",
hostname: "**",
},
],
},
serverExternalPackages: ["sharp", "pdf2pic", "chokidar", "pg"],
};
export default nextConfig;

3680
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,25 +4,46 @@
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "npx prisma generate && next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"db:migrate": "npx prisma migrate dev",
"db:push": "npx prisma db push",
"db:studio": "npx prisma studio",
"postinstall": "npx prisma generate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"@base-ui/react": "^1.2.0",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.577.0",
"minio": "^8.0.7",
"next": "16.1.6",
"next-themes": "^0.4.6",
"pdf2pic": "^3.2.0",
"pg": "^8.20.0",
"prisma": "^7.4.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^4.0.2",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",

14
prisma.config.ts Normal file
View file

@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

82
prisma/schema.prisma Normal file
View file

@ -0,0 +1,82 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model ResponseCard {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Personal Info (Response Card side)
name String?
gender String?
dateOfBirth String?
maritalStatus String?
maritalStatusOther String?
visitType String?
cellPhone String?
homePhone String?
email String?
address String?
aptNumber String?
city String?
state String?
zip String?
prayerRequests String?
prayerForTeam Boolean @default(false)
prayerConfidential Boolean @default(false)
// Survey Info (Easter Survey side)
messageTopics Json?
messageTopicsOther String?
nextStep Json?
attendanceDuration String?
campusPreference Json?
campusPreferenceOther String?
howHeard Json?
howHeardOther String?
serviceAttended String?
// Meta
sourceFile String?
frontImagePath String?
backImagePath String?
ocrStatus String @default("pending")
reviewStatus String @default("unreviewed")
ocrConfidence Float?
ocrError String?
rawOcrResponse Json?
@@index([ocrStatus])
@@index([reviewStatus])
@@index([name])
@@index([createdAt])
}
model ProcessingJob {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fileName String
filePath String
status String @default("queued")
totalPages Int @default(0)
processed Int @default(0)
error String?
cardIds Json?
@@index([status])
}
model AppSettings {
id String @id @default("singleton")
ollamaUrl String @default("http://192.168.68.108:11434")
model String @default("llava:7b")
watchDir String @default("")
watching Boolean @default(false)
}

View file

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
});
if (!card) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
const updated = await prisma.responseCard.update({
where: { id },
data: { reviewStatus: "exported" },
});
return NextResponse.json(updated);
} catch (error) {
console.error("[cards/[id]/export POST]", error);
return NextResponse.json(
{ error: "Failed to mark card as exported" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getPresignedUrl, deleteObject } from "@/lib/minio";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
});
if (!card) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
const [frontImageUrl, backImageUrl] = await Promise.all([
card.frontImagePath ? getPresignedUrl(card.frontImagePath) : null,
card.backImagePath ? getPresignedUrl(card.backImagePath) : null,
]);
return NextResponse.json({
...card,
frontImageUrl,
backImageUrl,
});
} catch (error) {
console.error("[cards/[id] GET]", error);
return NextResponse.json(
{ error: "Failed to fetch card" },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
});
if (!card) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
const stringFields = [
"name",
"gender",
"dateOfBirth",
"maritalStatus",
"maritalStatusOther",
"visitType",
"cellPhone",
"homePhone",
"email",
"address",
"aptNumber",
"city",
"state",
"zip",
"prayerRequests",
"messageTopicsOther",
"attendanceDuration",
"campusPreferenceOther",
"howHeardOther",
"serviceAttended",
"ocrStatus",
"reviewStatus",
"ocrError",
];
for (const field of stringFields) {
if (body[field] != null) data[field] = String(body[field]);
}
if (body.prayerForTeam != null) data.prayerForTeam = Boolean(body.prayerForTeam);
if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential);
if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence);
if (body.messageTopics != null) data.messageTopics = body.messageTopics;
if (body.nextStep != null) data.nextStep = body.nextStep;
if (body.campusPreference != null) data.campusPreference = body.campusPreference;
if (body.howHeard != null) data.howHeard = body.howHeard;
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
const updated = await prisma.responseCard.update({
where: { id },
data: data as Parameters<typeof prisma.responseCard.update>[0]["data"],
});
return NextResponse.json(updated);
} catch (error) {
console.error("[cards/[id] PUT]", error);
return NextResponse.json(
{ error: "Failed to update card" },
{ status: 500 }
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const card = await prisma.responseCard.findUnique({
where: { id },
});
if (!card) {
return NextResponse.json({ error: "Card not found" }, { status: 404 });
}
const deletePromises: Promise<void>[] = [];
if (card.frontImagePath) deletePromises.push(deleteObject(card.frontImagePath));
if (card.backImagePath) deletePromises.push(deleteObject(card.backImagePath));
await Promise.allSettled(deletePromises);
await prisma.responseCard.delete({
where: { id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("[cards/[id] DELETE]", error);
return NextResponse.json(
{ error: "Failed to delete card" },
{ status: 500 }
);
}
}

130
src/app/api/cards/route.ts Normal file
View file

@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
const search = searchParams.get("search")?.trim() || undefined;
const ocrStatus = searchParams.get("ocrStatus") || undefined;
const reviewStatus = searchParams.get("reviewStatus") || undefined;
const attendanceDuration = searchParams.get("attendanceDuration") || undefined;
const visitType = searchParams.get("visitType") || undefined;
const serviceAttended = searchParams.get("serviceAttended") || undefined;
const sortBy = searchParams.get("sortBy") ?? "createdAt";
const sortOrder = searchParams.get("sortOrder") ?? "desc";
const validSortFields = [
"createdAt",
"updatedAt",
"name",
"ocrStatus",
"reviewStatus",
"attendanceDuration",
"visitType",
"serviceAttended",
];
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
const order = sortOrder === "asc" ? "asc" : "desc";
const where: Record<string, unknown> = {};
if (ocrStatus) where.ocrStatus = ocrStatus;
if (reviewStatus) where.reviewStatus = reviewStatus;
if (attendanceDuration) where.attendanceDuration = attendanceDuration;
if (visitType) where.visitType = visitType;
if (serviceAttended) where.serviceAttended = serviceAttended;
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
{ cellPhone: { contains: search, mode: "insensitive" } },
{ homePhone: { contains: search, mode: "insensitive" } },
{ address: { contains: search, mode: "insensitive" } },
{ city: { contains: search, mode: "insensitive" } },
{ state: { contains: search, mode: "insensitive" } },
{ zip: { contains: search, mode: "insensitive" } },
];
}
const [cards, total] = await Promise.all([
prisma.responseCard.findMany({
where,
orderBy: { [orderByField]: order },
skip: (page - 1) * limit,
take: limit,
}),
prisma.responseCard.count({ where }),
]);
return NextResponse.json({ cards, total, page, limit });
} catch (error) {
console.error("[cards GET]", error);
return NextResponse.json(
{ error: "Failed to fetch cards" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
const stringFields = [
"name",
"gender",
"dateOfBirth",
"maritalStatus",
"maritalStatusOther",
"visitType",
"cellPhone",
"homePhone",
"email",
"address",
"aptNumber",
"city",
"state",
"zip",
"prayerRequests",
"messageTopicsOther",
"attendanceDuration",
"campusPreferenceOther",
"howHeardOther",
"serviceAttended",
"sourceFile",
"frontImagePath",
"backImagePath",
"ocrStatus",
"reviewStatus",
"ocrError",
];
for (const field of stringFields) {
if (body[field] != null) data[field] = String(body[field]);
}
if (body.prayerForTeam != null) data.prayerForTeam = Boolean(body.prayerForTeam);
if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential);
if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence);
if (body.messageTopics != null) data.messageTopics = body.messageTopics;
if (body.nextStep != null) data.nextStep = body.nextStep;
if (body.campusPreference != null) data.campusPreference = body.campusPreference;
if (body.howHeard != null) data.howHeard = body.howHeard;
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
const card = await prisma.responseCard.create({
data: data as Parameters<typeof prisma.responseCard.create>[0]["data"],
});
return NextResponse.json(card, { status: 201 });
} catch (error) {
console.error("[cards POST]", error);
return NextResponse.json(
{ error: "Failed to create card" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getPresignedUrl } from "@/lib/minio";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: pathSegments } = await params;
const key = pathSegments.join("/");
if (!key) {
return NextResponse.json({ error: "Missing path" }, { status: 400 });
}
const url = await getPresignedUrl(key);
return NextResponse.redirect(url);
} catch (error) {
console.error("[images GET]", error);
return NextResponse.json(
{ error: "Failed to get image" },
{ status: 500 }
);
}
}

31
src/app/api/jobs/route.ts Normal file
View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
const status = searchParams.get("status") || undefined;
const where = status ? { status } : {};
const [jobs, total] = await Promise.all([
prisma.processingJob.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.processingJob.count({ where }),
]);
return NextResponse.json({ jobs, total, page, limit });
} catch (error) {
console.error("[jobs GET]", error);
return NextResponse.json(
{ error: "Failed to fetch jobs" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (!settings) {
const created = await prisma.appSettings.create({
data: { id: "singleton" },
});
return NextResponse.json(created);
}
return NextResponse.json(settings);
} catch (error) {
console.error("[settings GET]", error);
return NextResponse.json(
{ error: "Failed to fetch settings" },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const data: Record<string, unknown> = {};
if (body.ollamaUrl != null) data.ollamaUrl = String(body.ollamaUrl);
if (body.model != null) data.model = String(body.model);
if (body.watchDir != null) data.watchDir = String(body.watchDir);
if (body.watching != null) data.watching = Boolean(body.watching);
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
ollamaUrl: (data.ollamaUrl as string) ?? "http://192.168.68.108:11434",
model: (data.model as string) ?? "llava:7b",
watchDir: (data.watchDir as string) ?? "",
watching: (data.watching as boolean) ?? false,
},
update: data as Parameters<typeof prisma.appSettings.update>[0]["data"],
});
return NextResponse.json(settings);
} catch (error) {
console.error("[settings PUT]", error);
return NextResponse.json(
{ error: "Failed to update settings" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const [total, byOcrStatus, byReviewStatus] = await Promise.all([
prisma.responseCard.count(),
prisma.responseCard.groupBy({
by: ["ocrStatus"],
_count: { id: true },
}),
prisma.responseCard.groupBy({
by: ["reviewStatus"],
_count: { id: true },
}),
]);
const ocrStatusCounts = Object.fromEntries(
byOcrStatus.map((r) => [r.ocrStatus, r._count.id])
);
const reviewStatusCounts = Object.fromEntries(
byReviewStatus.map((r) => [r.reviewStatus, r._count.id])
);
return NextResponse.json({
total,
byOcrStatus: ocrStatusCounts,
byReviewStatus: reviewStatusCounts,
});
} catch (error) {
console.error("[stats GET]", error);
return NextResponse.json(
{ error: "Failed to fetch stats" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { processFile } from "@/lib/ocr";
const ALLOWED_TYPES = [
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
];
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const files = [
...(formData.getAll("files") as File[]),
...(formData.get("file") ? [formData.get("file") as File] : []),
].filter(Boolean);
if (!files?.length) {
return NextResponse.json(
{ error: "No files provided" },
{ status: 400 }
);
}
const jobIds: string[] = [];
for (const file of files) {
if (!(file instanceof File)) continue;
const contentType = file.type;
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json(
{ error: `Invalid file type: ${contentType}. Allowed: PDF, JPEG, PNG, WebP` },
{ status: 400 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const fileName = file.name || `upload-${Date.now()}`;
const isPdf = contentType === "application/pdf";
const job = await prisma.processingJob.create({
data: {
fileName,
filePath: `sources/${fileName}`,
status: "queued",
},
});
jobIds.push(job.id);
processFile(job.id, fileName, buffer, isPdf).catch((err) => {
console.error(`[upload] Background processing failed for job ${job.id}:`, err);
});
}
return NextResponse.json({ jobIds });
} catch (error) {
console.error("[upload POST]", error);
return NextResponse.json(
{ error: "Failed to upload files" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { startWatching, stopWatching, isWatching } from "@/lib/watcher";
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const action = body.action as string | undefined;
const watchDir = body.watchDir as string | undefined;
if (!action || !["start", "stop"].includes(action)) {
return NextResponse.json(
{ error: "Invalid action. Use 'start' or 'stop'" },
{ status: 400 }
);
}
if (action === "start") {
if (!watchDir) {
return NextResponse.json(
{ error: "watchDir is required to start watching" },
{ status: 400 }
);
}
await startWatching(watchDir);
} else {
await stopWatching();
}
return NextResponse.json({
watching: isWatching(),
watchDir: watchDir || "",
});
} catch (error) {
console.error("[watch POST]", error);
const message = error instanceof Error ? error.message : "Failed to update watch settings";
return NextResponse.json({ error: message }, { status: 500 });
}
}

410
src/app/cards/[id]/page.tsx Normal file
View file

@ -0,0 +1,410 @@
"use client";
import * as React from "react";
import { useParams, useRouter } from "next/navigation";
import Image from "next/image";
import { toast } from "sonner";
import { ArrowLeft, ArrowRight, Check, Download, ImageIcon, ChevronDown, ChevronUp } from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
type CardData = {
id: string;
name: string | null;
gender: string | null;
dateOfBirth: string | null;
maritalStatus: string | null;
maritalStatusOther: string | null;
visitType: string | null;
cellPhone: string | null;
homePhone: string | null;
email: string | null;
address: string | null;
aptNumber: string | null;
city: string | null;
state: string | null;
zip: string | null;
prayerRequests: string | null;
prayerForTeam: boolean;
prayerConfidential: boolean;
messageTopics: string[] | null;
messageTopicsOther: string | null;
nextStep: string[] | null;
attendanceDuration: string | null;
campusPreference: string[] | null;
campusPreferenceOther: string | null;
howHeard: string[] | null;
howHeardOther: string | null;
serviceAttended: string | null;
ocrStatus: string;
reviewStatus: string;
ocrConfidence: number | null;
ocrError: string | null;
rawOcrResponse: Record<string, unknown> | null;
frontImageUrl: string | null;
backImageUrl: string | null;
};
export default function CardDetailPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [card, setCard] = React.useState<CardData | null>(null);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [edits, setEdits] = React.useState<Record<string, string>>({});
const [showRawOcr, setShowRawOcr] = React.useState(false);
const fetchCard = React.useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/cards/${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setCard(data);
setEdits({});
} catch {
toast.error("Failed to load card");
} finally {
setLoading(false);
}
}, [id]);
React.useEffect(() => {
fetchCard();
}, [fetchCard]);
const getValue = (field: keyof CardData): string => {
if (field in edits) return edits[field];
const val = card?.[field];
if (val === null || val === undefined) return "";
return String(val);
};
const setField = (field: string, value: string) => {
setEdits((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (Object.keys(edits).length === 0) return;
setSaving(true);
try {
const res = await fetch(`/api/cards/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(edits),
});
if (!res.ok) throw new Error();
toast.success("Card updated");
await fetchCard();
} catch {
toast.error("Failed to save");
} finally {
setSaving(false);
}
};
const handleMarkReviewed = async () => {
await fetch(`/api/cards/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewStatus: "reviewed" }),
});
toast.success("Marked as reviewed");
fetchCard();
};
const handleExport = async () => {
await fetch(`/api/cards/${id}/export`, { method: "POST" });
toast.success("Marked as exported");
fetchCard();
};
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-96" />
<Skeleton className="h-96" />
</div>
</div>
);
}
if (!card) {
return (
<div className="space-y-6">
<Header title="Card Not Found" />
<Button variant="outline" onClick={() => router.push("/")}>
<ArrowLeft className="mr-2 size-4" /> Back to Dashboard
</Button>
</div>
);
}
const confidence = card.ocrConfidence;
const ocrStatus = card.ocrStatus;
const reviewStatus = card.reviewStatus;
const hasEdits = Object.keys(edits).length > 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
<ArrowLeft className="mr-1 size-4" /> Back
</Button>
<div className="flex items-center gap-2">
<Badge variant="secondary" className={cn(
"capitalize",
ocrStatus === "complete" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
ocrStatus === "error" && "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
ocrStatus === "processing" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
)}>
OCR: {ocrStatus}
</Badge>
<Badge variant="secondary" className={cn(
"capitalize",
reviewStatus === "reviewed" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
reviewStatus === "exported" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
reviewStatus === "unreviewed" && "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
)}>
{reviewStatus}
</Badge>
{confidence != null && (
<Badge variant="outline" className={cn(
confidence < 50 && "text-red-600 border-red-200",
confidence >= 50 && confidence <= 75 && "text-amber-600 border-amber-200",
confidence > 75 && "text-green-600 border-green-200",
)}>
{Math.round(confidence)}% confidence
</Badge>
)}
</div>
</div>
<Header title={String(card.name || "Unnamed Card")}>
<div className="flex gap-2">
{reviewStatus !== "reviewed" && (
<Button variant="outline" size="sm" onClick={handleMarkReviewed}>
<Check className="mr-1 size-4" /> Mark Reviewed
</Button>
)}
{reviewStatus !== "exported" && (
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="mr-1 size-4" /> Export
</Button>
)}
{hasEdits && (
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Changes"}
</Button>
)}
</div>
</Header>
<div className="grid gap-6 xl:grid-cols-2">
{/* Scanned Images */}
<Card>
<CardHeader>
<CardTitle className="text-base">Scanned Images</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<ImagePanel label="Response Card (Back)" url={card.backImageUrl as string | null} />
<ImagePanel label="Survey (Front)" url={card.frontImageUrl as string | null} />
</div>
</CardContent>
</Card>
{/* Personal Info */}
<Card>
<CardHeader>
<CardTitle className="text-base">Personal Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} />
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} />
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} />
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} />
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} />
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", 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)} />
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} />
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} />
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} />
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} />
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} />
</div>
<Separator />
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Prayer Requests</Label>
<Textarea
value={getValue("prayerRequests") as string || ""}
onChange={(e) => setField("prayerRequests", e.target.value)}
rows={3}
/>
</div>
</CardContent>
</Card>
{/* Survey Info */}
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle className="text-base">Survey Responses</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Message Topics</Label>
<div className="flex flex-wrap gap-1.5">
{card.messageTopics && card.messageTopics.length > 0 ? card.messageTopics.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">{t}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Next Steps</Label>
<div className="flex flex-wrap gap-1.5">
{card.nextStep && card.nextStep.length > 0 ? card.nextStep.map((s) => (
<Badge key={s} variant="secondary" className="text-xs">{s}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} />
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">Campus Preference</Label>
<div className="flex flex-wrap gap-1.5">
{card.campusPreference && card.campusPreference.length > 0 ? card.campusPreference.map((c) => (
<Badge key={c} variant="secondary" className="text-xs">{c}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">How Heard</Label>
<div className="flex flex-wrap gap-1.5">
{card.howHeard && card.howHeard.length > 0 ? card.howHeard.map((h) => (
<Badge key={h} variant="secondary" className="text-xs">{h}</Badge>
)) : (
<span className="text-sm text-muted-foreground">None selected</span>
)}
</div>
</div>
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} />
</div>
</CardContent>
</Card>
{/* Raw OCR Response */}
{card.rawOcrResponse && (
<Card className="xl:col-span-2">
<CardHeader>
<button
className="flex w-full items-center justify-between text-left"
onClick={() => setShowRawOcr(!showRawOcr)}
>
<CardTitle className="text-base">Raw OCR Response</CardTitle>
{showRawOcr ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
</button>
</CardHeader>
{showRawOcr && (
<CardContent>
<pre className="max-h-96 overflow-auto rounded-lg bg-muted p-4 text-xs">
{JSON.stringify(card.rawOcrResponse, null, 2)}
</pre>
</CardContent>
)}
</Card>
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between border-t pt-4">
<Button variant="outline" size="sm" onClick={() => router.push("/")}>
<ArrowLeft className="mr-1 size-4" /> All Cards
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled>
<ArrowLeft className="mr-1 size-4" /> Previous
</Button>
<Button variant="outline" size="sm" disabled>
Next <ArrowRight className="ml-1 size-4" />
</Button>
</div>
</div>
</div>
);
}
function ImagePanel({ label, url }: { label: string; url: string | null }) {
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
{url ? (
<div className="relative aspect-[3/4] overflow-hidden rounded-lg border bg-muted">
<Image src={url} alt={label} fill className="object-contain" unoptimized />
</div>
) : (
<div className="flex aspect-[3/4] items-center justify-center rounded-lg border bg-muted/50">
<ImageIcon className="size-12 text-muted-foreground/30" />
</div>
)}
</div>
);
}
function Field({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
<Input value={value || ""} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
<Select value={value || "__none__"} onValueChange={(v: string | null) => onChange(!v || v === "__none__" ? "" : v)}>
<SelectTrigger>
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{options.map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View file

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
import { Sidebar } from "@/components/layout/sidebar";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Echo OCR",
description: "Response card scanning and management",
};
export default function RootLayout({
@ -23,11 +25,18 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
{children}
<Providers>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 pt-14 md:pt-0 md:pl-[240px]">
<div className="p-6 md:p-8">{children}</div>
</main>
</div>
</Providers>
</body>
</html>
);

View file

@ -1,65 +1,25 @@
import Image from "next/image";
import { Suspense } from "react";
import { Header } from "@/components/layout/header";
import { DashboardContent } from "@/components/cards/dashboard-content";
import { Skeleton } from "@/components/ui/skeleton";
export default function Home() {
export default function DashboardPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
<div className="space-y-6">
<Header
title="Response Cards"
description="Manage scanned response cards from Echo Life Church"
/>
<Suspense
fallback={
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-[400px] w-full" />
</div>
}
>
<DashboardContent />
</Suspense>
</div>
);
}

222
src/app/settings/page.tsx Normal file
View file

@ -0,0 +1,222 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Loader2, Save, Wifi, WifiOff } from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
type Settings = {
ollamaUrl: string;
model: string;
watchDir: string;
watching: boolean;
};
export default function SettingsPage() {
const [settings, setSettings] = React.useState<Settings>({
ollamaUrl: "",
model: "",
watchDir: "",
watching: false,
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [ollamaStatus, setOllamaStatus] = React.useState<"unknown" | "connected" | "error">("unknown");
React.useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((data) => {
setSettings(data);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error();
toast.success("Settings saved");
} catch {
toast.error("Failed to save settings");
} finally {
setSaving(false);
}
};
const testOllamaConnection = async () => {
setOllamaStatus("unknown");
try {
const url = settings.ollamaUrl || "http://192.168.68.108:11434";
const res = await fetch(`${url}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
setOllamaStatus("connected");
toast.success("Connected to Ollama");
} else {
setOllamaStatus("error");
toast.error("Ollama responded with an error");
}
} catch {
setOllamaStatus("error");
toast.error("Cannot reach Ollama. Check the URL and ensure it's running.");
}
};
const toggleWatch = async () => {
try {
const action = settings.watching ? "stop" : "start";
const res = await fetch("/api/watch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, watchDir: settings.watchDir }),
});
if (!res.ok) throw new Error();
setSettings((s) => ({ ...s, watching: !s.watching }));
toast.success(action === "start" ? "Folder watching started" : "Folder watching stopped");
} catch {
toast.error("Failed to toggle folder watching");
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<Header title="Settings" description="Configure OCR and app settings">
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Save className="mr-2 size-4" />}
Save Settings
</Button>
</Header>
<div className="grid gap-6 lg:grid-cols-2">
{/* Ollama Configuration */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Ollama Configuration</CardTitle>
<CardDescription>Connect to your Ollama instance for OCR processing</CardDescription>
</div>
<Badge
variant="secondary"
className={
ollamaStatus === "connected"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: ollamaStatus === "error"
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
: ""
}
>
{ollamaStatus === "connected" && <Wifi className="mr-1 size-3" />}
{ollamaStatus === "error" && <WifiOff className="mr-1 size-3" />}
{ollamaStatus === "connected" ? "Connected" : ollamaStatus === "error" ? "Error" : "Not tested"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Ollama URL</Label>
<Input
value={settings.ollamaUrl}
onChange={(e) => setSettings((s) => ({ ...s, ollamaUrl: e.target.value }))}
placeholder="http://192.168.68.108:11434"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Model</Label>
<Input
value={settings.model}
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
placeholder="llava:7b"
/>
<p className="mt-1 text-xs text-muted-foreground">
Recommended: llava:7b, moondream, or llama3.2-vision
</p>
</div>
<Button variant="outline" size="sm" onClick={testOllamaConnection}>
Test Connection
</Button>
</CardContent>
</Card>
{/* Folder Watch */}
<Card>
<CardHeader>
<CardTitle className="text-base">Folder Monitoring</CardTitle>
<CardDescription>Automatically process new PDFs dropped into a folder</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Watch Directory</Label>
<Input
value={settings.watchDir}
onChange={(e) => setSettings((s) => ({ ...s, watchDir: e.target.value }))}
placeholder="/data/watch"
/>
<p className="mt-1 text-xs text-muted-foreground">
Absolute path on the server. Mount a host folder into the container.
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant={settings.watching ? "destructive" : "outline"}
size="sm"
onClick={toggleWatch}
disabled={!settings.watchDir}
>
{settings.watching ? "Stop Watching" : "Start Watching"}
</Button>
{settings.watching && (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
Active
</Badge>
)}
</div>
</CardContent>
</Card>
{/* Monday.com Integration (placeholder) */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">Monday.com Integration</CardTitle>
<CardDescription>Push scanned card data to Monday.com boards (coming soon)</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border-2 border-dashed border-muted-foreground/20 p-8 text-center">
<p className="text-sm text-muted-foreground">
API endpoints are ready. Configure Monday.com connection in a future update.
</p>
<p className="mt-2 text-xs text-muted-foreground">
Use the REST API at <code className="rounded bg-muted px-1 py-0.5">/api/cards</code> to
integrate with n8n, Zapier, or Monday.com directly.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

300
src/app/upload/page.tsx Normal file
View file

@ -0,0 +1,300 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
Upload,
FileText,
Image as ImageIcon,
X,
Loader2,
CheckCircle,
AlertCircle,
FolderOpen,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type ProcessingJob = {
id: string;
fileName: string;
status: string;
totalPages: number;
processed: number;
error: string | null;
createdAt: string;
};
export default function UploadPage() {
const [files, setFiles] = React.useState<File[]>([]);
const [uploading, setUploading] = React.useState(false);
const [jobs, setJobs] = React.useState<ProcessingJob[]>([]);
const [dragOver, setDragOver] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const fetchJobs = React.useCallback(async () => {
try {
const res = await fetch("/api/jobs?limit=20");
if (res.ok) {
const data = await res.json();
setJobs(data.jobs);
}
} catch {
/* ignore */
}
}, []);
React.useEffect(() => {
fetchJobs();
const interval = setInterval(fetchJobs, 3000);
return () => clearInterval(interval);
}, [fetchJobs]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const dropped = Array.from(e.dataTransfer.files).filter(
(f) =>
f.type === "application/pdf" ||
f.type.startsWith("image/")
);
setFiles((prev) => [...prev, ...dropped]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
setFiles((prev) => [...prev, ...selected]);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
if (files.length === 0) return;
setUploading(true);
try {
const formData = new FormData();
files.forEach((f) => formData.append("files", f));
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Upload failed");
}
const data = await res.json();
toast.success(`Uploaded ${files.length} file(s). Processing ${data.jobIds.length} job(s).`);
setFiles([]);
fetchJobs();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "queued":
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
case "processing":
return <Loader2 className="size-4 animate-spin text-blue-500" />;
case "complete":
return <CheckCircle className="size-4 text-green-500" />;
case "error":
return <AlertCircle className="size-4 text-red-500" />;
default:
return null;
}
};
return (
<div className="space-y-6">
<Header
title="Upload"
description="Upload scanned response cards for OCR processing"
/>
<Card>
<CardContent className="pt-6">
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center rounded-xl border-2 border-dashed p-12 text-center transition-colors",
dragOver
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
>
<div className="mb-4 rounded-full bg-muted p-4">
<Upload className="size-8 text-muted-foreground" />
</div>
<h3 className="mb-1 text-lg font-medium">
Drop files here or click to browse
</h3>
<p className="mb-4 text-sm text-muted-foreground">
Supports PDF, JPEG, PNG, and WebP files
</p>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<FolderOpen className="mr-2 size-4" />
Choose Files
</Button>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={handleFileSelect}
/>
</div>
{files.length > 0 && (
<div className="mt-6 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">
{files.length} file{files.length !== 1 ? "s" : ""} selected
</h4>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFiles([])}
>
Clear all
</Button>
<Button
size="sm"
onClick={handleUpload}
disabled={uploading}
>
{uploading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="mr-2 size-4" />
Upload & Process
</>
)}
</Button>
</div>
</div>
<div className="space-y-2">
{files.map((file, i) => (
<div
key={`${file.name}-${i}`}
className="flex items-center gap-3 rounded-lg border px-3 py-2"
>
{file.type === "application/pdf" ? (
<FileText className="size-4 text-red-500" />
) : (
<ImageIcon className="size-4 text-blue-500" />
)}
<span className="flex-1 truncate text-sm">{file.name}</span>
<span className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(0)} KB
</span>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeFile(i)}
>
<X className="size-3" />
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Processing Queue */}
<Card>
<CardHeader>
<CardTitle className="text-base">Processing Queue</CardTitle>
</CardHeader>
<CardContent>
{jobs.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No processing jobs yet. Upload files to get started.
</p>
) : (
<div className="space-y-2">
{jobs.map((job) => (
<div
key={job.id}
className="flex items-center gap-3 rounded-lg border px-4 py-3"
>
{getStatusIcon(job.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{job.fileName}
</span>
<Badge
variant="secondary"
className={cn(
"capitalize text-xs",
job.status === "complete" && "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
job.status === "error" && "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
job.status === "processing" && "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
)}
>
{job.status}
</Badge>
</div>
{job.totalPages > 0 && (
<div className="mt-1">
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{
width: `${Math.round((job.processed / job.totalPages) * 100)}%`,
}}
/>
</div>
<span className="text-xs text-muted-foreground">
{job.processed}/{job.totalPages} pages
</span>
</div>
</div>
)}
{job.error && (
<p className="mt-1 text-xs text-red-500">{job.error}</p>
)}
</div>
<span className="text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleTimeString()}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,330 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import {
MoreHorizontal,
Eye,
CheckCircle,
Trash2,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { ImagePreview } from "./image-preview";
function SortableHeader({
column,
children,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
};
children: React.ReactNode;
}) {
const sorted = column.getIsSorted();
return (
<Button
variant="ghost"
size="sm"
className="-ml-2 h-8 font-medium hover:bg-transparent"
onClick={column.getToggleSortingHandler()}
>
{children}
{sorted === "asc" ? (
<ArrowUp className="ml-1 size-4" />
) : sorted === "desc" ? (
<ArrowDown className="ml-1 size-4" />
) : (
<ArrowUpDown className="ml-1 size-4 opacity-50" />
)}
</Button>
);
}
export type ResponseCard = {
id: string;
createdAt: string;
name: string | null;
email: string | null;
cellPhone: string | null;
city: string | null;
state: string | null;
gender: string | null;
visitType: string | null;
attendanceDuration: string | null;
serviceAttended: string | null;
ocrStatus: string;
reviewStatus: string;
ocrConfidence: number | null;
frontImageUrl: string | null;
backImageUrl: string | null;
};
const ocrStatusVariant: Record<string, string> = {
pending: "bg-muted text-muted-foreground",
processing: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
complete: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
error: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
};
const reviewStatusVariant: Record<string, string> = {
unreviewed:
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
reviewed: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
exported: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
};
function getOcrBadgeClass(status: string): string {
return ocrStatusVariant[status.toLowerCase()] ?? ocrStatusVariant.pending;
}
function getReviewBadgeClass(status: string): string {
return (
reviewStatusVariant[status.toLowerCase()] ?? reviewStatusVariant.unreviewed
);
}
function getConfidenceClass(confidence: number | null): string {
if (confidence == null) return "text-muted-foreground";
if (confidence < 50) return "text-red-600 dark:text-red-400 font-medium";
if (confidence <= 75) return "text-amber-600 dark:text-amber-400 font-medium";
return "text-green-600 dark:text-green-400 font-medium";
}
export type ColumnActions = {
onViewDetails?: (card: ResponseCard) => void;
onMarkReviewed?: (card: ResponseCard) => void;
onDelete?: (card: ResponseCard) => void;
};
export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[] {
return [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={
table.getIsSomePageRowsSelected() &&
!table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "frontImageUrl",
id: "thumbnail",
header: "",
cell: ({ row }) => (
<ImagePreview
imageUrl={row.original.frontImageUrl ?? row.original.backImageUrl}
alt={row.original.name ?? "Card"}
/>
),
enableSorting: false,
enableHiding: true,
},
{
accessorKey: "name",
header: ({ column }) => (
<SortableHeader column={column}>Name</SortableHeader>
),
cell: ({ row }) => (
<span className="font-medium">
{row.getValue("name") ?? "—"}
</span>
),
},
{
accessorKey: "email",
header: ({ column }) => (
<SortableHeader column={column}>Email</SortableHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("email") ?? "—"}
</span>
),
},
{
accessorKey: "cellPhone",
header: ({ column }) => (
<SortableHeader column={column}>Phone</SortableHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("cellPhone") ?? "—"}
</span>
),
},
{
id: "location",
header: "Location",
cell: ({ row }) => {
const city = row.original.city;
const state = row.original.state;
const loc = [city, state].filter(Boolean).join(", ");
return (
<span className="text-muted-foreground">{loc || "—"}</span>
);
},
enableSorting: false,
},
{
accessorKey: "visitType",
header: ({ column }) => (
<SortableHeader column={column}>Visit Type</SortableHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("visitType") ?? "—"}
</span>
),
},
{
accessorKey: "attendanceDuration",
header: ({ column }) => (
<SortableHeader column={column}>Attendance</SortableHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("attendanceDuration") ?? "—"}
</span>
),
},
{
accessorKey: "serviceAttended",
header: ({ column }) => (
<SortableHeader column={column}>Service</SortableHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("serviceAttended") ?? "—"}
</span>
),
},
{
accessorKey: "ocrStatus",
header: ({ column }) => (
<SortableHeader column={column}>OCR Status</SortableHeader>
),
cell: ({ row }) => {
const status = String(row.getValue("ocrStatus") ?? "pending");
return (
<Badge
variant="secondary"
className={cn("capitalize", getOcrBadgeClass(status))}
>
{status}
</Badge>
);
},
},
{
accessorKey: "reviewStatus",
header: ({ column }) => (
<SortableHeader column={column}>Review</SortableHeader>
),
cell: ({ row }) => {
const status = String(row.getValue("reviewStatus") ?? "unreviewed");
return (
<Badge
variant="secondary"
className={cn("capitalize", getReviewBadgeClass(status))}
>
{status}
</Badge>
);
},
},
{
accessorKey: "ocrConfidence",
header: ({ column }) => (
<SortableHeader column={column}>Confidence</SortableHeader>
),
cell: ({ row }) => {
const val = row.getValue("ocrConfidence") as number | null;
const pct = val != null ? Math.round(val) : null;
return (
<span className={cn(getConfidenceClass(val ?? 0))}>
{pct != null ? `${pct}%` : "—"}
</span>
);
},
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="size-7"
aria-label="Open menu"
/>
}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{actions?.onViewDetails && (
<DropdownMenuItem
onClick={() => actions.onViewDetails?.(row.original)}
>
<Eye className="mr-2 size-4" />
View details
</DropdownMenuItem>
)}
{actions?.onMarkReviewed && (
<DropdownMenuItem
onClick={() => actions.onMarkReviewed?.(row.original)}
>
<CheckCircle className="mr-2 size-4" />
Mark reviewed
</DropdownMenuItem>
)}
{actions?.onDelete && (
<DropdownMenuItem
variant="destructive"
onClick={() => actions.onDelete?.(row.original)}
>
<Trash2 className="mr-2 size-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
),
enableSorting: false,
enableHiding: false,
},
];
}
export const columns = createColumns();

View file

@ -0,0 +1,195 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { DataTable } from "./data-table";
import { Filters } from "./filters";
import { createColumns, type ResponseCard } from "./columns";
const VISIT_TYPE_OPTIONS = [
"First/Second Time Guest",
"Update My Information",
];
const ATTENDANCE_OPTIONS = [
"Less than 6 months",
"6 Months - 1 Year",
"1-3 Years",
"4-6 Years",
"7+ Years",
];
const SERVICE_OPTIONS = ["A", "B", "C", "D"];
export function DashboardContent() {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const sortBy = searchParams.get("sortBy") || "createdAt";
const sortOrder = (searchParams.get("sortOrder") || "desc") as "asc" | "desc";
const [data, setData] = React.useState<ResponseCard[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [columnVisibility, setColumnVisibility] = React.useState<Record<string, boolean>>({});
const fetchCards = React.useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams(searchParams.toString());
if (!params.has("page")) params.set("page", "1");
if (!params.has("limit")) params.set("limit", "20");
const res = await fetch(`/api/cards?${params.toString()}`);
if (!res.ok) throw new Error("Failed to fetch cards");
const json = await res.json();
const cards = json.cards.map((card: Record<string, unknown>) => ({
...card,
createdAt: card.createdAt as string,
frontImageUrl: card.frontImagePath
? `/api/images/${card.frontImagePath}`
: null,
backImageUrl: card.backImagePath
? `/api/images/${card.backImagePath}`
: null,
}));
setData(cards);
setTotal(json.total);
} catch {
toast.error("Failed to load cards");
} finally {
setLoading(false);
}
}, [searchParams]);
React.useEffect(() => {
fetchCards();
}, [fetchCards]);
const updateUrl = (updates: Record<string, string | number>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
params.set(key, String(value));
}
router.push(`/?${params.toString()}`);
};
const handleBulkAction = async (
ids: string[],
action: "reviewed" | "exported" | "delete"
) => {
try {
if (action === "delete") {
await Promise.all(
ids.map((id) => fetch(`/api/cards/${id}`, { method: "DELETE" }))
);
toast.success(`Deleted ${ids.length} card(s)`);
} else {
await Promise.all(
ids.map((id) =>
fetch(`/api/cards/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewStatus: action }),
})
)
);
toast.success(`Marked ${ids.length} card(s) as ${action}`);
}
fetchCards();
} catch {
toast.error("Bulk action failed");
}
};
const handleExportCsv = () => {
if (data.length === 0) {
toast.error("No data to export");
return;
}
const headers = [
"Name", "Email", "Phone", "City", "State", "Gender", "DOB",
"Visit Type", "Attendance", "Service", "OCR Status", "Review Status",
"Confidence",
];
const rows = data.map((c) => [
c.name || "", c.email || "", c.cellPhone || "", c.city || "",
c.state || "", c.gender || "", "", c.visitType || "",
c.attendanceDuration || "", c.serviceAttended || "", c.ocrStatus,
c.reviewStatus, c.ocrConfidence?.toString() || "",
]);
const csv = [headers, ...rows].map((r) =>
r.map((v) => `"${v.replace(/"/g, '""')}"`).join(",")
).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `response-cards-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
toast.success("CSV exported");
};
const columns = React.useMemo(
() =>
createColumns({
onViewDetails: (card) => router.push(`/cards/${card.id}`),
onMarkReviewed: async (card) => {
await fetch(`/api/cards/${card.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewStatus: "reviewed" }),
});
toast.success("Marked as reviewed");
fetchCards();
},
onDelete: async (card) => {
await fetch(`/api/cards/${card.id}`, { method: "DELETE" });
toast.success("Card deleted");
fetchCards();
},
}),
[router, fetchCards]
);
return (
<div className="space-y-6">
<Filters
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
visitTypeOptions={VISIT_TYPE_OPTIONS}
attendanceDurationOptions={ATTENDANCE_OPTIONS}
serviceAttendedOptions={SERVICE_OPTIONS}
onExportCsv={handleExportCsv}
/>
<div className={loading ? "opacity-60 transition-opacity" : ""}>
<DataTable
data={data}
columns={columns}
totalCount={total}
page={page}
limit={limit}
sortBy={sortBy}
sortOrder={sortOrder}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
onPageChange={(p) => updateUrl({ page: p })}
onLimitChange={(l) => updateUrl({ limit: l, page: 1 })}
onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })}
onBulkMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
onBulkMarkExported={(ids) => handleBulkAction(ids, "exported")}
onBulkDelete={(ids) => handleBulkAction(ids, "delete")}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,305 @@
"use client";
import * as React from "react";
import {
type ColumnDef,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ChevronLeft,
ChevronRight,
CheckCircle,
Download,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { ResponseCard } from "./columns";
type DataTableProps<TData> = {
data: TData[];
columns: ColumnDef<TData, unknown>[];
totalCount: number;
page: number;
limit: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
onBulkMarkReviewed?: (ids: string[]) => void;
onBulkMarkExported?: (ids: string[]) => void;
onBulkDelete?: (ids: string[]) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
sortBy?: string;
sortOrder?: "asc" | "desc";
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
};
const PAGE_SIZES = [10, 20, 50, 100];
export function DataTable<TData extends ResponseCard>({
data,
columns,
totalCount,
page,
limit,
onPageChange,
onLimitChange,
onBulkMarkReviewed,
onBulkMarkExported,
onBulkDelete,
columnVisibility: controlledVisibility,
onColumnVisibilityChange,
sortBy: controlledSortBy,
sortOrder: controlledSortOrder,
onSortChange,
}: DataTableProps<TData>) {
const sorting: SortingState =
controlledSortBy && controlledSortOrder
? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }]
: [];
const setSorting = React.useCallback(
(updater: React.SetStateAction<SortingState>) => {
const next =
typeof updater === "function" ? updater(sorting) : updater;
const first = next[0];
if (first && onSortChange) {
onSortChange(first.id, first.desc ? "desc" : "asc");
}
},
[sorting, onSortChange]
);
const [rowSelection, setRowSelection] = React.useState({});
const [internalVisibility, setInternalVisibility] =
React.useState<VisibilityState>({});
const visibility =
controlledVisibility !== undefined ? controlledVisibility : internalVisibility;
const setVisibility =
onColumnVisibilityChange ?? setInternalVisibility;
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: (updater) => {
const next =
typeof updater === "function" ? updater(visibility) : updater;
setVisibility(next);
},
state: {
sorting,
rowSelection,
columnVisibility: visibility,
pagination: {
pageIndex: page - 1,
pageSize: limit,
},
},
manualPagination: true,
manualSorting: !!onSortChange,
pageCount: Math.ceil(totalCount / limit) || 1,
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedCount = selectedRows.length;
const selectedIds = selectedRows.map((r) => r.original.id);
const totalPages = Math.ceil(totalCount / limit);
const canPrev = page > 1;
const canNext = page < totalPages;
const start = (page - 1) * limit + 1;
const end = Math.min(page * limit, totalCount);
return (
<div className="space-y-4">
{selectedCount > 0 && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 px-4 py-2.5">
<span className="text-sm font-medium">
{selectedCount} row{selectedCount !== 1 ? "s" : ""} selected
</span>
<div className="flex items-center gap-2">
{onBulkMarkReviewed && (
<Button
variant="outline"
size="sm"
onClick={() => onBulkMarkReviewed(selectedIds)}
>
<CheckCircle className="mr-1.5 size-4" />
Mark Reviewed
</Button>
)}
{onBulkMarkExported && (
<Button
variant="outline"
size="sm"
onClick={() => onBulkMarkExported(selectedIds)}
>
<Download className="mr-1.5 size-4" />
Mark Exported
</Button>
)}
{onBulkDelete && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onBulkDelete(selectedIds)}
>
<Trash2 className="mr-1.5 size-4" />
Delete
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => table.toggleAllPageRowsSelected(false)}
>
Clear selection
</Button>
</div>
</div>
)}
<div className="rounded-lg border border-border bg-card">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="px-4 py-3">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4 py-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{totalCount === 0
? "0 results"
: `${start}${end} of ${totalCount}`}
</span>
<div className="flex items-center gap-2">
<span>Rows per page</span>
<select
value={limit}
onChange={(e) => onLimitChange?.(Number(e.target.value))}
className="h-8 rounded-md border border-input bg-transparent px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(page - 1)}
disabled={!canPrev}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let p: number;
if (totalPages <= 5) {
p = i + 1;
} else if (page <= 3) {
p = i + 1;
} else if (page >= totalPages - 2) {
p = totalPages - 4 + i;
} else {
p = page - 2 + i;
}
return (
<Button
key={p}
variant={p === page ? "default" : "ghost"}
size="sm"
className="size-8 p-0"
onClick={() => onPageChange?.(p)}
>
{p}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(page + 1)}
disabled={!canNext}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,278 @@
"use client";
import * as React from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Search, Download, Columns3 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
const REVIEW_OPTIONS = [
{ value: "", label: "All" },
{ value: "unreviewed", label: "Unreviewed" },
{ value: "reviewed", label: "Reviewed" },
{ value: "exported", label: "Exported" },
] as const;
const OCR_OPTIONS = [
{ value: "", label: "All" },
{ value: "complete", label: "Complete" },
{ value: "error", label: "Error" },
{ value: "pending", label: "Pending" },
] as const;
const TOGGLEABLE_COLUMNS = [
{ id: "thumbnail", label: "Thumbnail" },
{ id: "name", label: "Name" },
{ id: "email", label: "Email" },
{ id: "cellPhone", label: "Phone" },
{ id: "location", label: "Location" },
{ id: "visitType", label: "Visit Type" },
{ id: "attendanceDuration", label: "Attendance" },
{ id: "serviceAttended", label: "Service" },
{ id: "ocrStatus", label: "OCR Status" },
{ id: "reviewStatus", label: "Review" },
{ id: "ocrConfidence", label: "Confidence" },
] as const;
export type FiltersProps = {
search?: string;
reviewStatus?: string;
ocrStatus?: string;
visitType?: string;
attendanceDuration?: string;
serviceAttended?: string;
visitTypeOptions?: string[];
attendanceDurationOptions?: string[];
serviceAttendedOptions?: string[];
columnVisibility?: Record<string, boolean>;
onColumnVisibilityChange?: (visibility: Record<string, boolean>) => void;
onExportCsv?: () => void;
};
export function Filters({
search: initialSearch = "",
reviewStatus: initialReviewStatus = "",
ocrStatus: initialOcrStatus = "",
visitType: initialVisitType = "",
attendanceDuration: initialAttendanceDuration = "",
serviceAttended: initialServiceAttended = "",
visitTypeOptions = [],
attendanceDurationOptions = [],
serviceAttendedOptions = [],
columnVisibility = {},
onColumnVisibilityChange,
onExportCsv,
}: FiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const search = searchParams.get("search") ?? initialSearch;
const reviewStatus = searchParams.get("reviewStatus") ?? initialReviewStatus;
const ocrStatus = searchParams.get("ocrStatus") ?? initialOcrStatus;
const visitType = searchParams.get("visitType") ?? initialVisitType;
const attendanceDuration =
searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
const serviceAttended =
searchParams.get("serviceAttended") ?? initialServiceAttended;
const updateParams = React.useCallback(
(updates: Record<string, string | undefined>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === undefined || value === "") {
params.delete(key);
} else {
params.set(key, value);
}
}
params.set("page", "1");
router.push(`${pathname}?${params.toString()}`);
},
[pathname, router, searchParams]
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
updateParams({ search: v || undefined });
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
const isColumnVisible = (id: string) => columnVisibility[id] !== false;
const toggleColumn = (id: string, visible: boolean) => {
onColumnVisibilityChange?.({
...columnVisibility,
[id]: visible,
});
};
return (
<div className="flex flex-col gap-4">
<form onSubmit={handleSearchSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search name, email, phone, city..."
value={search}
onChange={handleSearchChange}
className="pl-9"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
Review:
</span>
{REVIEW_OPTIONS.map((opt) => (
<Badge
key={opt.value || "all"}
variant={reviewStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors",
reviewStatus === opt.value && "bg-primary text-primary-foreground"
)}
onClick={() => updateParams({ reviewStatus: opt.value || undefined })}
>
{opt.label}
</Badge>
))}
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
OCR:
</span>
{OCR_OPTIONS.map((opt) => (
<Badge
key={opt.value || "all"}
variant={ocrStatus === opt.value ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors",
ocrStatus === opt.value && "bg-primary text-primary-foreground"
)}
onClick={() => updateParams({ ocrStatus: opt.value || undefined })}
>
{opt.label}
</Badge>
))}
</div>
</div>
</form>
<div className="flex flex-wrap items-center gap-3">
<Select
value={visitType || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
visitType: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Visit Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All visit types</SelectItem>
{visitTypeOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={attendanceDuration || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
attendanceDuration: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Attendance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All attendance</SelectItem>
{attendanceDurationOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={serviceAttended || "__all__"}
onValueChange={(v: string | null) =>
updateParams({
serviceAttended: !v || v === "__all__" ? undefined : v,
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All services</SelectItem>
{serviceAttendedOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm" />
}
>
<Columns3 className="mr-2 size-4" />
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{TOGGLEABLE_COLUMNS.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={isColumnVisible(col.id)}
onCheckedChange={(checked) =>
toggleColumn(col.id, checked !== false)
}
>
{col.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{onExportCsv && (
<Button variant="outline" size="sm" onClick={onExportCsv}>
<Download className="mr-2 size-4" />
Export CSV
</Button>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { ImageIcon } from "lucide-react";
const THUMB_SIZE = 40;
type ImagePreviewProps = {
imageUrl: string | null;
alt?: string;
className?: string;
};
export function ImagePreview({
imageUrl,
alt = "Card image",
className,
}: ImagePreviewProps) {
const [open, setOpen] = React.useState(false);
if (!imageUrl) {
return (
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50",
className
)}
aria-label="No image"
>
<ImageIcon className="size-5 text-muted-foreground" />
</div>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<button
type="button"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-md border border-border bg-muted/50 transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
aria-label="View image"
/>
}
>
<Image
src={imageUrl}
alt={alt}
width={THUMB_SIZE}
height={THUMB_SIZE}
className="object-cover"
unoptimized
/>
</DialogTrigger>
<DialogContent
className="max-w-[90vw] max-h-[90vh] w-auto p-0 overflow-hidden bg-transparent border-0 shadow-none"
showCloseButton={true}
>
<div className="relative flex items-center justify-center p-4">
<Image
src={imageUrl}
alt={alt}
width={800}
height={600}
className="max-h-[85vh] w-auto object-contain rounded-lg"
unoptimized
/>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,25 @@
interface HeaderProps {
title: string;
description?: string;
children?: React.ReactNode;
}
export function Header({ title, description, children }: HeaderProps) {
return (
<header className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
{title}
</h1>
{description && (
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
)}
</div>
{children && (
<div className="mt-4 flex shrink-0 items-center gap-2 sm:mt-0">
{children}
</div>
)}
</header>
);
}

View file

@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import {
LayoutDashboard,
Upload,
Settings,
Moon,
Sun,
ScanLine,
Menu,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const navItems = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/upload", label: "Upload", icon: Upload },
{ href: "/settings", label: "Settings", icon: Settings },
];
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-0.5 px-2">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/" && pathname.startsWith(item.href));
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
)}
>
<Icon className="size-4 shrink-0 opacity-80" />
{item.label}
</Link>
);
})}
</nav>
);
}
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-sidebar-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
/>
}
>
<Sun className="size-4 dark:hidden" />
<Moon className="hidden size-4 dark:block" />
<span className="sr-only">Toggle theme</span>
</TooltipTrigger>
<TooltipContent side="right">
{theme === "dark" ? "Switch to light" : "Switch to dark"}
</TooltipContent>
</Tooltip>
);
}
function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
return (
<>
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-sidebar-border px-4">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<ScanLine className="size-4" />
</div>
<span className="font-semibold text-sidebar-foreground">
Echo OCR
</span>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 overflow-auto py-4">
<NavLinks onNavigate={onNavigate} />
</div>
<div className="shrink-0 border-t border-sidebar-border p-3">
<div className="flex items-center justify-between px-2">
<span className="text-xs text-muted-foreground">Theme</span>
<ThemeToggle />
</div>
</div>
</>
);
}
export function Sidebar() {
const [open, setOpen] = useState(false);
return (
<>
{/* Mobile menu trigger */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button
variant="ghost"
size="icon"
className="fixed left-4 top-4 z-40 md:hidden"
/>
}
>
<Menu className="size-5" />
<span className="sr-only">Open menu</span>
</SheetTrigger>
<SheetContent
side="left"
className="w-[240px] border-sidebar-border bg-sidebar p-0"
showCloseButton={true}
>
<div className="flex h-full w-full flex-col">
<SidebarContent onNavigate={() => setOpen(false)} />
</div>
</SheetContent>
</Sheet>
{/* Desktop sidebar */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-30 hidden w-[240px] flex-col border-r border-sidebar-border bg-sidebar",
"md:flex"
)}
>
<SidebarContent />
</aside>
</>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "sonner";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
{children}
<Toaster richColors position="bottom-right" />
</TooltipProvider>
</ThemeProvider>
);
}

View file

@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View file

@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

103
src/components/ui/card.tsx Normal file
View file

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,271 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View file

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View file

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View file

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

135
src/components/ui/sheet.tsx Normal file
View file

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
src/components/ui/table.tsx Normal file
View file

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View file

@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

19
src/lib/db.ts Normal file
View file

@ -0,0 +1,19 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
const adapter = new PrismaPg(pool);
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

46
src/lib/minio.ts Normal file
View file

@ -0,0 +1,46 @@
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "192.168.68.105";
const MINIO_PORT = parseInt(process.env.MINIO_PORT || "9000");
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || "minioadmin";
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || "";
const MINIO_BUCKET = process.env.MINIO_BUCKET || "echos-ocr";
export const s3 = new S3Client({
endpoint: `http://${MINIO_ENDPOINT}:${MINIO_PORT}`,
region: "us-east-1",
credentials: {
accessKeyId: MINIO_ACCESS_KEY,
secretAccessKey: MINIO_SECRET_KEY,
},
forcePathStyle: true,
});
export const BUCKET = MINIO_BUCKET;
export async function uploadBuffer(key: string, buffer: Buffer, contentType: string): Promise<string> {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
return key;
}
export async function getPresignedUrl(key: string, expiresIn = 3600): Promise<string> {
return getSignedUrl(
s3,
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
{ expiresIn }
);
}
export async function deleteObject(key: string): Promise<void> {
await s3.send(
new DeleteObjectCommand({ Bucket: BUCKET, Key: key })
);
}

172
src/lib/ocr.ts Normal file
View file

@ -0,0 +1,172 @@
import { prisma } from "./db";
import { uploadBuffer } from "./minio";
import { ocrImage } from "./ollama";
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
export async function processFile(
jobId: string,
fileName: string,
fileBuffer: Buffer,
isPdf: boolean
): Promise<string[]> {
const cardIds: string[] = [];
try {
await prisma.processingJob.update({
where: { id: jobId },
data: { status: "processing" },
});
let pageImages: { buffer: Buffer; page: number }[];
if (isPdf) {
pageImages = await pdfToImages(fileBuffer);
await prisma.processingJob.update({
where: { id: jobId },
data: { totalPages: pageImages.length },
});
} else {
const processed = await processUploadedImage(fileBuffer);
pageImages = [{ buffer: processed, page: 1 }];
await prisma.processingJob.update({
where: { id: jobId },
data: { totalPages: 1 },
});
}
const sourceKey = `sources/${jobId}/${fileName}`;
await uploadBuffer(sourceKey, fileBuffer, isPdf ? "application/pdf" : "image/jpeg");
// Pair pages: page 1 = response card (back), page 2 = survey (front), etc.
const pairs: { response?: typeof pageImages[0]; survey?: typeof pageImages[0] }[] = [];
for (let i = 0; i < pageImages.length; i += 2) {
pairs.push({
response: pageImages[i],
survey: pageImages[i + 1],
});
}
// If single image (not PDF), treat as response card side
if (!isPdf && pageImages.length === 1) {
pairs.length = 0;
pairs.push({ response: pageImages[0] });
}
for (const pair of pairs) {
const card = await prisma.responseCard.create({
data: {
sourceFile: sourceKey,
ocrStatus: "processing",
},
});
cardIds.push(card.id);
try {
let responseData: Record<string, unknown> = {};
let surveyData: Record<string, unknown> = {};
let totalConfidence = 0;
let confidenceCount = 0;
if (pair.response) {
const imgKey = `images/${card.id}/response.jpg`;
await uploadBuffer(imgKey, pair.response.buffer, "image/jpeg");
await prisma.responseCard.update({
where: { id: card.id },
data: { backImagePath: imgKey },
});
const base64 = await imageToBase64(pair.response.buffer);
const result = await ocrImage(base64, "response");
responseData = result.data;
totalConfidence += result.confidence;
confidenceCount++;
}
if (pair.survey) {
const imgKey = `images/${card.id}/survey.jpg`;
await uploadBuffer(imgKey, pair.survey.buffer, "image/jpeg");
await prisma.responseCard.update({
where: { id: card.id },
data: { frontImagePath: imgKey },
});
const base64 = await imageToBase64(pair.survey.buffer);
const result = await ocrImage(base64, "survey");
surveyData = result.data;
totalConfidence += result.confidence;
confidenceCount++;
}
const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
await prisma.responseCard.update({
where: { id: card.id },
data: {
name: asString(responseData.name),
gender: asString(responseData.gender),
dateOfBirth: asString(responseData.dateOfBirth),
maritalStatus: asString(responseData.maritalStatus),
maritalStatusOther: asString(responseData.maritalStatusOther),
visitType: asString(responseData.visitType),
cellPhone: asString(responseData.cellPhone),
homePhone: asString(responseData.homePhone),
email: asString(responseData.email),
address: asString(responseData.address),
aptNumber: asString(responseData.aptNumber),
city: asString(responseData.city),
state: asString(responseData.state),
zip: asString(responseData.zip),
prayerRequests: asString(responseData.prayerRequests),
prayerForTeam: asBool(responseData.prayerForTeam),
prayerConfidential: asBool(responseData.prayerConfidential),
messageTopics: surveyData.messageTopics ?? [],
messageTopicsOther: asString(surveyData.messageTopicsOther),
nextStep: surveyData.nextStep ?? [],
attendanceDuration: asString(surveyData.attendanceDuration),
campusPreference: surveyData.campusPreference ?? [],
campusPreferenceOther: asString(surveyData.campusPreferenceOther),
howHeard: surveyData.howHeard ?? [],
howHeardOther: asString(surveyData.howHeardOther),
serviceAttended: asString(surveyData.serviceAttended),
ocrStatus: "complete",
ocrConfidence: Math.round(avgConfidence),
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown OCR error";
await prisma.responseCard.update({
where: { id: card.id },
data: { ocrStatus: "error", ocrError: message },
});
}
await prisma.processingJob.update({
where: { id: jobId },
data: { processed: { increment: 1 } },
});
}
await prisma.processingJob.update({
where: { id: jobId },
data: { status: "complete", cardIds },
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
await prisma.processingJob.update({
where: { id: jobId },
data: { status: "error", error: message },
});
}
return cardIds;
}
function asString(v: unknown): string | null {
if (v === null || v === undefined) return null;
return String(v);
}
function asBool(v: unknown): boolean {
return v === true;
}

118
src/lib/ollama.ts Normal file
View file

@ -0,0 +1,118 @@
import { prisma } from "./db";
async function getSettings() {
let settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
if (!settings) {
settings = await prisma.appSettings.create({
data: { id: "singleton" },
});
}
return settings;
}
function getOllamaUrl() {
return process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434";
}
async function getModel() {
const settings = await getSettings();
return process.env.OLLAMA_MODEL || settings.model;
}
const RESPONSE_CARD_PROMPT = `You are analyzing a scanned church response card. This is the PERSONAL INFORMATION side.
Extract ALL of the following fields from the image. For checkboxes, determine if they are checked or unchecked.
For handwritten text, read it as accurately as possible.
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
{
"name": "string or null",
"gender": "Male" or "Female" or null,
"dateOfBirth": "string as written or null",
"maritalStatus": "Married" or "Single" or "Other" or null,
"maritalStatusOther": "string if Other is checked, else null",
"visitType": "First/Second Time Guest" or "Update My Information" or null,
"cellPhone": "string or null",
"homePhone": "string or null",
"email": "string or null",
"address": "string or null",
"aptNumber": "string or null",
"city": "string or null",
"state": "string or null",
"zip": "string or null",
"prayerRequests": "string or null",
"prayerForTeam": true/false,
"prayerConfidential": true/false,
"confidence": 0-100
}`;
const SURVEY_PROMPT = `You are analyzing a scanned church Easter survey form. This is the SURVEY side.
Extract ALL of the following fields. For checkboxes, determine if they are checked (filled/marked) or unchecked (empty).
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
{
"messageTopics": ["array of checked topics from: Stress, Marriage, Revival, Addiction, Parenting, Miracles, Forgiveness, Finances, My Identity, Conflict Resolution, The Holy Spirit, Understanding The Bible, Spiritual Warfare, Sharing My Faith, Anxiety, Heaven, Spiritual Gifts"],
"messageTopicsOther": "string if Other is filled in, else null",
"nextStep": ["array of checked items from: Baptism, Next Steps"],
"attendanceDuration": "Less than 6 months" or "6 Months - 1 Year" or "1-3 Years" or "4-6 Years" or "7+ Years" or null,
"campusPreference": ["array of checked locations from: Beulah, Pace/Milton, Gulf Breeze, Warrington"],
"campusPreferenceOther": "string if Other is filled in, else null",
"howHeard": ["array of checked items from: This is my church home, Regular Attender, Drove by, Social Media, Google, Personal Invite"],
"howHeardOther": "string if Other is filled in, else null",
"serviceAttended": "A" or "B" or "C" or "D" or null,
"confidence": 0-100
}`;
export interface OcrResult {
data: Record<string, unknown>;
confidence: number;
raw: string;
side: "response" | "survey";
}
export async function ocrImage(
imageBase64: string,
side: "response" | "survey"
): Promise<OcrResult> {
const ollamaUrl = getOllamaUrl();
const model = await getModel();
const prompt = side === "response" ? RESPONSE_CARD_PROMPT : SURVEY_PROMPT;
const response = await fetch(`${ollamaUrl}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
prompt,
images: [imageBase64],
stream: false,
options: {
temperature: 0.1,
num_predict: 2048,
},
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ollama API error (${response.status}): ${errorText}`);
}
const result = await response.json();
const rawText = result.response || "";
let parsed: Record<string, unknown>;
try {
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("No JSON found in response");
parsed = JSON.parse(jsonMatch[0]);
} catch {
throw new Error(`Failed to parse OCR response: ${rawText.slice(0, 500)}`);
}
const confidence = typeof parsed.confidence === "number" ? parsed.confidence : 50;
delete parsed.confidence;
return { data: parsed, confidence, raw: rawText, side };
}

60
src/lib/pdf.ts Normal file
View file

@ -0,0 +1,60 @@
import { fromBuffer } from "pdf2pic";
import sharp from "sharp";
export interface PageImage {
page: number;
buffer: Buffer;
width: number;
height: number;
}
export async function pdfToImages(pdfBuffer: Buffer): Promise<PageImage[]> {
const converter = fromBuffer(pdfBuffer, {
density: 200,
format: "jpeg",
width: 1600,
height: 2200,
quality: 90,
});
const pageCount = await getPdfPageCount(pdfBuffer);
const images: PageImage[] = [];
for (let i = 1; i <= pageCount; i++) {
const result = await converter(i, { responseType: "buffer" });
if (result.buffer) {
const metadata = await sharp(result.buffer).metadata();
images.push({
page: i,
buffer: result.buffer as Buffer,
width: metadata.width || 1600,
height: metadata.height || 2200,
});
}
}
return images;
}
async function getPdfPageCount(pdfBuffer: Buffer): Promise<number> {
const text = pdfBuffer.toString("latin1");
const matches = text.match(/\/Type\s*\/Page(?!s)/g);
return matches ? matches.length : 2;
}
export async function imageToBase64(buffer: Buffer): Promise<string> {
const processed = await sharp(buffer)
.resize(1200, undefined, { withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
return processed.toString("base64");
}
export async function processUploadedImage(buffer: Buffer): Promise<Buffer> {
return sharp(buffer)
.resize(1600, undefined, { withoutEnlargement: true })
.normalize()
.sharpen()
.jpeg({ quality: 90 })
.toBuffer();
}

92
src/lib/watcher.ts Normal file
View file

@ -0,0 +1,92 @@
import chokidar, { type FSWatcher } from "chokidar";
import fs from "fs/promises";
import path from "path";
import { prisma } from "./db";
import { processFile } from "./ocr";
let watcher: FSWatcher | null = null;
const processedFiles = new Set<string>();
export async function startWatching(watchDir: string): Promise<void> {
if (watcher) {
await stopWatching();
}
try {
await fs.access(watchDir);
} catch {
throw new Error(`Watch directory does not exist: ${watchDir}`);
}
watcher = chokidar.watch(watchDir, {
ignored: /(^|[/\\])\../,
persistent: true,
ignoreInitial: false,
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 500,
},
});
watcher.on("add", async (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
const allowed = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
if (!allowed.includes(ext)) return;
if (processedFiles.has(filePath)) return;
processedFiles.add(filePath);
const fileName = path.basename(filePath);
const isPdf = ext === ".pdf";
try {
const buffer = await fs.readFile(filePath);
const job = await prisma.processingJob.create({
data: {
fileName,
filePath,
status: "queued",
},
});
processFile(job.id, fileName, buffer, isPdf).catch((err) => {
console.error(`[watcher] Processing failed for ${fileName}:`, err);
});
console.log(`[watcher] Queued: ${fileName}`);
} catch (err) {
console.error(`[watcher] Failed to read file ${filePath}:`, err);
}
});
watcher.on("error", (error: unknown) => {
console.error("[watcher] Error:", error);
});
await prisma.appSettings.upsert({
where: { id: "singleton" },
update: { watching: true, watchDir },
create: { id: "singleton", watching: true, watchDir },
});
console.log(`[watcher] Started watching: ${watchDir}`);
}
export async function stopWatching(): Promise<void> {
if (watcher) {
await watcher.close();
watcher = null;
}
await prisma.appSettings.upsert({
where: { id: "singleton" },
update: { watching: false },
create: { id: "singleton", watching: false },
});
console.log("[watcher] Stopped");
}
export function isWatching(): boolean {
return watcher !== null;
}