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*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files
|
||||||
.env*
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
@ -39,3 +41,5 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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
|
```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
|
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.
|
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.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
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
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
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.
|
| GET | `/api/cards` | List cards (filters, pagination, search) |
|
||||||
|
| POST | `/api/cards` | Create a card |
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
| 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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
serverExternalPackages: ["sharp", "pdf2pic", "chokidar", "pg"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "npx prisma generate && next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1005.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||||
"@base-ui/react": "^1.2.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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"minio": "^8.0.7",
|
||||||
"next": "16.1.6",
|
"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": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"shadcn": "^4.0.2",
|
"shadcn": "^4.0.2",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/components/providers";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Echo OCR",
|
||||||
description: "Generated by create next app",
|
description: "Response card scanning and management",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -23,11 +25,18 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<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>
|
</body>
|
||||||
</html>
|
</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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="space-y-6">
|
||||||
<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">
|
<Header
|
||||||
<Image
|
title="Response Cards"
|
||||||
className="dark:invert"
|
description="Manage scanned response cards from Echo Life Church"
|
||||||
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">
|
<Suspense
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
fallback={
|
||||||
To get started, edit the page.tsx file.
|
<div className="space-y-4">
|
||||||
</h1>
|
<Skeleton className="h-10 w-full" />
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<Skeleton className="h-[400px] w-full" />
|
||||||
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>
|
||||||
<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
|
<DashboardContent />
|
||||||
className="dark:invert"
|
</Suspense>
|
||||||
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>
|
</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