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:
parent
6a6a140139
commit
7e423a3e50
65 changed files with 9354 additions and 107 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
*.md
|
||||
16
.env.example
Normal file
16
.env.example
Normal 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
8
.gitignore
vendored
|
|
@ -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
41
Dockerfile
Normal 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"]
|
||||
80
README.md
80
README.md
|
|
@ -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
55
docker-compose.yml
Normal 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:
|
||||
|
|
@ -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
3680
package-lock.json
generated
File diff suppressed because it is too large
Load diff
27
package.json
27
package.json
|
|
@ -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
14
prisma.config.ts
Normal 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
82
prisma/schema.prisma
Normal 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)
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
31
src/app/api/cards/[id]/export/route.ts
Normal file
31
src/app/api/cards/[id]/export/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
139
src/app/api/cards/[id]/route.ts
Normal file
139
src/app/api/cards/[id]/route.ts
Normal 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
130
src/app/api/cards/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/app/api/images/[...path]/route.ts
Normal file
25
src/app/api/images/[...path]/route.ts
Normal 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
31
src/app/api/jobs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
57
src/app/api/settings/route.ts
Normal file
57
src/app/api/settings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/app/api/stats/route.ts
Normal file
37
src/app/api/stats/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/app/api/upload/route.ts
Normal file
68
src/app/api/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
src/app/api/watch/route.ts
Normal file
38
src/app/api/watch/route.ts
Normal 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
410
src/app/cards/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
222
src/app/settings/page.tsx
Normal 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
300
src/app/upload/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
330
src/components/cards/columns.tsx
Normal file
330
src/components/cards/columns.tsx
Normal 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();
|
||||
195
src/components/cards/dashboard-content.tsx
Normal file
195
src/components/cards/dashboard-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
305
src/components/cards/data-table.tsx
Normal file
305
src/components/cards/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
src/components/cards/filters.tsx
Normal file
278
src/components/cards/filters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/cards/image-preview.tsx
Normal file
78
src/components/cards/image-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/layout/header.tsx
Normal file
25
src/components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/components/layout/sidebar.tsx
Normal file
163
src/components/layout/sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/components/providers.tsx
Normal file
16
src/components/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/ui/avatar.tsx
Normal file
109
src/components/ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal 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
103
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
29
src/components/ui/checkbox.tsx
Normal file
29
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
196
src/components/ui/command.tsx
Normal file
196
src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
157
src/components/ui/dialog.tsx
Normal file
157
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
271
src/components/ui/dropdown-menu.tsx
Normal file
271
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
158
src/components/ui/input-group.tsx
Normal file
158
src/components/ui/input-group.tsx
Normal 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,
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal 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 }
|
||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal 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 }
|
||||
90
src/components/ui/popover.tsx
Normal file
90
src/components/ui/popover.tsx
Normal 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,
|
||||
}
|
||||
55
src/components/ui/scroll-area.tsx
Normal file
55
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
201
src/components/ui/select.tsx
Normal file
201
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal 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
135
src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
32
src/components/ui/switch.tsx
Normal file
32
src/components/ui/switch.tsx
Normal 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
116
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
82
src/components/ui/tabs.tsx
Normal file
82
src/components/ui/tabs.tsx
Normal 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 }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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 }
|
||||
66
src/components/ui/tooltip.tsx
Normal file
66
src/components/ui/tooltip.tsx
Normal 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
19
src/lib/db.ts
Normal 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
46
src/lib/minio.ts
Normal 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
172
src/lib/ocr.ts
Normal 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
118
src/lib/ollama.ts
Normal 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
60
src/lib/pdf.ts
Normal 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
92
src/lib/watcher.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue