commit 023f0394b4c5cfdd5977ac779ba9d291f03b33ca Author: Hermes Date: Sun Aug 23 00:35:51 2026 +0000 initial: pulsy from pulse-clock-main diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d126cf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.next +node_modules +coverage +data +logs +.env* +!.env.example +*.db +*.db-shm +*.db-wal +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +Dockerfile +docker-compose.yml +README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..86a63dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a0c9d97 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Use at least 32 random characters. Generate with: openssl rand -base64 32 +PULSE_SECRET=replace-with-at-least-32-random-characters +DATABASE_URL=./data/pulse-clock.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5b65ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.vscode/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +/logs/ + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# local task files +.opencode-task.txt +.ralph/ +.hermes/ +CLAUDE.md +PROMPT.md +AGENTS.md + +# sqlite runtime files +/data/*.db +*.db-shm +*.db-wal diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..27d52fa --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +dist/ +package-lock.json +data/ +next-env.d.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..7cebf38 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "semi": true +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9949fb3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS deps +COPY package.json package-lock.json ./ +RUN npm ci + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV DATABASE_URL=/app/data/pulse-clock.db + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs \ + && mkdir -p /app/data \ + && chown -R nextjs:nodejs /app/data + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle + +VOLUME ["/app/data"] +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..99aa35e --- /dev/null +++ b/README.md @@ -0,0 +1,317 @@ +# Pulse Clock + +Multi-tenant time clock and workforce management system for hotel/hospitality environments. Employees punch in/out and take breaks via a kiosk interface; administrators monitor time cards, detect scheduling issues, and export payroll data. + +## Overview + +`pulse-clock` provides: + +- **Employee Kiosk**: Web-based punch interface with webcam capture +- **Admin Dashboard**: Time grid view with issue detection +- **Management**: Hotels and employees CRUD +- **Payroll Export**: TSV download with validation checks +- **Validation Engine**: State machine enforcing valid punch sequences + +## Prerequisites + +- **Node.js** (v20.9 or higher) +- **npm** (package manager) +- **SQLite** (accessed through Drizzle and the libSQL client) + +## Installation + +```bash +cd pulse-clock +npm install +``` + +Copy the example environment file and configure: + +```bash +cp .env.example .env +``` + +Edit `.env` with your configuration. Key variables: + +| Variable | Description | Default | +| ------------------- | ------------------------------------ | ----------------------- | +| `PULSE_SECRET` | Login secret; use 32+ random chars | — | +| `PULSE_RATE_LIMIT` | API rate limit (requests per window) | `60` | +| `PULSE_RATE_WINDOW` | Rate limit window (seconds) | `60` | +| `DATABASE_URL` | SQLite database path | `./data/pulse-clock.db` | + +## Usage + +### Development Server + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to access the application. + +### Build for Production + +```bash +npm run build +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Pulse Clock │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Kiosk Mode │ │ Admin Dashboard │ │ +│ │ /kiosk/* │ │ /admin/* │ │ +│ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ API Routes │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ /punch │ │ /time- │ │ /payroll │ │ │ +│ │ │ │ │ entries │ │ │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ time-entry-service.ts │ │ +│ │ (createValidatedTimeEntry) │ │ +│ │ Punch State Machine │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SQLite Database │ │ +│ │ companies | employees | time_entries | audit_log │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Kiosk Punch Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Employee Selects → Webcam Capture → Punch Button Click │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ POST /api/punch │ │ +│ │ { employeeId, type }│ │ +│ └────────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ Punch State Machine │ │ +│ │ validateNewEntry... │ │ +│ └────────────┬───────────┘ │ +│ │ │ +│ ┌─────────────────────┼──────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────────┐ ┌───────────────┐ ┌──────────┐ +│ │ Valid Punch │ │ Invalid Punch │ │ Duplicate│ +│ │ → Save Entry │ │ → Reject │ │ → Ignore │ +│ └───────────────┘ └───────────────┘ └──────────┘ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Punch State Machine + +``` +Valid Transitions: + + OUT ──────▶ IN (clock in) + IN ──────▶ OUT (clock out) + IN ──────▶ BREAK_OUT (start break) + BREAK_OUT ─▶ BREAK_IN (end break) + +All other transitions are rejected unless forceOverride=true +``` + +## Key Features + +### Employee Kiosk + +- Live employee cards showing current status: **Off Duty** / **On Shift** / **On Break** +- Webcam capture on each punch (base64 stored in DB) +- Punch modal with Clock In, Clock Out, Start Break, End Break buttons +- 5-second duplicate-punch guard on kiosk clicks +- Auto-refreshes employee status every 5 seconds +- Elapsed shift time shown on employee cards (net of break time) + +### Admin Dashboard + +- Date range selector with Today/Yesterday/Last 7/Last 14/Last 30 presets +- Grid view: employees (rows) × calendar days (columns) +- Red cell highlighting for validation issues +- "Issues Only" filter toggle +- Detail drawer per employee/day: full punch timeline +- Timestamp editing through authenticated admin sessions +- Add manual punch entries + +### Validation Engine + +Detects per employee per day: + +- First entry is not Clock In +- Consecutive duplicate entries (e.g., two INs in a row) +- Break started without prior Clock In +- Break ended without break start +- Break started but never ended +- Still clocked in at day end +- Missing clock out + +### Payroll Export + +- Date range presets including Pay Period 1-15 and Pay Period 16-31 +- Preview table: Employee | Date | In | Out | Break (min) | Net Hours | Notes +- Copy to clipboard (TSV) and Download .tsv buttons +- Issues column flags validation problems per day row +- Summary strip with total hours and error counts + +### Public Hosting Security + +- All pages redirect to `/login` unless the `pulse_auth` cookie is valid. +- All API routes except `/api/authorize` and `/api/health` require the auth cookie. +- `PULSE_SECRET` is required and must be at least 32 characters. +- The auth cookie is `HttpOnly`, `SameSite=Lax`, `Secure` in production, and valid for 1 year. +- Login attempts and authenticated API calls are rate limited by IP. +- Mutating API requests require same-origin/CSRF protection. + +### Multi-Tenancy + +All data is scoped by `companyId`: + +- Employees belong to a company +- Time entries scoped to company + employee +- Audit logs scoped to company + +## Pages Reference + +| Path | Description | +| -------------------- | -------------------------------- | +| `/` | Landing page with company grid | +| `/login` | Secret-based login page | +| `/kiosk` | Kiosk company selector | +| `/kiosk/[companyId]` | Employee kiosk interface | +| `/admin` | Admin time grid dashboard | +| `/admin/manage` | Hotels and employees CRUD | +| `/admin/payroll` | Payroll export with TSV download | + +## API Reference + +| Endpoint | Method | Description | +| ---------------------------- | ------------------------ | ------------------------------ | +| `/api/companies` | GET, POST | Company CRUD | +| `/api/employees` | GET, POST | Employee CRUD | +| `/api/employees/with-status` | GET | Employee list with live status | +| `/api/punch` | POST | Kiosk punch (state machine) | +| `/api/time-entries` | GET, POST, PATCH, DELETE | Time entry CRUD + manual edits | +| `/api/payroll` | GET | Payroll TSV generation | +| `/api/authorize` | GET, POST | Cookie auth check/login | +| `/api/health` | GET | Unauthenticated healthcheck | + +### Authentication + +Open `/login` and enter `PULSE_SECRET`. Successful login sets the `pulse_auth` cookie. + +API calls from the browser use that cookie automatically. For non-browser scripts, first authenticate and store the cookie: + +```bash +curl -c cookies.txt \ + -H "Content-Type: application/json" \ + -H "x-pulse-csrf: 1" \ + -X POST https://localhost:3000/api/authorize \ + -d '{"secret":"your-32-character-minimum-secret"}' + +curl -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "x-pulse-csrf: 1" \ + -X PATCH https://localhost:3000/api/time-entries \ + -d '{"id":123,"timestamp":1747507200}' +``` + +## Database Schema + +```sql +companies (id, name, created_at) +employees (id, company_id, name, is_active, created_at) +time_entries (id, employee_id, company_id, type, timestamp, photo_base64, is_deleted) +audit_log (id, employee_id, company_id, admin_id, action, detail, created_at) +``` + +## Project Structure + +``` +pulse-clock/ +├── src/ +│ ├── app/ # Next.js App Router +│ │ ├── page.tsx # Landing page +│ │ ├── kiosk/ +│ │ │ └── [companyId]/ # Kiosk UI +│ │ ├── admin/ +│ │ │ ├── page.tsx # Time grid dashboard +│ │ │ ├── manage/page.tsx # Hotels & employees CRUD +│ │ │ └── payroll/page.tsx # Payroll export +│ │ └── api/ # API routes +│ ├── lib/ # Shared business logic +│ │ ├── api.ts # fetchApi wrapper +│ │ ├── validation.ts # State machine +│ │ ├── calculations.ts # Time calculations +│ │ ├── time.ts # Date utilities +│ │ └── time-entry-service.ts +│ ├── db/ +│ │ ├── schema.ts # Drizzle schema +│ │ └── index.ts # Drizzle client and migrations +│ └── components/ # React components +├── drizzle/ # SQL migrations +└── package.json +``` + +## Docker / Coolify Deployment + +The app includes a production `Dockerfile` for Coolify. + +Recommended Coolify settings: + +| Setting | Value | +| ------------------- | ------------ | +| Build Pack | Dockerfile | +| Dockerfile Location | `Dockerfile` | +| Port | `3000` | +| Persistent Storage | `/app/data` | + +Set these environment variables in Coolify: + +```bash +PULSE_SECRET=replace-with-at-least-32-random-characters +DATABASE_URL=/app/data/pulse-clock.db +PULSE_RATE_LIMIT=60 +PULSE_RATE_WINDOW=60 +``` + +Generate a deployment secret with: + +```bash +openssl rand -base64 32 +``` + +For local Docker testing: + +```bash +docker build -t pulse-clock . +docker run --rm -p 3000:3000 -v pulse-clock-data:/app/data --env-file .env pulse-clock +``` + +SQLite migrations run automatically on startup, and the database is stored in `/app/data` when deployed with the Docker defaults. +Run one app replica per SQLite volume so startup migrations are not executed concurrently. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..d63caaf --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: process.env.DATABASE_URL ?? './data/pulse-clock.db', + }, +}); diff --git a/drizzle/0000_chubby_toro.sql b/drizzle/0000_chubby_toro.sql new file mode 100644 index 0000000..dcc39e4 --- /dev/null +++ b/drizzle/0000_chubby_toro.sql @@ -0,0 +1,44 @@ +CREATE TABLE `audit_log` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `employee_id` integer NOT NULL, + `company_id` integer NOT NULL, + `admin_id` integer DEFAULT 0 NOT NULL, + `action` text NOT NULL, + `detail` text NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `audit_employee_idx` ON `audit_log` (`employee_id`);--> statement-breakpoint +CREATE INDEX `audit_company_idx` ON `audit_log` (`company_id`);--> statement-breakpoint +CREATE TABLE `companies` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `employees` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `company_id` integer NOT NULL, + `name` text NOT NULL, + `is_active` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `company_active_idx` ON `employees` (`company_id`,`is_active`);--> statement-breakpoint +CREATE TABLE `time_entries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `employee_id` integer NOT NULL, + `company_id` integer NOT NULL, + `type` text NOT NULL, + `timestamp` integer DEFAULT (unixepoch()) NOT NULL, + `photo_base64` text, + `is_deleted` integer DEFAULT false NOT NULL, + FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`company_id`) REFERENCES `companies`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `employee_timestamp_idx` ON `time_entries` (`employee_id`,`timestamp`);--> statement-breakpoint +CREATE INDEX `company_timestamp_idx` ON `time_entries` (`company_id`,`timestamp`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..8fa1ad1 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,295 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a499d0ef-cc64-470d-98e6-79b06f766cb9", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "employee_id": { + "name": "employee_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "audit_employee_idx": { + "name": "audit_employee_idx", + "columns": ["employee_id"], + "isUnique": false + }, + "audit_company_idx": { + "name": "audit_company_idx", + "columns": ["company_id"], + "isUnique": false + } + }, + "foreignKeys": { + "audit_log_employee_id_employees_id_fk": { + "name": "audit_log_employee_id_employees_id_fk", + "tableFrom": "audit_log", + "tableTo": "employees", + "columnsFrom": ["employee_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_log_company_id_companies_id_fk": { + "name": "audit_log_company_id_companies_id_fk", + "tableFrom": "audit_log", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "companies": { + "name": "companies", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "employees": { + "name": "employees", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "company_active_idx": { + "name": "company_active_idx", + "columns": ["company_id", "is_active"], + "isUnique": false + } + }, + "foreignKeys": { + "employees_company_id_companies_id_fk": { + "name": "employees_company_id_companies_id_fk", + "tableFrom": "employees", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "time_entries": { + "name": "time_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "employee_id": { + "name": "employee_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "photo_base64": { + "name": "photo_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "employee_timestamp_idx": { + "name": "employee_timestamp_idx", + "columns": ["employee_id", "timestamp"], + "isUnique": false + }, + "company_timestamp_idx": { + "name": "company_timestamp_idx", + "columns": ["company_id", "timestamp"], + "isUnique": false + } + }, + "foreignKeys": { + "time_entries_employee_id_employees_id_fk": { + "name": "time_entries_employee_id_employees_id_fk", + "tableFrom": "time_entries", + "tableTo": "employees", + "columnsFrom": ["employee_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "time_entries_company_id_companies_id_fk": { + "name": "time_entries_company_id_companies_id_fk", + "tableFrom": "time_entries", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..102570a --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1779035877611, + "tag": "0000_chubby_toro", + "breakpoints": true + } + ] +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..f9c05ea --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,34 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + + // Ban raw fetch() for internal /api/ routes — prevents API envelope mismatch bugs + { + rules: { + 'react-hooks/set-state-in-effect': 'off', + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.type='MemberExpression'][callee.object.name='fetch']", + message: + 'Use fetchApi() from @/lib/api-fetch instead of raw fetch(). Internal API routes return {data, success} envelope that must be unwrapped.', + }, + ], + }, + }, + + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + ]), +]); + +export default eslintConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..af2c632 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,20 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'same-origin' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'Permissions-Policy', value: 'camera=(self), microphone=()' }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..78e86ad --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5989 @@ +{ + "name": "pulse-clock", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulse-clock", + "version": "0.1.0", + "dependencies": { + "@libsql/client": "^0.17.3", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-webcam": "^7.2.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "daisyui": "^5.5.20", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "knip": "^6.14.1", + "prettier": "^3.8.3", + "react-doctor": "^0.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "dev": true, + "license": "ISC" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@libsql/client": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.3.tgz", + "integrity": "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==", + "license": "MIT", + "dependencies": { + "@libsql/core": "^0.17.3", + "@libsql/hrana-client": "^0.10.0", + "js-base64": "^3.7.5", + "libsql": "^0.5.28", + "promise-limit": "^2.7.0" + } + }, + "node_modules/@libsql/core": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@libsql/core/-/core-0.17.3.tgz", + "integrity": "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/darwin-arm64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.5.29.tgz", + "integrity": "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/darwin-x64": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.5.29.tgz", + "integrity": "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@libsql/hrana-client": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@libsql/hrana-client/-/hrana-client-0.10.0.tgz", + "integrity": "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==", + "license": "MIT", + "dependencies": { + "@libsql/isomorphic-ws": "^0.1.5", + "js-base64": "^3.7.5" + } + }, + "node_modules/@libsql/isomorphic-ws": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@libsql/isomorphic-ws/-/isomorphic-ws-0.1.5.tgz", + "integrity": "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.5.4", + "ws": "^8.13.0" + } + }, + "node_modules/@libsql/linux-arm-gnueabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-gnueabihf/-/linux-arm-gnueabihf-0.5.29.tgz", + "integrity": "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm-musleabihf": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm-musleabihf/-/linux-arm-musleabihf-0.5.29.tgz", + "integrity": "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.5.29.tgz", + "integrity": "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-arm64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.5.29.tgz", + "integrity": "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-gnu": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.5.29.tgz", + "integrity": "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/linux-x64-musl": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.5.29.tgz", + "integrity": "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@libsql/win32-x64-msvc": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.5.29.tgz", + "integrity": "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@neon-rs/load": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", + "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==", + "license": "MIT" + }, + "node_modules/@next/env": { + "version": "16.2.6", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.130.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.130.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.19.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.19.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.65.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.65.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.40", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-install": { + "version": "0.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@iarna/toml": "^2.2.5", + "commander": "^14.0.0", + "jsonc-parser": "^3.3.1", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "yaml": "^2.8.3" + }, + "bin": { + "agent-install": "bin/agent-install.mjs" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/daisyui": { + "version": "5.5.20", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.20.tgz", + "integrity": "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formatly": { + "version": "0.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.0", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/knip": { + "version": "6.14.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.3.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", + "minimist": "^1.2.8", + "oxc-parser": "^0.130.0", + "oxc-resolver": "^11.19.1", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.16", + "unbash": "^3.0.0", + "yaml": "^2.9.0", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/knip/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/knip/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libsql": { + "version": "0.5.29", + "resolved": "https://registry.npmjs.org/libsql/-/libsql-0.5.29.tgz", + "integrity": "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==", + "cpu": [ + "x64", + "arm64", + "wasm32", + "arm" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "@neon-rs/load": "^0.0.4", + "detect-libc": "2.0.2" + }, + "optionalDependencies": { + "@libsql/darwin-arm64": "0.5.29", + "@libsql/darwin-x64": "0.5.29", + "@libsql/linux-arm-gnueabihf": "0.5.29", + "@libsql/linux-arm-musleabihf": "0.5.29", + "@libsql/linux-arm64-gnu": "0.5.29", + "@libsql/linux-arm64-musl": "0.5.29", + "@libsql/linux-x64-gnu": "0.5.29", + "@libsql/linux-x64-musl": "0.5.29", + "@libsql/win32-x64-msvc": "0.5.29" + } + }, + "node_modules/libsql/node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.14.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.6", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "9.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oxc-parser": { + "version": "0.130.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.130.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.130.0", + "@oxc-parser/binding-android-arm64": "0.130.0", + "@oxc-parser/binding-darwin-arm64": "0.130.0", + "@oxc-parser/binding-darwin-x64": "0.130.0", + "@oxc-parser/binding-freebsd-x64": "0.130.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", + "@oxc-parser/binding-linux-arm64-musl": "0.130.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-musl": "0.130.0", + "@oxc-parser/binding-openharmony-arm64": "0.130.0", + "@oxc-parser/binding-wasm32-wasi": "0.130.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", + "@oxc-parser/binding-win32-x64-msvc": "0.130.0" + } + }, + "node_modules/oxc-parser/node_modules/@oxc-project/types": { + "version": "0.130.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/oxc-resolver": { + "version": "11.19.1", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.19.1", + "@oxc-resolver/binding-android-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-arm64": "11.19.1", + "@oxc-resolver/binding-darwin-x64": "11.19.1", + "@oxc-resolver/binding-freebsd-x64": "11.19.1", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", + "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", + "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", + "@oxc-resolver/binding-linux-x64-musl": "11.19.1", + "@oxc-resolver/binding-openharmony-arm64": "11.19.1", + "@oxc-resolver/binding-wasm32-wasi": "11.19.1", + "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", + "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", + "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" + } + }, + "node_modules/oxlint": { + "version": "1.65.0", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.65.0", + "@oxlint/binding-android-arm64": "1.65.0", + "@oxlint/binding-darwin-arm64": "1.65.0", + "@oxlint/binding-darwin-x64": "1.65.0", + "@oxlint/binding-freebsd-x64": "1.65.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.65.0", + "@oxlint/binding-linux-arm-musleabihf": "1.65.0", + "@oxlint/binding-linux-arm64-gnu": "1.65.0", + "@oxlint/binding-linux-arm64-musl": "1.65.0", + "@oxlint/binding-linux-ppc64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-gnu": "1.65.0", + "@oxlint/binding-linux-riscv64-musl": "1.65.0", + "@oxlint/binding-linux-s390x-gnu": "1.65.0", + "@oxlint/binding-linux-x64-gnu": "1.65.0", + "@oxlint/binding-linux-x64-musl": "1.65.0", + "@oxlint/binding-openharmony-arm64": "1.65.0", + "@oxlint/binding-win32-arm64-msvc": "1.65.0", + "@oxlint/binding-win32-ia32-msvc": "1.65.0", + "@oxlint/binding-win32-x64-msvc": "1.65.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, + "node_modules/prompts": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-doctor": { + "version": "0.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-install": "0.0.5", + "commander": "^14.0.3", + "knip": "^6.10.0", + "ora": "^9.4.0", + "oxlint": "^1.63.0", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "typescript": ">=5.0.4 <7" + }, + "bin": { + "react-doctor": "bin/react-doctor.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "eslint-plugin-react-hooks": "^6 || ^7", + "eslint-plugin-react-you-might-not-need-an-effect": "^0.10" + }, + "peerDependenciesMeta": { + "eslint-plugin-react-hooks": { + "optional": true + }, + "eslint-plugin-react-you-might-not-need-an-effect": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "dev": true, + "license": "MIT" + }, + "node_modules/react-webcam": { + "version": "7.2.0", + "license": "MIT", + "peerDependencies": { + "react": ">=16.2.0", + "react-dom": ">=16.2.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.0", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/unbash": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e610bb --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "pulse-clock", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "PULSE_SKIP_DB_BOOTSTRAP=1 next build", + "start": "next start", + "lint": "eslint", + "test": "node scripts/test-calculations.mjs", + "typecheck": "tsc --noEmit", + "check:dead": "knip", + "check:react": "react-doctor . --yes --offline", + "format": "prettier --write .", + "check:format": "prettier --check .", + "check": "npm run lint && npm run typecheck && npm run check:dead && npm run check:react && npm run check:format" + }, + "dependencies": { + "@libsql/client": "^0.17.3", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-webcam": "^7.2.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "daisyui": "^5.5.20", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "knip": "^6.14.1", + "prettier": "^3.8.3", + "react-doctor": "^0.1.6", + "tailwindcss": "^4", + "typescript": "^5" + }, + "overrides": { + "postcss": "^8.5.14", + "esbuild": "^0.28.0" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..297374d --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/scripts/test-calculations.mjs b/scripts/test-calculations.mjs new file mode 100644 index 0000000..78f4410 --- /dev/null +++ b/scripts/test-calculations.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const source = readFileSync(new URL('../src/lib/calculations.ts', import.meta.url), 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, +}).outputText; + +const sandbox = { + exports: {}, + module: { exports: {} }, + require, + Date, + Math, + Set, +}; +sandbox.exports = sandbox.module.exports; +vm.runInNewContext(compiled, sandbox, { filename: 'calculations.ts' }); + +const { calculateDayMinutes } = sandbox.module.exports; + +function unix(localDateTime) { + return Math.floor(new Date(localDateTime).getTime() / 1000); +} + +function entry(id, type, localDateTime) { + return { + id, + employeeId: 1, + type, + timestamp: unix(localDateTime), + isDeleted: false, + }; +} + +function minutes(entries, dateStr, options) { + return calculateDayMinutes(entries, dateStr, options); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +{ + const entries = [entry(1, 'IN', '2026-05-28T09:00:00'), entry(2, 'OUT', '2026-05-28T17:00:00')]; + assert.deepEqual(plain(minutes(entries, '2026-05-28')), { + clockMinutes: 480, + breakMinutes: 0, + workedMinutes: 480, + hasErrors: false, + errors: [], + }); +} + +{ + const entries = [entry(1, 'IN', '2026-05-28T22:00:00'), entry(2, 'OUT', '2026-05-29T06:00:00')]; + assert.equal(minutes(entries, '2026-05-28').workedMinutes, 120); + assert.equal(minutes(entries, '2026-05-29').workedMinutes, 360); + assert.equal(minutes(entries, '2026-05-28').hasErrors, false); + assert.equal(minutes(entries, '2026-05-29').hasErrors, false); +} + +{ + const entries = [ + entry(1, 'IN', '2026-05-28T22:00:00'), + entry(2, 'BREAK_OUT', '2026-05-28T23:30:00'), + entry(3, 'BREAK_IN', '2026-05-29T00:30:00'), + entry(4, 'OUT', '2026-05-29T06:00:00'), + ]; + assert.equal(minutes(entries, '2026-05-28').clockMinutes, 120); + assert.equal(minutes(entries, '2026-05-28').breakMinutes, 30); + assert.equal(minutes(entries, '2026-05-28').workedMinutes, 90); + assert.equal(minutes(entries, '2026-05-29').clockMinutes, 360); + assert.equal(minutes(entries, '2026-05-29').breakMinutes, 30); + assert.equal(minutes(entries, '2026-05-29').workedMinutes, 330); +} + +{ + const entries = [entry(1, 'IN', '2026-05-28T22:00:00')]; + const options = { nowUnix: unix('2026-05-29T02:00:00') }; + const firstDay = minutes(entries, '2026-05-28', options); + const secondDay = minutes(entries, '2026-05-29', options); + assert.equal(firstDay.workedMinutes, 120); + assert.equal(secondDay.workedMinutes, 120); + assert.deepEqual(plain(firstDay.errors), ['Still clocked in']); + assert.deepEqual(plain(secondDay.errors), ['Still clocked in']); +} + +console.log('calculation tests passed'); diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..5cd8f6d --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Admin | Pulse Clock', + description: 'Review time cards, manage hotels and employees, and export payroll.', +}; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/admin/manage/page.tsx b/src/app/admin/manage/page.tsx new file mode 100644 index 0000000..e860db5 --- /dev/null +++ b/src/app/admin/manage/page.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Building2, Users, Pencil, Trash2, X, Check, Plus, ArrowLeft, Monitor } from 'lucide-react'; +import Link from 'next/link'; +import { fetchApi } from '@/lib/api'; + +type Company = { id: number; name: string }; +type Employee = { id: number; name: string; isActive: boolean; companyId: number }; + +export default function ManagePage() { + const [companies, setCompanies] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(''); + const [employees, setEmployees] = useState([]); + + const [editingCompany, setEditingCompany] = useState(null); + const [editingCompanyName, setEditingCompanyName] = useState(''); + const [editingEmployee, setEditingEmployee] = useState(null); + const [editingEmployeeName, setEditingEmployeeName] = useState(''); + + const [newCompanyName, setNewCompanyName] = useState(''); + const [showNewCompany, setShowNewCompany] = useState(false); + const [newEmployeeName, setNewEmployeeName] = useState(''); + const [showNewEmployee, setShowNewEmployee] = useState(false); + const [error, setError] = useState(null); + + const fail = (fallback: string, caught: unknown): never => { + setError(caught instanceof Error ? caught.message : fallback); + throw caught; + }; + + const loadCompanies = useCallback(async () => { + setCompanies(await fetchApi('/api/companies')); + }, []); + + const loadEmployees = useCallback(async (companyId: string) => { + setEmployees(await fetchApi(`/api/employees?companyId=${companyId}`)); + }, []); + + useEffect(() => { + loadCompanies(); + }, [loadCompanies]); + useEffect(() => { + if (selectedCompany) loadEmployees(selectedCompany); + else setEmployees([]); + }, [selectedCompany, loadEmployees]); + + const handleCreateCompany = async () => { + if (!newCompanyName.trim()) return; + try { + setError(null); + await fetchApi<{ company: Company }>('/api/companies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newCompanyName.trim() }), + }); + setNewCompanyName(''); + setShowNewCompany(false); + loadCompanies(); + } catch (caught) { + fail('Failed to create hotel', caught); + } + }; + + const handleUpdateCompany = async (id: number) => { + if (!editingCompanyName.trim()) return; + try { + setError(null); + await fetchApi<{ company: Company }>('/api/companies', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name: editingCompanyName.trim() }), + }); + setEditingCompany(null); + loadCompanies(); + } catch (caught) { + fail('Failed to update hotel', caught); + } + }; + + const handleDeleteCompany = async (id: number) => { + if (!confirm('Delete this hotel and all its employees and time entries?')) return; + try { + setError(null); + await fetchApi<{ company: Company }>(`/api/companies?id=${id}`, { method: 'DELETE' }); + if (selectedCompany === id.toString()) setSelectedCompany(''); + loadCompanies(); + } catch (caught) { + fail('Failed to delete hotel', caught); + } + }; + + const handleCreateEmployee = async () => { + if (!newEmployeeName.trim() || !selectedCompany) return; + try { + setError(null); + await fetchApi<{ employee: Employee }>('/api/employees', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ companyId: parseInt(selectedCompany), name: newEmployeeName.trim() }), + }); + setNewEmployeeName(''); + setShowNewEmployee(false); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to create employee', caught); + } + }; + + const handleUpdateEmployee = async (id: number) => { + if (!editingEmployeeName.trim()) return; + try { + setError(null); + await fetchApi<{ employee: Employee }>('/api/employees', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name: editingEmployeeName.trim() }), + }); + setEditingEmployee(null); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to update employee', caught); + } + }; + + const handleToggleActive = async (employee: Employee) => { + try { + setError(null); + await fetchApi<{ employee: Employee }>('/api/employees', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: employee.id, isActive: !employee.isActive }), + }); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to update employee status', caught); + } + }; + + const handleDeleteEmployee = async (id: number) => { + if (!confirm('Delete this employee and all their time entries?')) return; + try { + setError(null); + await fetchApi<{ employee: Employee }>(`/api/employees?id=${id}`, { method: 'DELETE' }); + loadEmployees(selectedCompany); + } catch (caught) { + fail('Failed to delete employee', caught); + } + }; + + return ( +
+
+
+
+ ⚙️ +

Manage Hotels & Employees

+
+
+ + + +
+
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Hotels Section */} +
+
+

+ Hotels +

+ +
+ + {showNewCompany && ( +
+
+
+ + setNewCompanyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateCompany()} + /> +
+ + +
+
+ )} + +
+ + + + + + + + + + {companies.map((c) => ( + + + + + + ))} + {companies.length === 0 && ( + + + + )} + +
NameIDActions
+ {editingCompany === c.id ? ( +
+ setEditingCompanyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleUpdateCompany(c.id)} + /> + + +
+ ) : ( + {c.name} + )} +
#{c.id} +
+ + + + + +
+
+ No hotels yet +
+
+
+ +
+ + {/* Employees Section */} +
+
+

+ Employees +

+
+ + +
+
+ + {showNewEmployee && selectedCompany && ( +
+
+
+ + setNewEmployeeName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateEmployee()} + /> +
+ + +
+
+ )} + +
+ + + + + + + + + + {employees.map((emp) => ( + + + + + + ))} + {!selectedCompany && ( + + + + )} + {selectedCompany && employees.length === 0 && ( + + + + )} + +
NameStatusActions
+ {editingEmployee === emp.id ? ( +
+ setEditingEmployeeName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleUpdateEmployee(emp.id)} + /> + + +
+ ) : ( + {emp.name} + )} +
+ + +
+ + +
+
+ Select a hotel to see employees +
+ No employees yet +
+
+
+
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..242bb85 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,865 @@ +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { addDays, subDays } from 'date-fns'; +import Image from 'next/image'; +import { + ChevronLeft, + ChevronRight, + Trash2, + Plus, + Building2, + Settings, + X, + Pencil, + Check, + Ban, + AlertTriangle, + Filter, + Download, + Clock, + Coffee, + Zap, +} from 'lucide-react'; +import Link from 'next/link'; +import { formatTime, formatDate, toUnix, dayRange, dateKey, fromInput } from '@/lib/time'; +import { calculateDayMinutes, formatHours } from '@/lib/calculations'; +import { fetchApi } from '@/lib/api'; + +type Company = { id: number; name: string }; +type Employee = { id: number; name: string; isActive: boolean; companyId: number }; +type TimeEntry = { + id: number; + employeeId: number; + companyId: number; + type: 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT'; + timestamp: number; + photoBase64: string | null; + isDeleted: boolean; + employeeName: string; +}; +type DayEntries = { + date: string; + entries: TimeEntry[]; + clockMinutes: number; + breakMinutes: number; + workedMinutes: number; + hasErrors: boolean; + errors: string[]; +}; + +function Drawer({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) { + if (!open) return null; + return ( +
+ + {children} +
+
+ ); +} + +const DATE_PRESETS = [ + { label: 'Today', getDates: () => ({ start: new Date(), end: new Date() }) }, + { label: 'Yesterday', getDates: () => ({ start: subDays(new Date(), 1), end: subDays(new Date(), 1) }) }, + { label: 'Last 7 days', getDates: () => ({ start: subDays(new Date(), 6), end: new Date() }) }, + { label: 'Last 14 days', getDates: () => ({ start: subDays(new Date(), 13), end: new Date() }) }, + { label: 'Last 30 days', getDates: () => ({ start: subDays(new Date(), 29), end: new Date() }) }, +]; + +function dateInputValue(date: Date) { + return formatDate(toUnix(date), 'yyyy-MM-dd'); +} + +function dateInputToDate(value: string, boundary: 'start' | 'end' = 'start') { + return new Date(`${value}${boundary === 'start' ? 'T00:00:00' : 'T23:59:59'}`); +} + +export default function AdminPage() { + const [companies, setCompanies] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(''); + const [employees, setEmployees] = useState([]); + const [allEntries, setAllEntries] = useState([]); + const [startDate, setStartDate] = useState(() => dateInputValue(subDays(new Date(), 13))); + const [endDate, setEndDate] = useState(() => dateInputValue(new Date())); + const [selectedDayEntries, setSelectedDayEntries] = useState<{ + employeeName: string; + entries: TimeEntry[]; + date: string; + errors: string[]; + } | null>(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [manualPunchForm, setManualPunchForm] = useState<{ + employeeId: number; + type: string; + timestamp: string; + forceOverride: boolean; + }>({ employeeId: 0, type: 'IN', timestamp: '', forceOverride: false }); + const [editingEntryIds, setEditingEntryIds] = useState>(new Set()); + const [editTimestamps, setEditTimestamps] = useState>({}); + const [editingOverride, setEditingOverride] = useState>({}); + const [showIssuesOnly, setShowIssuesOnly] = useState(false); + const [manualPunchError, setManualPunchError] = useState(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setSheetOpen(false); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + useEffect(() => { + fetchApi('/api/companies') + .then((data) => { + setCompanies(data); + setSelectedCompany((current) => current || data[0]?.id.toString() || ''); + }) + .catch(console.error); + }, []); + + useEffect(() => { + if (!selectedCompany) return; + fetchApi(`/api/employees?companyId=${selectedCompany}`).then(setEmployees).catch(console.error); + }, [selectedCompany]); + + const loadEntries = useCallback(async () => { + if (!selectedCompany) return; + const { start: startUnix } = dayRange(dateInputToDate(startDate)); + const { end: endUnix } = dayRange(dateInputToDate(endDate, 'end')); + const contextStartUnix = startUnix - 36 * 3600; + const contextEndUnix = endUnix + 36 * 3600; + fetchApi( + `/api/time-entries?companyId=${selectedCompany}&startDate=${contextStartUnix}&endDate=${contextEndUnix}`, + ) + .then(setAllEntries) + .catch(console.error); + }, [selectedCompany, startDate, endDate]); + + useEffect(() => { + loadEntries(); + }, [loadEntries]); + + const days = useMemo(() => { + const start = dateInputToDate(startDate); + const end = dateInputToDate(endDate); + const diff = Math.max((end.getTime() - start.getTime()) / 86400000, 0); + return Array.from({ length: diff + 1 }, (_, i) => addDays(start, i)); + }, [startDate, endDate]); + + const gridData = useMemo(() => { + const data: Record> = {}; + for (const emp of employees) { + const empData: Record = {}; + data[emp.id] = empData; + for (const day of days) { + const { start, end } = dayRange(day); + const dateStr = dateKey(toUnix(day)); + const empEntries = allEntries.filter((e) => e.employeeId === emp.id); + const dayEntries = empEntries.filter( + (e) => e.employeeId === emp.id && e.timestamp >= start && e.timestamp <= end, + ); + // Use the shared library , never duplicate inline + const result = calculateDayMinutes(empEntries, dateStr); + empData[dateStr] = { date: dateStr, entries: dayEntries, ...result }; + } + } + return data; + }, [employees, allEntries, days]); + + const employeeTotals = useMemo(() => { + const totals: Record< + number, + { clockMinutes: number; breakMinutes: number; workedMinutes: number; hasActivity: boolean } + > = {}; + for (const emp of employees) { + let clockMinutes = 0, + breakMinutes = 0, + workedMinutes = 0, + hasActivity = false; + for (const day of days) { + const dayData = gridData[emp.id]?.[dateKey(toUnix(day))]; + if (dayData) { + clockMinutes += dayData.clockMinutes; + breakMinutes += dayData.breakMinutes; + workedMinutes += dayData.workedMinutes; + if (dayData.entries.length > 0) hasActivity = true; + } + } + totals[emp.id] = { clockMinutes, breakMinutes, workedMinutes, hasActivity }; + } + return totals; + }, [employees, gridData, days]); + + const issueStats = useMemo(() => { + let totalErrors = 0; + const employeesWithErrors = new Set(); + for (const emp of employees) { + for (const day of days) { + if (gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors) { + totalErrors++; + employeesWithErrors.add(emp.id); + } + } + } + return { totalErrors, employeeCount: employeesWithErrors.size }; + }, [employees, days, gridData]); + + const visibleEmployees = useMemo(() => { + if (!showIssuesOnly) return employees; + return employees.filter((emp) => days.some((day) => gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors)); + }, [employees, days, gridData, showIssuesOnly]); + + const openDetailDrawer = (employee: Employee, dateStr: string) => { + const dayData = gridData[employee.id]?.[dateStr]; + if (!dayData) return; + setSelectedDayEntries({ + employeeName: employee.name, + entries: dayData.entries, + date: dateStr, + errors: dayData.errors || [], + }); + setManualPunchForm({ + employeeId: employee.id, + type: 'IN', + timestamp: new Date().toISOString().slice(0, 16), + forceOverride: false, + }); + setEditingEntryIds(new Set()); + setEditTimestamps({}); + setEditingOverride({}); + setManualPunchError(null); + setSheetOpen(true); + }; + + const handleDeleteEntry = async (entryId: number) => { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: entryId, isDeleted: true }), + }); + loadEntries(); + }; + + const handleRestoreEntry = async (entryId: number) => { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: entryId, isDeleted: false }), + }); + loadEntries(); + }; + + const handleAddManualPunch = async () => { + if (!manualPunchForm || !selectedCompany) return; + setManualPunchError(null); + const timestamp = fromInput(manualPunchForm.timestamp); + try { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + employeeId: manualPunchForm.employeeId, + companyId: parseInt(selectedCompany), + type: manualPunchForm.type, + timestamp, + forceOverride: manualPunchForm.forceOverride, + }), + }); + setManualPunchForm((prev) => ({ + ...prev, + type: 'IN', + timestamp: new Date().toISOString().slice(0, 16), + forceOverride: false, + })); + loadEntries(); + } catch (e: unknown) { + setManualPunchError(e instanceof Error ? e.message : 'Failed to add punch'); + throw e; + } + }; + + const startEditingTimestamp = (entry: TimeEntry) => { + setEditingEntryIds((prev) => { + const n = new Set(prev); + n.add(entry.id); + return n; + }); + setEditTimestamps((prev) => ({ + ...prev, + [entry.id]: `${formatDate(entry.timestamp, 'yyyy-MM-dd')}T${formatDate(entry.timestamp, 'HH:mm')}`, + })); + }; + + const saveTimestamp = async (entryId: number, forceOverride = false) => { + const ts = editTimestamps[entryId]; + if (!ts) return; + try { + await fetchApi<{ entry: TimeEntry }>('/api/time-entries', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: entryId, timestamp: fromInput(ts), forceOverride }), + }); + setEditingEntryIds((prev) => { + const n = new Set(prev); + n.delete(entryId); + return n; + }); + setEditingOverride((prev) => { + const n = { ...prev }; + delete n[entryId]; + return n; + }); + loadEntries(); + } catch (e: unknown) { + alert(e instanceof Error ? e.message : 'Failed to update timestamp'); + throw e; + } + }; + + const cancelEditing = (entryId: number) => { + setEditingEntryIds((prev) => { + const n = new Set(prev); + n.delete(entryId); + return n; + }); + setEditingOverride((prev) => { + const n = { ...prev }; + delete n[entryId]; + return n; + }); + }; + + const applyPreset = (preset: (typeof DATE_PRESETS)[0]) => { + const { start, end } = preset.getDates(); + setStartDate(dateInputValue(start)); + setEndDate(dateInputValue(end)); + }; + + const shiftWindow = (direction: 1 | -1) => { + // Shift by 1 day increments, preserving the existing date span + setStartDate(dateInputValue(addDays(dateInputToDate(startDate), direction))); + setEndDate(dateInputValue(addDays(dateInputToDate(endDate), direction))); + }; + + if (companies.length === 0) { + return ( +
+
+
📊
+

Admin Dashboard

+

No hotels yet.

+ + + +
+
+ ); + } + + const entryDotColors: Record = { + IN: 'bg-success', + OUT: 'bg-error', + BREAK_OUT: 'bg-warning', + BREAK_IN: 'bg-warning', + }; + const entryLabels: Record = { + IN: 'Clock In', + OUT: 'Clock Out', + BREAK_OUT: 'Break Start', + BREAK_IN: 'Break End', + }; + const entryIcons: Record = { IN: '🟢', OUT: '🔴', BREAK_OUT: '🟡', BREAK_IN: '🟡' }; + + return ( +
+ {/* Header */} +
+
+
+ 📊 +

Pulse Clock · Admin

+
+
+ + e.target.value && setStartDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> + + e.target.value && setEndDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> +
+ + +
+ + + + + + + + +
+
+ {/* Date presets */} +
+ {DATE_PRESETS.map((p) => ( + + ))} +
+
+ + {/* Issue Summary Banner , prominent */} +
+
+
+ {issueStats.totalErrors === 0 ? '✅' : } +
+
+ {issueStats.totalErrors === 0 ? ( + <> +
All Clear
+
+ {employees.length} employee{employees.length !== 1 ? 's' : ''} tracked · {days.length} day + {days.length !== 1 ? 's' : ''} · 0 issues found +
+ + ) : ( + <> +
+ ⚠️ {issueStats.totalErrors} Issue{issueStats.totalErrors !== 1 ? 's' : ''} Found +
+
+ Across {issueStats.employeeCount} employee{issueStats.employeeCount !== 1 ? 's' : ''} · Click any + flagged cell or{' '} + +
+ + )} +
+ {issueStats.totalErrors > 0 && ( + + )} +
+
+ + {/* Time Grid */} +
+
+ {/* Header row */} +
+
Employee
+ {days.map((day) => ( +
+
{formatDate(toUnix(day), 'EEE')}
+
{formatDate(toUnix(day), 'MM/dd')}
+
+ ))} +
+ Clock Hrs +
+
+ Break +
+
+ Net Hrs +
+
+ + {/* Employee rows */} + {visibleEmployees.map((emp) => { + const totals = employeeTotals[emp.id] || { + clockMinutes: 0, + breakMinutes: 0, + workedMinutes: 0, + hasActivity: false, + }; + const hasAnyError = days.some((day) => gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors); + return ( +
+
+ {emp.name} + {hasAnyError && } +
+ {days.map((day) => { + const dateStr = dateKey(toUnix(day)); + const dayData = gridData[emp.id]?.[dateStr]; + const bMin = dayData?.breakMinutes || 0; + const wMin = dayData?.workedMinutes || 0; + const hasErr = dayData?.hasErrors || false; + const hasActivity = + (dayData?.entries?.length ?? 0) > 0 || + (dayData?.clockMinutes ?? 0) > 0 || + (dayData?.breakMinutes ?? 0) > 0; + return ( + + ); + })} +
+ {totals.hasActivity && formatHours(totals.clockMinutes) === '-' + ? '0m' + : formatHours(totals.clockMinutes)} +
+
+ {totals.hasActivity && formatHours(totals.breakMinutes) === '-' + ? '0m' + : formatHours(totals.breakMinutes)} +
+
+ {totals.hasActivity && formatHours(totals.workedMinutes) === '-' + ? '0m' + : formatHours(totals.workedMinutes)} +
+
+ ); + })} + + {visibleEmployees.length === 0 && ( +
+
👥
+

+ {showIssuesOnly + ? 'No issues found for any employee in this date range! 🎉' + : 'No employees found for this hotel. Add employees in the Manage page.'} +

+ {showIssuesOnly && ( + + )} +
+ )} +
+
+ + {/* Detail Drawer */} + setSheetOpen(false)}> +
+

{selectedDayEntries?.employeeName}

+ {selectedDayEntries?.date} +
+

+ {selectedDayEntries?.entries.filter((e) => !e.isDeleted).length + ? `${selectedDayEntries!.entries.filter((e) => !e.isDeleted).length} entries` + : 'No entries'}{' '} + for this day +

+ + {/* Summary strip */} + {selectedDayEntries && ( +
+
+
Clock Hours
+
+ {' '} + {formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.clockMinutes || 0)} +
+
+
+
Break
+
+ {' '} + {formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.breakMinutes || 0)} +
+
+
+
Net Worked
+
+ {' '} + {formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.workedMinutes || 0)} +
+
+
+ )} + + {/* Issue summary section */} + {selectedDayEntries && selectedDayEntries.errors.length > 0 && ( +
+
+ + Issues Found +
+
    + {selectedDayEntries.errors.map((err) => ( +
  • + + {err} +
  • + ))} +
+
+ )} + + {/* Audit timeline */} +
+

Punch Timeline

+ {(selectedDayEntries?.entries ?? []) + .toSorted((a, b) => a.timestamp - b.timestamp) + .map((entry, i, arr) => { + const isEditing = editingEntryIds.has(entry.id); + const activeEntries = arr.filter((e) => !e.isDeleted); + const idx = activeEntries.indexOf(entry); + const forceOv = editingOverride[entry.id] || false; + return ( +
+ {!entry.isDeleted && ( +
+
+ {idx < activeEntries.length - 1 && idx >= 0 && ( +
+ )} +
+ )} +
+
+
+ {entryIcons[entry.type]} + + {entryLabels[entry.type] || entry.type} + + {isEditing ? ( +
+
+ setEditTimestamps((prev) => ({ ...prev, [entry.id]: e.target.value }))} + className="input input-bordered input-xs w-44" + /> + + +
+ +
+ ) : ( + <> + + {formatTime(entry.timestamp, 'MMM d, h:mm a')} + + + + )} +
+
+ {entry.isDeleted ? ( + + ) : ( + + )} +
+
+ {entry.photoBase64 && !entry.isDeleted && ( + Punch + )} +
+
+ ); + })} + {!selectedDayEntries?.entries?.length && ( +

No punches recorded for this day

+ )} +
+ +
+ + {/* Manual Punch Form */} +
+

+ Add Manual Punch +

+ + {/* Error display */} + {manualPunchError && ( +
+ {manualPunchError} +
+ )} + +
+
+ + +
+
+ + setManualPunchForm((prev) => (prev ? { ...prev, timestamp: e.target.value } : prev))} + /> +
+ + +
+
+ +
+ ); +} diff --git a/src/app/admin/payroll/page.tsx b/src/app/admin/payroll/page.tsx new file mode 100644 index 0000000..9c712c8 --- /dev/null +++ b/src/app/admin/payroll/page.tsx @@ -0,0 +1,466 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo, Fragment } from 'react'; +import { format, subDays, startOfMonth, endOfMonth, setDate } from 'date-fns'; +import { ArrowLeft, Copy, Check, Download, RefreshCw, AlertTriangle } from 'lucide-react'; +import Link from 'next/link'; +import { fetchApi } from '@/lib/api'; + +import { formatHours } from '@/lib/calculations'; + +type Company = { id: number; name: string }; + +type PayrollDay = { + date: string; + inTime: string; + outTime: string; + breakMinutes: number; + clockMinutes: number; + workedMinutes: number; + hasErrors: boolean; + errors: string[]; +}; + +type PayrollEmployee = { + id: number; + name: string; + days: PayrollDay[]; + totals: { clockMinutes: number; breakMinutes: number; workedMinutes: number }; +}; + +type PayrollData = { + company: string; + period: { startDate: string; endDate: string }; + employees: PayrollEmployee[]; + summary: string; +}; + +function fmtDecimalHours(mins: number): string { + if (mins === 0) return ','; + return (mins / 60).toFixed(2) + ' hrs'; +} + +// Date range presets specifically for payroll +const DATE_PRESETS = [ + { + label: 'Today', + apply: () => { + const today = format(new Date(), 'yyyy-MM-dd'); + return { start: today, end: today }; + }, + }, + { + label: 'Yesterday', + apply: () => { + const y = subDays(new Date(), 1); + const d = format(y, 'yyyy-MM-dd'); + return { start: d, end: d }; + }, + }, + { + label: 'Last 7 days', + apply: () => ({ + start: format(subDays(new Date(), 6), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd'), + }), + }, + { + label: 'Last 14 days', + apply: () => ({ + start: format(subDays(new Date(), 13), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd'), + }), + }, + { + label: 'Pay Period 1–15', + apply: () => { + const today = new Date(); + const day = today.getDate(); + if (day <= 15) { + return { + start: format(setDate(today, 1), 'yyyy-MM-dd'), + end: format(setDate(today, 15), 'yyyy-MM-dd'), + }; + } + return { + start: format(setDate(today, 16), 'yyyy-MM-dd'), + end: format(endOfMonth(today), 'yyyy-MM-dd'), + }; + }, + }, + { + label: 'Pay Period 16–31', + apply: () => { + const today = new Date(); + const day = today.getDate(); + if (day >= 16) { + return { + start: format(setDate(today, 16), 'yyyy-MM-dd'), + end: format(endOfMonth(today), 'yyyy-MM-dd'), + }; + } + return { + start: format(setDate(today, 1), 'yyyy-MM-dd'), + end: format(setDate(today, 15), 'yyyy-MM-dd'), + }; + }, + }, + { + label: 'This Month', + apply: () => ({ + start: format(startOfMonth(new Date()), 'yyyy-MM-dd'), + end: format(new Date(), 'yyyy-MM-dd'), + }), + }, +]; + +export default function PayrollPage() { + const [companies, setCompanies] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(''); + const [startDate, setStartDate] = useState(() => format(subDays(new Date(), 13), 'yyyy-MM-dd')); + const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd')); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + fetchApi('/api/companies') + .then((data) => { + setCompanies(data); + setSelectedCompany((current) => current || data[0]?.id.toString() || ''); + }) + .catch(() => {}); + }, []); + + const loadPayroll = useCallback(async () => { + if (!selectedCompany || !startDate || !endDate) return; + setLoading(true); + setError(null); + try { + const result = await fetchApi( + `/api/payroll?companyId=${selectedCompany}&startDate=${startDate}&endDate=${endDate}`, + ); + setData(result); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load payroll data'); + setData(null); + throw e; + } finally { + setLoading(false); + } + }, [selectedCompany, startDate, endDate]); + useEffect(() => { + if (selectedCompany) loadPayroll(); + }, [selectedCompany, loadPayroll]); + + const applyPreset = (preset: (typeof DATE_PRESETS)[0]) => { + const { start, end } = preset.apply(); + setStartDate(start); + setEndDate(end); + }; + + const totalStats = useMemo(() => { + if (!data) return { totalDays: 0, errorDays: 0, employeeCount: 0, totalHours: 0 }; + let errorDays = 0; + let totalHours = 0; + for (const emp of data.employees) { + totalHours += emp.totals.workedMinutes; + for (const day of emp.days) { + if (day.hasErrors) errorDays++; + } + } + return { + totalDays: data.employees.reduce((a, e) => a + e.days.filter((d) => d.inTime || d.outTime).length, 0), + errorDays, + employeeCount: data.employees.length, + totalHours, + }; + }, [data]); + + const copyTSV = async () => { + if (!data?.summary) return; + try { + await navigator.clipboard.writeText(data.summary); + } catch (caught) { + const ta = document.createElement('textarea'); + ta.value = data.summary; + document.body.appendChild(ta); + ta.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(ta); + if (!copied) throw caught; + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const downloadTSV = () => { + if (!data?.summary) return; + const blob = new Blob([data.summary], { type: 'text/tab-separated-values' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `payroll-${data.company}-${data.period.startDate}-to-${data.period.endDate}.tsv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+
+
+ 💰 +

Payroll Export

+
+ + + +
+
+ +
+ {/* Controls */} +
+
+ {/* Hotel */} +
+ + +
+ + {/* Date range */} +
+ + setStartDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> +
+
+ + setEndDate(e.target.value)} + className="input input-bordered input-sm w-36" + /> +
+ + {/* Date presets */} +
+ {DATE_PRESETS.map((p) => ( + + ))} +
+ + {/* Generate button */} + +
+ + {error && ( +
+ {error} +
+ )} +
+ + {/* Summary + Export actions */} + {data && ( + <> + {/* Summary strip */} +
+
+
+
+
Hotel
+
{data.company}
+
+
+
Period
+
+ {data.period.startDate} → {data.period.endDate} +
+
+
+
Employees
+
{data.employees.length}
+
+
+
Total Days
+
{totalStats.totalDays}
+
+
+
Total Hours
+
{formatHours(totalStats.totalHours)}
+
+ {totalStats.errorDays > 0 && ( +
+
⚠️ Issues
+
+ {totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with errors +
+
+ )} +
+ + {/* Export actions */} +
+ + +
+
+ + {/* Issue warning bar */} + {totalStats.errorDays > 0 && ( +
+ + + {totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with validation issues , review + the Notes column below before exporting to payroll. Errors + include: missing clock-out, unended breaks, or consecutive duplicate entries. + +
+ )} +
+ + {/* Preview Table */} + {data.employees.length > 0 ? ( +
+
+

Payroll Preview

+

+ TSV columns: Employee Name | Date | Clock In | Clock Out | Break (min) | Net Hours | Notes +

+
+
+ + + + + + + + + + + + + + {data.employees.map((emp) => { + const hasAnyError = emp.days.some((d) => d.hasErrors); + const visibleDays = emp.days.filter( + (d) => + d.inTime || d.outTime || d.clockMinutes > 0 || d.workedMinutes > 0 || d.breakMinutes > 0, + ); + return ( + + {visibleDays.map((day, di) => ( + + {di === 0 && ( + + )} + + + + + + + + ))} + {/* Totals row */} + + + + + + + + + ); + })} + +
EmployeeDateInOutBreak (min)Net HoursNotes / Issues
+
+ {emp.name} + {hasAnyError && } +
+
{day.date}{day.inTime || ','}{day.outTime || ','}{day.breakMinutes > 0 ? day.breakMinutes : ','} 0 ? 'text-success' : 'text-base-content/30'}`} + > + {day.workedMinutes > 0 ? fmtDecimalHours(day.workedMinutes) : ','} + + {day.hasErrors ? ( +
+ {day.errors.map((err, ei) => ( + + ⚠️ {err} + + ))} +
+ ) : day.workedMinutes > 0 ? ( + ✅ OK + ) : ( + , + )} +
+ {emp.name} , TOTAL + ,,{formatHours(emp.totals.breakMinutes)}{fmtDecimalHours(emp.totals.workedMinutes)} +
+
+
+ ) : ( +
+
📋
+

No time entries found for this period

+

Try a different date range

+
+ )} + + )} + + {!data && !loading && ( +
+
💰
+

Select a hotel and date range, then click Preview

+
+ )} +
+
+ ); +} diff --git a/src/app/api/authorize/route.ts b/src/app/api/authorize/route.ts new file mode 100644 index 0000000..6e02ada --- /dev/null +++ b/src/app/api/authorize/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPulseSecret, verifyCookie, setAuthCookie, verifySecret } from '@/lib/auth-cookie'; + +export async function GET(request: NextRequest) { + if (verifyCookie(request)) { + return NextResponse.json({ ok: true }); + } + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { secret } = body as { secret?: string }; + + if (!secret) { + return NextResponse.json({ error: 'Secret is required' }, { status: 400 }); + } + + if (!getPulseSecret()) { + return NextResponse.json( + { error: 'Server not configured: PULSE_SECRET must be at least 32 characters' }, + { status: 500 }, + ); + } + + if (!verifySecret(secret)) { + return NextResponse.json({ error: 'Invalid secret' }, { status: 401 }); + } + + const response = NextResponse.json({ ok: true }); + setAuthCookie(response); + return response; + } catch { + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + } +} diff --git a/src/app/api/companies/route.ts b/src/app/api/companies/route.ts new file mode 100644 index 0000000..b1c52bc --- /dev/null +++ b/src/app/api/companies/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { companies, employees, timeEntries, auditLog } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { CompanySchema, CompanyUpdateSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; + +export async function GET() { + await dbReady; + const results = await db.select().from(companies).all(); + return NextResponse.json(ok(results)); +} + +export async function POST(request: NextRequest) { + const body = await request.json(); + const parsed = CompanySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + await dbReady; + const company = await db.insert(companies).values(parsed.data).returning().get(); + return NextResponse.json(ok({ company })); +} + +export async function PATCH(request: NextRequest) { + const body = await request.json(); + const parsed = CompanyUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + await dbReady; + const company = await db + .update(companies) + .set({ name: parsed.data.name }) + .where(eq(companies.id, parsed.data.id)) + .returning() + .get(); + if (!company) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + return NextResponse.json(ok({ company })); +} + +export async function DELETE(request: NextRequest) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + const parsed = id ? parseInt(id) : NaN; + if (isNaN(parsed)) { + return NextResponse.json(err('Invalid id'), { status: 400 }); + } + + await dbReady; + const deletedCompany = await db.transaction(async (tx) => { + await Promise.all([ + tx.delete(auditLog).where(eq(auditLog.companyId, parsed)).run(), + tx.delete(timeEntries).where(eq(timeEntries.companyId, parsed)).run(), + ]); + await tx.delete(employees).where(eq(employees.companyId, parsed)).run(); + return tx.delete(companies).where(eq(companies.id, parsed)).returning().get(); + }); + + if (!deletedCompany) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + return NextResponse.json(ok({ company: deletedCompany })); +} diff --git a/src/app/api/employees/route.ts b/src/app/api/employees/route.ts new file mode 100644 index 0000000..7a6bf3b --- /dev/null +++ b/src/app/api/employees/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { employees, companies, timeEntries } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { EmployeeSchema, EmployeeUpdateSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = searchParams.get('companyId'); + if (!companyId) { + return NextResponse.json(err('companyId is required'), { status: 400 }); + } + await dbReady; + const result = await db + .select({ + id: employees.id, + name: employees.name, + isActive: employees.isActive, + companyId: employees.companyId, + }) + .from(employees) + .where(and(eq(employees.companyId, parseInt(companyId)), eq(employees.isActive, true))) + .all(); + return NextResponse.json(ok(result)); +} + +export async function POST(request: NextRequest) { + const body = await request.json(); + const parsed = EmployeeSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + await dbReady; + const company = await db.select().from(companies).where(eq(companies.id, parsed.data.companyId)).get(); + if (!company) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + const employee = await db.insert(employees).values(parsed.data).returning().get(); + return NextResponse.json(ok({ employee })); +} + +export async function PATCH(request: NextRequest) { + const body = await request.json(); + const parsed = EmployeeUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { id, ...rest } = parsed.data; + const updateData: Partial = {}; + if (rest.name !== undefined) updateData.name = rest.name; + if (rest.isActive !== undefined) updateData.isActive = rest.isActive; + if (Object.keys(updateData).length === 0) { + return NextResponse.json(err('No fields to update'), { status: 400 }); + } + await dbReady; + const employee = await db.update(employees).set(updateData).where(eq(employees.id, id)).returning().get(); + if (!employee) { + return NextResponse.json(err('Employee not found'), { status: 404 }); + } + return NextResponse.json(ok({ employee })); +} + +export async function DELETE(request: NextRequest) { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) { + return NextResponse.json(err('id is required'), { status: 400 }); + } + const parsedId = parseInt(id); + if (isNaN(parsedId)) { + return NextResponse.json(err('Invalid id'), { status: 400 }); + } + await dbReady; + const deletedEmployee = await db.transaction(async (tx) => { + await tx.delete(timeEntries).where(eq(timeEntries.employeeId, parsedId)).run(); + return tx.delete(employees).where(eq(employees.id, parsedId)).returning().get(); + }); + if (!deletedEmployee) { + return NextResponse.json(err('Employee not found'), { status: 404 }); + } + return NextResponse.json(ok({ employee: deletedEmployee })); +} diff --git a/src/app/api/employees/with-status/route.ts b/src/app/api/employees/with-status/route.ts new file mode 100644 index 0000000..3fb5fd8 --- /dev/null +++ b/src/app/api/employees/with-status/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { employees, timeEntries } from '@/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { statusAfter } from '@/lib/validation'; +import { ok, err } from '@/lib/api-response'; +import { calculateLiveShiftElapsed } from '@/lib/calculations'; +import { parseId } from '@/lib/params'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = parseId(searchParams.get('companyId')); + if (companyId == null) { + return NextResponse.json(err('valid companyId is required'), { status: 400 }); + } + + await dbReady; + const activeEmps = await db + .select({ id: employees.id, name: employees.name }) + .from(employees) + .where(and(eq(employees.companyId, companyId), eq(employees.isActive, true))) + .all(); + + if (activeEmps.length === 0) { + return NextResponse.json(ok({ employees: [], fetchedAt: Math.floor(Date.now() / 1000) })); + } + + const allEntries = await db + .select({ + id: timeEntries.id, + employeeId: timeEntries.employeeId, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + isDeleted: timeEntries.isDeleted, + }) + .from(timeEntries) + .where( + and( + eq(timeEntries.companyId, companyId), + eq(timeEntries.isDeleted, false), + inArray( + timeEntries.employeeId, + activeEmps.map((emp) => emp.id), + ), + ), + ) + .orderBy(timeEntries.employeeId, timeEntries.timestamp, timeEntries.id) + .all(); + + const employeeList = activeEmps.map((emp) => { + const entries = allEntries.filter((entry) => entry.employeeId === emp.id); + const latestEntry = entries.at(-1); + return { + id: emp.id, + name: emp.name, + status: latestEntry ? statusAfter(latestEntry.type) : 'OUT', + lastPunchTimestamp: latestEntry?.timestamp ?? null, + ...calculateLiveShiftElapsed(entries), + }; + }); + + return NextResponse.json(ok({ employees: employeeList, fetchedAt: Math.floor(Date.now() / 1000) })); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..3efea1c --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/payroll/route.ts b/src/app/api/payroll/route.ts new file mode 100644 index 0000000..dd24c66 --- /dev/null +++ b/src/app/api/payroll/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { employees, timeEntries, companies } from '@/db/schema'; +import { eq, and, gte, lte } from 'drizzle-orm'; +import { calculateDayMinutes, formatHours } from '@/lib/calculations'; +import { format, addDays } from 'date-fns'; +import { ok, err } from '@/lib/api-response'; +import { parseId } from '@/lib/params'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = parseId(searchParams.get('companyId')); + const startDateStr = searchParams.get('startDate'); + const endDateStr = searchParams.get('endDate'); + + if (companyId == null) { + return NextResponse.json(err('valid companyId is required'), { status: 400 }); + } + + if (!startDateStr || !endDateStr) { + return NextResponse.json(err('startDate and endDate are required'), { status: 400 }); + } + + const startDate = new Date(startDateStr + 'T00:00:00'); + const endDate = new Date(endDateStr + 'T23:59:59'); + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return NextResponse.json(err('Invalid date format'), { status: 400 }); + } + const startUnix = Math.floor(startDate.getTime() / 1000); + const endUnix = Math.floor(endDate.getTime() / 1000); + const contextStartUnix = startUnix - 36 * 3600; + const contextEndUnix = endUnix + 36 * 3600; + + await dbReady; + const [company, companyEmployees, allEntries] = await Promise.all([ + db.select().from(companies).where(eq(companies.id, companyId)).get(), + db + .select() + .from(employees) + .where(and(eq(employees.companyId, companyId), eq(employees.isActive, true))) + .all(), + db + .select({ + id: timeEntries.id, + employeeId: timeEntries.employeeId, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + isDeleted: timeEntries.isDeleted, + }) + .from(timeEntries) + .where( + and( + eq(timeEntries.companyId, companyId), + eq(timeEntries.isDeleted, false), + gte(timeEntries.timestamp, contextStartUnix), + lte(timeEntries.timestamp, contextEndUnix), + ), + ) + .orderBy(timeEntries.timestamp) + .all(), + ]); + if (!company) { + return NextResponse.json(err('Company not found'), { status: 404 }); + } + + const days: string[] = []; + let dayDate = startDate; + while (dayDate <= endDate) { + days.push(format(dayDate, 'yyyy-MM-dd')); + dayDate = addDays(dayDate, 1); + } + + const resultEmployees = companyEmployees.map((emp) => { + const empEntries = allEntries.filter((e) => e.employeeId === emp.id); + const empDays = days.map((dayStr) => { + const dayStart = new Date(dayStr + 'T00:00:00').getTime() / 1000; + const dayEnd = new Date(dayStr + 'T23:59:59').getTime() / 1000; + const dayEntries = empEntries.filter((e) => e.timestamp >= dayStart && e.timestamp <= dayEnd); + // Single call , errors are returned directly + const { clockMinutes, breakMinutes, workedMinutes, hasErrors, errors } = calculateDayMinutes(empEntries, dayStr); + + const inEntry = dayEntries.find((e) => e.type === 'IN' && !e.isDeleted); + const outEntry = dayEntries.find((e) => e.type === 'OUT' && !e.isDeleted); + + return { + date: dayStr, + inTime: inEntry ? format(new Date(inEntry.timestamp * 1000), 'h:mm a') : '', + outTime: outEntry ? format(new Date(outEntry.timestamp * 1000), 'h:mm a') : '', + breakMinutes: Math.round(breakMinutes), + clockMinutes: Math.round(clockMinutes), + workedMinutes: Math.round(workedMinutes), + hasErrors, + errors, + }; + }); + + const totals = empDays.reduce( + (acc, day) => ({ + clockMinutes: acc.clockMinutes + day.clockMinutes, + breakMinutes: acc.breakMinutes + day.breakMinutes, + workedMinutes: acc.workedMinutes + day.workedMinutes, + }), + { clockMinutes: 0, breakMinutes: 0, workedMinutes: 0 }, + ); + + return { + id: emp.id, + name: emp.name, + days: empDays, + totals, + }; + }); + + let summary = 'Employee\tDate\tIn\tOut\tBreak\tNet\tNotes\n'; + for (const emp of resultEmployees) { + for (const day of emp.days) { + const hasErrors = day.errors.length > 0; + summary += `${emp.name}\t${day.date}\t${day.inTime}\t${day.outTime}\t${formatHours(day.breakMinutes)}\t${formatHours(day.workedMinutes)}\t${hasErrors ? '[WARN] ' + day.errors.join('; ') : ''}\n`; + } + summary += `${emp.name}\tTOTAL\t\t\t${formatHours(emp.totals.breakMinutes)}\t${formatHours(emp.totals.workedMinutes)}\t\n`; + } + + return NextResponse.json( + ok({ + company: company.name, + period: { startDate: startDateStr, endDate: endDateStr }, + employees: resultEmployees, + summary, + }), + ); +} diff --git a/src/app/api/punch/route.ts b/src/app/api/punch/route.ts new file mode 100644 index 0000000..9fcd211 --- /dev/null +++ b/src/app/api/punch/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PunchSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; +import { createValidatedTimeEntry } from '@/lib/time-entry-service'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const parsed = PunchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { employeeId, companyId, type, photoBase64 } = parsed.data; + + const result = await createValidatedTimeEntry({ + employeeId, + companyId, + type, + photoBase64, + source: 'kiosk', + }); + + if (!result.ok) { + return NextResponse.json(err(result.error), { status: result.status }); + } + + return NextResponse.json( + ok({ + entry: { + id: result.entry.id, + employeeId: result.entry.employeeId, + type: result.entry.type, + timestamp: result.entry.timestamp, + }, + status: result.status, + }), + ); + } catch (error) { + console.error('Punch error:', error); + return NextResponse.json(err('Internal server error'), { status: 500 }); + } +} diff --git a/src/app/api/time-entries/route.ts b/src/app/api/time-entries/route.ts new file mode 100644 index 0000000..a44f9fa --- /dev/null +++ b/src/app/api/time-entries/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, dbReady } from '@/db'; +import { timeEntries, employees } from '@/db/schema'; +import { eq, and, gte, lte } from 'drizzle-orm'; +import { validateTimestampChronology } from '@/lib/validation'; +import { ManualEntrySchema, TimeEntryUpdateSchema } from '@/lib/schemas'; +import { ok, err } from '@/lib/api-response'; +import { createValidatedTimeEntry } from '@/lib/time-entry-service'; + +function parseRangeParam(value: string, boundary: 'start' | 'end'): number { + if (/^\d+$/.test(value)) return Number(value); + + const suffix = boundary === 'start' ? 'T00:00:00' : 'T23:59:59'; + return Math.floor(new Date(`${value}${suffix}`).getTime() / 1000); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const companyId = searchParams.get('companyId'); + const employeeId = searchParams.get('employeeId'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + if (!companyId) { + return NextResponse.json(err('companyId is required'), { status: 400 }); + } + + const whereConditions = [eq(timeEntries.companyId, parseInt(companyId))]; + if (employeeId) whereConditions.push(eq(timeEntries.employeeId, parseInt(employeeId))); + if (startDate) whereConditions.push(gte(timeEntries.timestamp, parseRangeParam(startDate, 'start'))); + if (endDate) whereConditions.push(lte(timeEntries.timestamp, parseRangeParam(endDate, 'end'))); + + await dbReady; + const results = await db + .select({ + id: timeEntries.id, + employeeId: timeEntries.employeeId, + companyId: timeEntries.companyId, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + photoBase64: timeEntries.photoBase64, + isDeleted: timeEntries.isDeleted, + employeeName: employees.name, + }) + .from(timeEntries) + .innerJoin(employees, eq(timeEntries.employeeId, employees.id)) + .where(and(...whereConditions)) + .orderBy(timeEntries.timestamp) + .all(); + + return NextResponse.json(ok(results)); +} + +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const parsed = TimeEntryUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { id, isDeleted, timestamp, forceOverride } = parsed.data; + + await dbReady; + const result = await db.transaction(async (tx) => { + const existing = await tx.select().from(timeEntries).where(eq(timeEntries.id, id)).get(); + if (!existing) { + return { ok: false as const, status: 404, error: 'Entry not found' }; + } + + if (timestamp !== undefined && timestamp !== existing.timestamp) { + const entries = await tx + .select() + .from(timeEntries) + .where( + and( + eq(timeEntries.employeeId, existing.employeeId), + eq(timeEntries.companyId, existing.companyId), + eq(timeEntries.isDeleted, false), + ), + ) + .all(); + const validation = validateTimestampChronology(entries, id, timestamp, !!forceOverride); + if (!validation.valid) { + return { ok: false as const, status: 400, error: validation.error ?? 'Validation failed' }; + } + } + + const updateData: Partial = {}; + if (isDeleted !== undefined) updateData.isDeleted = isDeleted; + if (timestamp !== undefined) updateData.timestamp = timestamp; + + const updated = await tx.update(timeEntries).set(updateData).where(eq(timeEntries.id, id)).returning().get(); + + return { ok: true as const, entry: updated }; + }); + + if (!result.ok) { + return NextResponse.json(err(result.error), { status: result.status }); + } + + return NextResponse.json(ok({ entry: result.entry })); + } catch (error) { + console.error('Update time entry error:', error); + return NextResponse.json(err('Internal server error'), { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const parsed = ManualEntrySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 }); + } + const { employeeId, companyId, type, timestamp, photoBase64, forceOverride } = parsed.data; + + const result = await createValidatedTimeEntry({ + employeeId, + companyId, + type, + timestamp, + photoBase64: photoBase64 || null, + forceOverride: !!forceOverride, + source: 'manual', + }); + if (!result.ok) { + return NextResponse.json(err(result.error), { status: result.status }); + } + + return NextResponse.json(ok({ entry: result.entry, overridden: result.overridden })); + } catch (error) { + console.error('Add manual entry error:', error); + return NextResponse.json(err('Internal server error'), { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..ee95d2c --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,2 @@ +@import 'tailwindcss'; +@import 'daisyui/daisyui.css'; diff --git a/src/app/kiosk/[companyId]/page.tsx b/src/app/kiosk/[companyId]/page.tsx new file mode 100644 index 0000000..6e8fdf9 --- /dev/null +++ b/src/app/kiosk/[companyId]/page.tsx @@ -0,0 +1,390 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams } from 'next/navigation'; +import Webcam from 'react-webcam'; +import { X, Loader2, Home, RefreshCw } from 'lucide-react'; +import Link from 'next/link'; +import { fetchApi } from '@/lib/api'; + +type Company = { id: number; name: string }; +type Employee = { id: number; name: string; isActive: boolean; companyId: number }; +type PunchType = 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT'; +type EmployeeWithStatus = { + id: number; + name: string; + status: 'IN' | 'OUT' | 'BREAK'; + lastPunchTimestamp: number | null; + shiftStartTimestamp: number | null; + activeBreakStartTimestamp: number | null; + completedBreakSeconds: number; +}; +type PunchResponse = { status: string; entry: { type: string } }; + +function useNowTime() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const t = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(t); + }, []); + return now; +} + +export default function KioskPage() { + const params = useParams(); + const companyId = params.companyId as string; + + const [company, setCompany] = useState(null); + const [employees, setEmployees] = useState([]); + const [punchModal, setPunchModal] = useState(null); + const [showSuccess, setShowSuccess] = useState(null); + const [isPunching, setIsPunching] = useState(false); + const [punchingType, setPunchingType] = useState(null); + const [cameraError, setCameraError] = useState(false); + const [punchError, setPunchError] = useState(null); + const webcamRef = useRef(null); + const now = useNowTime(); + + useEffect(() => { + if (!companyId) return; + fetchApi('/api/companies') + .then((data) => { + const found = data.find((c) => c.id.toString() === companyId); + if (found) setCompany(found); + }) + .catch(() => {}); + }, [companyId]); + + const loadEmployees = useCallback(async () => { + if (!companyId) return; + try { + const data = await fetchApi<{ employees: EmployeeWithStatus[] }>( + `/api/employees/with-status?companyId=${companyId}`, + ); + setEmployees(data.employees); + } catch { + setEmployees([]); + } + }, [companyId]); + + // Simple polling keeps kiosk tablets in sync without a long-lived stream. + useEffect(() => { + if (!companyId) return; + loadEmployees(); + const interval = setInterval(loadEmployees, 5000); + return () => { + clearInterval(interval); + }; + }, [companyId, loadEmployees]); + + useEffect(() => { + if (!punchModal) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isPunching) setPunchModal(null); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [punchModal, isPunching]); + + const openPunchModal = (employee: Employee) => { + setPunchModal(employee); + setPunchingType(null); + setPunchError(null); + }; + + const handleAction = async (type: PunchType) => { + if (!punchModal || isPunching) return; + setPunchingType(type); + setIsPunching(true); + setPunchError(null); + try { + const photo = webcamRef.current?.getScreenshot() || null; + const data = await fetchApi('/api/punch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + employeeId: punchModal.id, + companyId: parseInt(companyId), + type, + photoBase64: photo || undefined, + }), + }); + const labels: Record = { + IN: 'Clocked In', + OUT: 'Clocked Out', + BREAK_IN: 'Break Ended', + BREAK_OUT: 'Break Started', + }; + setShowSuccess(`${punchModal.name} , ${labels[data.entry.type] || data.entry.type}`); + setPunchModal(null); + loadEmployees(); + setTimeout(() => setShowSuccess(null), 2500); + } catch (err) { + console.error('Punch failed:', err); + setPunchError(err instanceof Error ? err.message : 'Connection error , please try again'); + } finally { + setIsPunching(false); + } + }; + + const statusColor = (status?: 'IN' | 'OUT' | 'BREAK') => { + switch (status) { + case 'IN': + return 'border-success bg-success/10 text-success'; + case 'BREAK': + return 'border-warning bg-warning/10 text-warning'; + default: + return 'border-base-300 bg-base-200 text-base-content/60'; + } + }; + + const statusLabel = (status?: 'IN' | 'OUT' | 'BREAK') => { + switch (status) { + case 'IN': + return 'On Shift'; + case 'BREAK': + return 'On Break'; + default: + return 'Off Duty'; + } + }; + + const refreshKiosk = () => { + window.location.reload(); + }; + + return ( +
+ {showSuccess && ( +
+
+ + {showSuccess} +
+
+ )} + +
+
+ +

Pulse Clock

+ {company && {company.name}} +
+
+ + {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + + + + +
+
+ +
+ {employees.length === 0 ? ( +
+
👥
+

No active employees found

+
+ ) : ( +
+ {employees.map((emp) => ( + + openPunchModal({ id: emp.id, name: emp.name, isActive: true, companyId: parseInt(companyId) }) + } + statusColor={statusColor} + statusLabel={statusLabel} + /> + ))} +
+ )} +
+ + {punchModal && ( +
+ + +

{punchModal.name}

+

+ Status:{' '} + + {statusLabel(currentModalStatus(employees, punchModal.id))} + +

+ + {punchError && ( +
+
+ +
+
Punch Rejected
+
{punchError}
+
+ +
+
+ )} + + {isPunching ? ( +
+ +

+ {punchingType === 'OUT' + ? 'Clocking out...' + : punchingType === 'BREAK_OUT' + ? 'Starting break…' + : punchingType === 'BREAK_IN' + ? 'Ending break…' + : 'Clocking in…'} +

+
+ ) : ( +
+
+ {cameraError ? ( +
+ Camera unavailable +
+ ) : ( + setCameraError(true)} + className="w-full scale-x-[-1]" + /> + )} +
+ +
+ {currentModalStatus(employees, punchModal.id) !== 'IN' && + currentModalStatus(employees, punchModal.id) !== 'BREAK' && ( + + )} + {(currentModalStatus(employees, punchModal.id) === 'IN' || + currentModalStatus(employees, punchModal.id) === 'BREAK') && ( + + )} + {currentModalStatus(employees, punchModal.id) === 'IN' && ( + + )} + {currentModalStatus(employees, punchModal.id) === 'BREAK' && ( + + )} +
+
+ )} +
+
+ )} +
+ ); +} + +function currentModalStatus(employees: EmployeeWithStatus[], employeeId: number): EmployeeWithStatus['status'] { + return employees.find((employee) => employee.id === employeeId)?.status ?? 'OUT'; +} + +// ── Sub-component so hooks are called at the top level (Rules of Hooks) ───────── + +type EmployeeCardProps = { + employee: Employee; + status?: 'IN' | 'OUT' | 'BREAK'; + shiftStartUnix: number | null; + activeBreakStartUnix: number | null; + completedBreakSeconds: number; + onClick: () => void; + statusColor: (status?: 'IN' | 'OUT' | 'BREAK') => string; + statusLabel: (status?: 'IN' | 'OUT' | 'BREAK') => string; +}; + +function EmployeeCard({ + employee, + status, + shiftStartUnix, + activeBreakStartUnix, + completedBreakSeconds, + onClick, + statusColor, + statusLabel, +}: EmployeeCardProps) { + const elapsed = shiftStartUnix + ? formatNetShiftElapsed(shiftStartUnix, completedBreakSeconds, activeBreakStartUnix) + : ''; + + return ( + + ); +} + +function formatNetShiftElapsed( + shiftStartUnix: number, + completedBreakSeconds: number, + activeBreakStartUnix: number | null, +): string { + const nowUnix = Math.floor(Date.now() / 1000); + const activeBreakSeconds = activeBreakStartUnix ? Math.max(nowUnix - activeBreakStartUnix, 0) : 0; + const secs = Math.max(nowUnix - shiftStartUnix - completedBreakSeconds - activeBreakSeconds, 0); + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} diff --git a/src/app/kiosk/layout.tsx b/src/app/kiosk/layout.tsx new file mode 100644 index 0000000..a5c63a9 --- /dev/null +++ b/src/app/kiosk/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata, Viewport } from 'next'; +import { KioskZoomLock } from '@/components/kiosk-zoom-lock'; + +export const metadata: Metadata = { + title: 'Kiosk | Pulse Clock', + description: 'Employee kiosk for clocking in, clocking out, and breaks.', +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; + +export default function KioskLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/src/app/kiosk/page.tsx b/src/app/kiosk/page.tsx new file mode 100644 index 0000000..8c416ce --- /dev/null +++ b/src/app/kiosk/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function KioskIndex() { + redirect('/admin/manage'); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..bd9511a --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; +import { AuthGate } from '@/components/auth-gate'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); + +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); + +export const metadata: Metadata = { + title: 'Pulse Clock', + description: 'Multi-tenant hotel time clock system', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..6e89743 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { Suspense, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [secret, setSecret] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const nextPath = useMemo(() => { + const requested = searchParams.get('next') ?? '/'; + return requested.startsWith('/') && !requested.startsWith('//') ? requested : '/'; + }, [searchParams]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!secret.trim()) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-pulse-csrf': '1' }, + body: JSON.stringify({ secret }), + }); + + if (!response.ok) { + setError(response.status === 500 ? 'Server auth is not configured' : 'Invalid secret'); + setLoading(false); + return; + } + + router.replace(nextPath); + router.refresh(); + } catch { + setError('Connection error'); + setLoading(false); + } + } + + return ( +
+
+
+

Pulse Clock

+

Enter the admin secret to continue.

+
+ +
+ {error && ( +
+ {error} +
+ )} + + setSecret(event.target.value)} + autoFocus + disabled={loading} + autoComplete="current-password" + /> + + +
+
+
+ ); +} + +export default function LoginPage() { + return ( + + + + } + > + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..d516359 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/admin'); +} diff --git a/src/components/auth-gate.tsx b/src/components/auth-gate.tsx new file mode 100644 index 0000000..1e75612 --- /dev/null +++ b/src/components/auth-gate.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; + +function AuthGateInner({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [showModal, setShowModal] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [checked, setChecked] = useState(false); + + async function checkAuth() { + try { + const res = await fetch('/api/authorize'); + if (res.ok) { + setChecked(true); + } else { + setShowModal(true); + setChecked(true); + } + } catch { + setShowModal(true); + setChecked(true); + } + } + + useEffect(() => { + if (pathname === '/login') { + setChecked(true); + return; + } + + if (searchParams.get('unauthorized') === '1') { + setShowModal(true); + setChecked(true); + } else { + checkAuth(); + } + }, [pathname, searchParams]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!password.trim()) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-pulse-csrf': '1' }, + body: JSON.stringify({ secret: password }), + }); + + if (res.ok) { + window.location.reload(); + } else { + setError('Invalid secret'); + setLoading(false); + } + } catch { + setError('Connection error'); + setLoading(false); + } + } + + if (!checked) { + return ( +
+ +
+ ); + } + + return ( + <> + {children} + {showModal && ( +
+
+
+
🔒
+

Authorization Required

+

+ Enter the admin secret to continue +

+
+ +
+ {error && ( +
+ {error} +
+ )} + + setPassword(e.target.value)} + autoFocus + disabled={loading} + /> + + +
+
+
+ )} + + ); +} + +export function AuthGate({ children }: { children: React.ReactNode }) { + return ( + + +
+ } + > + {children} + + ); +} diff --git a/src/components/kiosk-zoom-lock.tsx b/src/components/kiosk-zoom-lock.tsx new file mode 100644 index 0000000..3af0542 --- /dev/null +++ b/src/components/kiosk-zoom-lock.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect } from 'react'; + +export function KioskZoomLock() { + useEffect(() => { + const preventMultiTouchZoom = (event: TouchEvent) => { + if (event.touches.length > 1) event.preventDefault(); + }; + + const preventGestureZoom = (event: Event) => { + event.preventDefault(); + }; + + const preventCtrlWheelZoom = (event: WheelEvent) => { + if (event.ctrlKey) event.preventDefault(); + }; + + document.addEventListener('touchmove', preventMultiTouchZoom, { passive: false }); + document.addEventListener('gesturestart', preventGestureZoom, { passive: false }); + document.addEventListener('gesturechange', preventGestureZoom, { passive: false }); + document.addEventListener('wheel', preventCtrlWheelZoom, { passive: false }); + + return () => { + document.removeEventListener('touchmove', preventMultiTouchZoom); + document.removeEventListener('gesturestart', preventGestureZoom); + document.removeEventListener('gesturechange', preventGestureZoom); + document.removeEventListener('wheel', preventCtrlWheelZoom); + }; + }, []); + + return null; +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..b380fa3 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,37 @@ +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; +import * as schema from './schema'; +import path from 'path'; + +function resolveDatabaseUrl() { + const configuredPath = process.env.DATABASE_URL ?? './data/pulse-clock.db'; + + if (/^(file|libsql|https?):/.test(configuredPath)) { + return configuredPath; + } + + const filePath = path.isAbsolute(configuredPath) + ? configuredPath + : path.join(/* turbopackIgnore: true */ process.cwd(), configuredPath); + + return `file:${filePath}`; +} + +const databaseUrl = resolveDatabaseUrl(); + +import fs from 'fs'; +if (databaseUrl.startsWith('file:')) { + const dataDir = path.dirname(databaseUrl.slice('file:'.length)); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } +} + +const client = createClient({ url: databaseUrl }); + +export const db = drizzle(client, { schema }); +export const dbReady = + process.env.PULSE_SKIP_DB_BOOTSTRAP === '1' + ? Promise.resolve() + : migrate(db, { migrationsFolder: path.join(process.cwd(), 'drizzle') }); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..e5c2b21 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,105 @@ +import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +import { relations } from 'drizzle-orm'; + +export const companies = sqliteTable('companies', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + createdAt: text('created_at') + .notNull() + .default(sql`(datetime('now'))`), +}); + +export const employees = sqliteTable( + 'employees', + { + id: integer('id').primaryKey({ autoIncrement: true }), + companyId: integer('company_id') + .notNull() + .references(() => companies.id), + name: text('name').notNull(), + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), + createdAt: text('created_at') + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => ({ + companyActiveIdx: index('company_active_idx').on(table.companyId, table.isActive), + }), +); + +export const timeEntries = sqliteTable( + 'time_entries', + { + id: integer('id').primaryKey({ autoIncrement: true }), + employeeId: integer('employee_id') + .notNull() + .references(() => employees.id), + companyId: integer('company_id') + .notNull() + .references(() => companies.id), + type: text('type', { enum: ['IN', 'OUT', 'BREAK_IN', 'BREAK_OUT'] }).notNull(), + timestamp: integer('timestamp') + .notNull() + .default(sql`(unixepoch())`), + photoBase64: text('photo_base64'), + isDeleted: integer('is_deleted', { mode: 'boolean' }).notNull().default(false), + }, + (table) => ({ + employeeTimestampIdx: index('employee_timestamp_idx').on(table.employeeId, table.timestamp), + companyTimestampIdx: index('company_timestamp_idx').on(table.companyId, table.timestamp), + }), +); + +export const employeesRelations = relations(employees, ({ one, many }) => ({ + company: one(companies, { + fields: [employees.companyId], + references: [companies.id], + }), + timeEntries: many(timeEntries), +})); + +export const timeEntriesRelations = relations(timeEntries, ({ one }) => ({ + employee: one(employees, { + fields: [timeEntries.employeeId], + references: [employees.id], + }), + company: one(companies, { + fields: [timeEntries.companyId], + references: [companies.id], + }), +})); + +export const companiesRelations = relations(companies, ({ many }) => ({ + employees: many(employees), + timeEntries: many(timeEntries), +})); + +/** Immutable audit log , every punch (including overrides) is recorded. */ +export const auditLog = sqliteTable( + 'audit_log', + { + id: integer('id').primaryKey({ autoIncrement: true }), + /** The employee who punched */ + employeeId: integer('employee_id') + .notNull() + .references(() => employees.id), + /** Which hotel the punch was for */ + companyId: integer('company_id') + .notNull() + .references(() => companies.id), + /** adminId=0 means kiosk punch; non-zero = admin override */ + adminId: integer('admin_id').notNull().default(0), + /** e.g. 'CLOCK_IN', 'CLOCK_OUT', 'BREAK_START', 'BREAK_END', 'OVERRIDE_IN', etc. */ + action: text('action').notNull(), + /** Human-readable detail string */ + detail: text('detail').notNull(), + createdAt: text('created_at') + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => ({ + employeeIdx: index('audit_employee_idx').on(table.employeeId), + companyIdx: index('audit_company_idx').on(table.companyId), + }), +); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts new file mode 100644 index 0000000..19744b2 --- /dev/null +++ b/src/lib/api-response.ts @@ -0,0 +1,9 @@ +export type ApiResponse = { data: T; success: true } | { error: string; details?: unknown; success: false }; + +export function ok(data: T): ApiResponse { + return { data, success: true }; +} + +export function err(error: string, details?: unknown): ApiResponse { + return { error, details, success: false }; +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..d540385 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,19 @@ +import { ApiResponse } from './api-response'; + +export async function fetchApi(url: string, options?: RequestInit): Promise { + const method = options?.method?.toUpperCase() ?? 'GET'; + const headers = new Headers(options?.headers); + if (method !== 'GET' && method !== 'HEAD') { + headers.set('x-pulse-csrf', '1'); + } + + const res = await fetch(url, { ...options, headers }); + const json = (await res.json()) as ApiResponse; + if (!json.success) { + throw new Error((json as { error: string }).error ?? 'API error'); + } + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${url}`); + } + return json.data as T; +} diff --git a/src/lib/auth-cookie.ts b/src/lib/auth-cookie.ts new file mode 100644 index 0000000..142f99e --- /dev/null +++ b/src/lib/auth-cookie.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createHash, timingSafeEqual } from 'crypto'; + +export const PULSE_AUTH_COOKIE = 'pulse_auth'; +const AUTH_VERSION = 'pulse-auth-v1'; +const MIN_SECRET_LENGTH = 32; + +export function getPulseSecret(): string | null { + const secret = process.env.PULSE_SECRET; + if (!secret || secret.length < MIN_SECRET_LENGTH) return null; + return secret; +} + +export function computeCookieValue(): string | null { + const secret = getPulseSecret(); + if (!secret) return null; + return createHash('sha256').update(secret + ':' + AUTH_VERSION).digest('hex'); +} + +export function verifyCookie(request: NextRequest): boolean { + const cookie = request.cookies.get(PULSE_AUTH_COOKIE)?.value; + if (!cookie) return false; + const expected = computeCookieValue(); + if (!expected) return false; + return cookie === expected; +} + +export function verifySecret(candidate: string): boolean { + const secret = getPulseSecret(); + if (!secret) return false; + + const candidateBuffer = Buffer.from(candidate); + const secretBuffer = Buffer.from(secret); + return candidateBuffer.length === secretBuffer.length && timingSafeEqual(candidateBuffer, secretBuffer); +} + +export function setAuthCookie(response: NextResponse): NextResponse { + const value = computeCookieValue(); + if (!value) { + throw new Error('PULSE_SECRET must be set to at least 32 characters'); + } + response.cookies.set(PULSE_AUTH_COOKIE, value, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 31536000, + path: '/', + }); + return response; +} + +export function clearAuthCookie(response: NextResponse): NextResponse { + response.cookies.set(PULSE_AUTH_COOKIE, '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0, + path: '/', + }); + return response; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..fa77189 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,68 @@ +/** + * Rate limiting utilities. + * + * Rate limiting: In-memory sliding window counter keyed by IP. + * Configure via PULSE_RATE_LIMIT (requests per window, default 60) + * and PULSE_RATE_WINDOW (seconds, default 60). + */ + +import { NextRequest, NextResponse } from 'next/server'; + +// ── Rate limiting ───────────────────────────────────────────────────────────── + +interface RateLimitEntry { + count: number; + resetAt: number; // unix ms +} + +const rateLimitStore = new Map(); + +function getRateLimitConfig() { + const limit = parseInt(process.env.PULSE_RATE_LIMIT ?? '60', 10); + const windowMs = parseInt(process.env.PULSE_RATE_WINDOW ?? '60', 10) * 1000; + return { limit, windowMs }; +} + +function getClientKey(request: NextRequest): string { + return request.headers.get('x-forwarded-for') ?? request.headers.get('cf-connecting-ip') ?? 'unknown'; +} + +/** + * Check and record rate limit for a request. Returns a response if limit is + * exceeded, null if the request is allowed. + */ +export function checkRateLimit(request: NextRequest): NextResponse | null { + const { limit, windowMs } = getRateLimitConfig(); + const key = getClientKey(request); + const now = Date.now(); + + const entry = rateLimitStore.get(key); + + if (!entry || now > entry.resetAt) { + // First request or window expired , start fresh + rateLimitStore.set(key, { count: 1, resetAt: now + windowMs }); + return null; + } + + if (entry.count >= limit) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return NextResponse.json( + { + error: 'Too many requests. Please slow down.', + retryAfter, + }, + { + status: 429, + headers: { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(entry.resetAt / 1000)), + }, + }, + ); + } + + entry.count++; + return null; +} diff --git a/src/lib/calculations.ts b/src/lib/calculations.ts new file mode 100644 index 0000000..ad4cc6f --- /dev/null +++ b/src/lib/calculations.ts @@ -0,0 +1,186 @@ +export type CalcTimeEntry = { + id: number; + employeeId: number; + type: 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT'; + timestamp: number; + isDeleted: boolean; +}; + +export type DayMinutes = { + clockMinutes: number; + breakMinutes: number; + workedMinutes: number; + hasErrors: boolean; + errors: string[]; // human-readable reasons for hasErrors +}; + +export type DayMinutesOptions = { + nowUnix?: number; + openShiftMaxHours?: number; +}; + +export type LiveShiftElapsed = { + shiftStartTimestamp: number | null; + activeBreakStartTimestamp: number | null; + completedBreakSeconds: number; +}; + +export function calculateLiveShiftElapsed(entries: CalcTimeEntry[]): LiveShiftElapsed { + const active = entries.filter((e) => !e.isDeleted).sort((a, b) => a.timestamp - b.timestamp || a.id - b.id); + let shiftStartTimestamp: number | null = null; + let activeBreakStartTimestamp: number | null = null; + let completedBreakSeconds = 0; + + for (const entry of active) { + switch (entry.type) { + case 'IN': + shiftStartTimestamp = entry.timestamp; + activeBreakStartTimestamp = null; + completedBreakSeconds = 0; + break; + case 'BREAK_OUT': + if (shiftStartTimestamp !== null && activeBreakStartTimestamp === null) { + activeBreakStartTimestamp = entry.timestamp; + } + break; + case 'BREAK_IN': + if (activeBreakStartTimestamp !== null) { + completedBreakSeconds += Math.max(entry.timestamp - activeBreakStartTimestamp, 0); + activeBreakStartTimestamp = null; + } + break; + case 'OUT': + shiftStartTimestamp = null; + activeBreakStartTimestamp = null; + completedBreakSeconds = 0; + break; + } + } + + return { shiftStartTimestamp, activeBreakStartTimestamp, completedBreakSeconds }; +} + +/** + * Given an employee punch timeline and one calendar day, return the + * clock/break/worked minutes whose shift segments intersect that day. + * + * Callers should pass enough surrounding entries to include overnight shifts + * that start before or end after the displayed date range. + */ +export function calculateDayMinutes( + entries: CalcTimeEntry[], + dateStr: string, + options: DayMinutesOptions = {}, +): DayMinutes { + const active = entries.filter((e) => !e.isDeleted).sort((a, b) => a.timestamp - b.timestamp || a.id - b.id); + if (active.length === 0) return { clockMinutes: 0, breakMinutes: 0, workedMinutes: 0, hasErrors: false, errors: [] }; + + const dayStart = new Date(dateStr + 'T00:00:00').getTime(); + const dayEnd = new Date(dateStr + 'T00:00:00').getTime() + 86400000; + const nowMs = (options.nowUnix ?? Math.floor(Date.now() / 1000)) * 1000; + const openShiftMaxMs = (options.openShiftMaxHours ?? 12) * 3600000; + const errors = new Set(); + let clockMs = 0; + let breakMs = 0; + let shiftStart: number | null = null; + let breakStart: number | null = null; + let previousType: CalcTimeEntry['type'] | null = null; + + const overlapsDay = (start: number, end: number) => Math.min(end, dayEnd) > Math.max(start, dayStart); + const eventIsInDay = (ts: number) => ts >= dayStart && ts < dayEnd; + const addIssue = (message: string, relevantAt: number | null = null) => { + if (relevantAt === null || eventIsInDay(relevantAt)) errors.add(message); + }; + const addOverlap = (start: number, end: number, kind: 'clock' | 'break') => { + const overlapStart = Math.max(start, dayStart); + const overlapEnd = Math.min(end, dayEnd); + if (overlapEnd <= overlapStart) return; + if (kind === 'clock') clockMs += overlapEnd - overlapStart; + if (kind === 'break') breakMs += overlapEnd - overlapStart; + }; + + for (const entry of active) { + const ts = entry.timestamp * 1000; + + if (previousType === entry.type) { + addIssue(`Consecutive ${entry.type} entries`, ts); + } + + switch (entry.type) { + case 'IN': + if (shiftStart !== null) addIssue('Clock In before Clock Out', ts); + if (breakStart !== null) { + addIssue('BREAK_IN missing before IN', ts); + breakStart = null; + } + shiftStart = ts; + break; + case 'BREAK_OUT': + if (shiftStart === null) { + addIssue('Break started without Clock In', ts); + } else if (breakStart !== null) { + addIssue('Break started without break end', ts); + addOverlap(breakStart, ts, 'break'); + } + breakStart = ts; + break; + case 'BREAK_IN': + if (shiftStart === null) { + addIssue('Break end without Clock In', ts); + } else if (breakStart === null) { + addIssue('Break end without break start', ts); + } else { + addOverlap(breakStart, ts, 'break'); + breakStart = null; + } + break; + case 'OUT': + if (shiftStart === null) { + addIssue('Clock out without Clock In', ts); + } else { + addOverlap(shiftStart, ts, 'clock'); + if (breakStart !== null) { + addIssue('Break started but never ended', overlapsDay(breakStart, ts) ? null : ts); + addOverlap(breakStart, ts, 'break'); + } + shiftStart = null; + breakStart = null; + } + break; + } + + previousType = entry.type; + } + + if (shiftStart !== null) { + const openEnd = Math.max(shiftStart, Math.min(nowMs, shiftStart + openShiftMaxMs)); + if (overlapsDay(shiftStart, openEnd)) { + errors.add('Still clocked in'); + addOverlap(shiftStart, openEnd, 'clock'); + if (breakStart !== null) { + errors.add('Break started but never ended'); + addOverlap(breakStart, openEnd, 'break'); + } + } + } + + const clockMinutes = Math.round(clockMs / 60000); + const breakMinutes = Math.round(breakMs / 60000); + const workedMinutes = clockMinutes - breakMinutes; + return { + clockMinutes, + breakMinutes, + workedMinutes, + hasErrors: errors.size > 0, + errors: Array.from(errors), + }; +} + +export function formatHours(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = Math.round(minutes % 60); + if (hours === 0 && mins === 0) return '-'; + if (hours === 0) return `${mins}m`; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; +} diff --git a/src/lib/params.ts b/src/lib/params.ts new file mode 100644 index 0000000..53d8467 --- /dev/null +++ b/src/lib/params.ts @@ -0,0 +1,5 @@ +export function parseId(value: string | null): number | null { + if (!value || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..09c2a82 --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +export const PunchType = z.enum(['IN', 'OUT', 'BREAK_IN', 'BREAK_OUT']); +export type PunchType = z.infer; + +export const CompanySchema = z.object({ + name: z + .string() + .min(1, 'Company name is required') + .transform((v) => v.trim()), +}); + +export const CompanyUpdateSchema = z.object({ + id: z.number(), + name: z + .string() + .min(1, 'Company name is required') + .transform((v) => v.trim()), +}); + +export const EmployeeSchema = z.object({ + companyId: z.number(), + name: z.string().min(1, 'Employee name is required'), +}); + +export const EmployeeUpdateSchema = z.object({ + id: z.number(), + name: z.string().min(1).optional(), + isActive: z.boolean().optional(), +}); + +export const PunchSchema = z.object({ + employeeId: z.number(), + companyId: z.number(), + type: PunchType, + photoBase64: z.string().optional(), +}); + +export const ManualEntrySchema = z + .object({ + employeeId: z.number(), + companyId: z.number(), + type: PunchType, + timestamp: z.number().int(), + photoBase64: z.string().optional(), + forceOverride: z.boolean().optional(), + }) + .superRefine((entry, ctx) => { + const now = Math.floor(Date.now() / 1000); + if (entry.timestamp < now - 31536000) { + ctx.addIssue({ + code: 'too_small', + minimum: now - 31536000, + inclusive: true, + origin: 'number', + path: ['timestamp'], + message: 'Timestamp cannot be more than one year in the past', + }); + } + if (entry.timestamp > now + 86400) { + ctx.addIssue({ + code: 'too_big', + maximum: now + 86400, + inclusive: true, + origin: 'number', + path: ['timestamp'], + message: 'Timestamp cannot be more than one day in the future', + }); + } + }); + +export const TimeEntryUpdateSchema = z.object({ + id: z.number(), + isDeleted: z.boolean().optional(), + timestamp: z.number().optional(), + forceOverride: z.boolean().optional(), +}); diff --git a/src/lib/time-entry-service.ts b/src/lib/time-entry-service.ts new file mode 100644 index 0000000..b6c9ac6 --- /dev/null +++ b/src/lib/time-entry-service.ts @@ -0,0 +1,117 @@ +import { and, eq } from 'drizzle-orm'; +import { db, dbReady } from '@/db'; +import { auditLog, employees, timeEntries } from '@/db/schema'; +import { AUDIT_ACTIONS, PUNCH_LABELS, statusAfter, validateNewEntryFromHistory } from './validation'; +import type { PunchType } from './schemas'; + +const DUPLICATE_PUNCH_WINDOW_SECONDS = 5; + +type CreateTimeEntryInput = { + employeeId: number; + companyId: number; + type: PunchType; + timestamp?: number; + photoBase64?: string | null; + forceOverride?: boolean; + source: 'kiosk' | 'manual'; +}; + +export type CreateTimeEntryResult = + | { + ok: true; + entry: Pick< + typeof timeEntries.$inferSelect, + 'id' | 'employeeId' | 'companyId' | 'type' | 'timestamp' | 'photoBase64' | 'isDeleted' + >; + status: ReturnType; + overridden: boolean; + } + | { ok: false; status: number; error: string }; + +export async function createValidatedTimeEntry(input: CreateTimeEntryInput): Promise { + await dbReady; + return db.transaction(async (tx) => { + const employee = await tx + .select() + .from(employees) + .where(and(eq(employees.id, input.employeeId), eq(employees.companyId, input.companyId))) + .get(); + + if (!employee) { + return { ok: false, status: 404, error: 'Employee not found or does not belong to this company' }; + } + + const timestamp = input.timestamp ?? Math.floor(Date.now() / 1000); + const entries = await tx + .select({ + id: timeEntries.id, + type: timeEntries.type, + timestamp: timeEntries.timestamp, + isDeleted: timeEntries.isDeleted, + }) + .from(timeEntries) + .where( + and( + eq(timeEntries.employeeId, input.employeeId), + eq(timeEntries.companyId, input.companyId), + eq(timeEntries.isDeleted, false), + ), + ) + .orderBy(timeEntries.timestamp, timeEntries.id) + .all(); + + const validation = validateNewEntryFromHistory(entries, input.type, timestamp, { + forceOverride: !!input.forceOverride, + duplicateWindowSeconds: input.source === 'kiosk' ? DUPLICATE_PUNCH_WINDOW_SECONDS : 0, + requireIncreasingTimestamp: input.source === 'manual', + }); + if (!validation.valid) { + return { ok: false, status: 400, error: validation.error ?? 'Validation failed' }; + } + + const entry = await tx + .insert(timeEntries) + .values({ + employeeId: input.employeeId, + companyId: input.companyId, + type: input.type, + timestamp, + photoBase64: input.photoBase64 || null, + }) + .returning() + .get(); + + const overrideActions = { + IN: 'OVERRIDE_IN', + OUT: 'OVERRIDE_OUT', + BREAK_OUT: 'OVERRIDE_BREAK_START', + BREAK_IN: 'OVERRIDE_BREAK_END', + } as const satisfies Record; + const auditKey = input.forceOverride ? overrideActions[input.type] : input.type; + const action = AUDIT_ACTIONS[auditKey] ?? input.type; + const detail = + input.source === 'kiosk' + ? `Kiosk punch: ${PUNCH_LABELS[input.type]}` + : input.forceOverride + ? `Admin override: ${input.type} at ${new Date(timestamp * 1000).toLocaleString()}` + : `Manual entry: ${input.type} at ${new Date(timestamp * 1000).toLocaleString()}`; + + await tx + .insert(auditLog) + .values({ + employeeId: input.employeeId, + companyId: input.companyId, + adminId: input.source === 'kiosk' ? 0 : 1, + action, + detail, + }) + .run(); + + return { + ok: true, + entry, + status: statusAfter(input.type), + overridden: !!input.forceOverride, + }; + }); +} diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..6222076 --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,58 @@ +import { format } from 'date-fns'; + +/** + * Convert a Unix timestamp (seconds since epoch) to a JS Date. + */ +function fromUnix(unixSeconds: number): Date { + return new Date(unixSeconds * 1000); +} + +/** + * Format a Unix timestamp as a time string (e.g. "2:30 PM"). + */ +export function formatTime(unixSeconds: number, fmt: string = 'h:mm a'): string { + return format(fromUnix(unixSeconds), fmt); +} + +/** + * Format a Unix timestamp as a date string (e.g. "May 10, 2026"). + */ +export function formatDate(unixSeconds: number, fmt: string = 'MMM d, yyyy'): string { + return format(fromUnix(unixSeconds), fmt); +} + +/** + * Given a Date, return the Unix timestamp range for that calendar day + * (local midnight to 23:59:59). + */ +export function dayRange(date: Date): { start: number; end: number } { + const start = Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000); + const end = start + 86400 - 1; + return { start, end }; +} + +/** + * Given a Date (local midnight for a day), return the full Unix second for + * the start of that day. + */ +export function toUnix(date: Date): number { + return Math.floor(date.getTime() / 1000); +} + +/** + * Convert a `datetime-local` input value to a Unix timestamp. + */ +export function fromInput(inputValue: string): number { + const parsed = new Date(inputValue); + if (isNaN(parsed.getTime())) { + throw new Error(`Invalid date input: ${inputValue}`); + } + return Math.floor(parsed.getTime() / 1000); +} + +/** + * ISO date key (e.g. "2026-05-10") , for grouping entries by day. + */ +export function dateKey(unixSeconds: number): string { + return format(fromUnix(unixSeconds), 'yyyy-MM-dd'); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..5055529 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,154 @@ +import { timeEntries } from '@/db/schema'; +import { PunchType } from './schemas'; + +// PunchType: single source of truth is schemas.ts (Zod schema + z.infer type). +// This file re-exports it so callers can import from one place. +export type { PunchType } from './schemas'; + +export type EmployeeStatus = 'IN' | 'OUT' | 'BREAK'; +export type ValidationEntry = { + id: number; + type: PunchType; + timestamp: number; + isDeleted: boolean; +}; + +/** Human-readable labels for punch types */ +export const PUNCH_LABELS: Record = { + IN: 'Clock In', + OUT: 'Clock Out', + BREAK_OUT: 'Break Start', + BREAK_IN: 'Break End', +}; + +export const AUDIT_ACTIONS = { + IN: 'CLOCK_IN', + OUT: 'CLOCK_OUT', + BREAK_OUT: 'BREAK_START', + BREAK_IN: 'BREAK_END', + OVERRIDE_IN: 'OVERRIDE_IN', + OVERRIDE_OUT: 'OVERRIDE_OUT', + OVERRIDE_BREAK_START: 'OVERRIDE_BREAK_START', + OVERRIDE_BREAK_END: 'OVERRIDE_BREAK_END', +} as const; + +/** Maps a PunchType value to the resulting EmployeeStatus */ +export function statusAfter(type: PunchType): EmployeeStatus { + switch (type) { + case 'IN': + return 'IN'; + case 'OUT': + return 'OUT'; + case 'BREAK_OUT': + return 'BREAK'; // employee stepped out → on break + case 'BREAK_IN': + return 'IN'; // employee returned from break + } +} + +// --------------------------------------------------------------------------- +// Core state machine rules +// --------------------------------------------------------------------------- + +type ValidationResult = { valid: boolean; error?: string; currentStatus?: EmployeeStatus }; + +/** Rules enforced by the state machine. Used by both kiosk punches and + * manual/admin entries. Pass `forceOverride=true` to bypass. */ +function validatePunchRules(currentStatus: EmployeeStatus, type: PunchType, forceOverride = false): ValidationResult { + if (forceOverride) return { valid: true, currentStatus }; + + switch (type) { + case 'IN': + if (currentStatus === 'IN') return { valid: false, error: 'Already on shift , clock out first', currentStatus }; + if (currentStatus === 'BREAK') + return { valid: false, error: 'Already on break , end break first', currentStatus }; + break; + case 'OUT': + if (currentStatus === 'OUT') return { valid: false, error: 'Not clocked in , clock in first', currentStatus }; + if (currentStatus === 'BREAK') + return { valid: false, error: 'On break , end break before clocking out', currentStatus }; + break; + case 'BREAK_OUT': + if (currentStatus !== 'IN') return { valid: false, error: 'Must be on shift to start a break', currentStatus }; + break; + case 'BREAK_IN': + if (currentStatus !== 'BREAK') return { valid: false, error: 'Not on break , start break first', currentStatus }; + break; + } + return { valid: true, currentStatus }; +} + +export function validateNewEntryFromHistory( + entries: ValidationEntry[], + type: PunchType, + timestamp: number, + options: { forceOverride?: boolean; duplicateWindowSeconds?: number; requireIncreasingTimestamp?: boolean } = {}, +): ValidationResult { + const activeEntries = entries + .filter((entry) => !entry.isDeleted) + .sort((a, b) => a.timestamp - b.timestamp || a.id - b.id); + + if (activeEntries.length === 0) { + if (type !== 'IN' && !options.forceOverride) { + return { valid: false, error: 'No shift found , clock in first', currentStatus: 'OUT' }; + } + return { valid: true, currentStatus: 'OUT' }; + } + + const lastEntry = activeEntries[activeEntries.length - 1]!; + const currentStatus = statusAfter(lastEntry.type); + + if (options.forceOverride) { + return { valid: true, currentStatus }; + } + + const duplicateWindowSeconds = options.duplicateWindowSeconds ?? 0; + if ( + duplicateWindowSeconds > 0 && + lastEntry.type === type && + Math.abs(timestamp - lastEntry.timestamp) <= duplicateWindowSeconds + ) { + return { valid: false, error: 'Duplicate punch ignored , the same action was just recorded', currentStatus }; + } + + if (options.requireIncreasingTimestamp !== false && timestamp <= lastEntry.timestamp) { + return { + valid: false, + error: `Timestamp must be after last entry (${formatTs(lastEntry.timestamp)})`, + currentStatus, + }; + } + + return validatePunchRules(currentStatus, type); +} + +/** Shifting an entry's timestamp must still fit between neighbours. */ +export function validateTimestampChronology( + entries: (typeof timeEntries.$inferSelect)[], + entryId: number, + newTimestamp: number, + forceOverride = false, +): { valid: boolean; error?: string } { + const allEntries = entries.toSorted((a, b) => a.timestamp - b.timestamp || a.id - b.id); + const idx = allEntries.findIndex((e) => e.id === entryId); + if (idx === -1) return { valid: false, error: 'Entry not found' }; + + const prev = idx > 0 ? allEntries[idx - 1] : null; + const next = idx < allEntries.length - 1 ? allEntries[idx + 1] : null; + + if (prev && newTimestamp <= prev.timestamp && !forceOverride) { + return { valid: false, error: 'Timestamp must be after the previous entry' }; + } + if (next && newTimestamp >= next.timestamp && !forceOverride) { + return { valid: false, error: 'Timestamp must be before the next entry' }; + } + return { valid: true }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- +function formatTs(ts: number): string { + const d = new Date(ts * 1000); + return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..10ab202 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,165 @@ +/** + * Next.js proxy, handles CORS, global rate limiting, and cookie auth for all API routes. + * + * CORS: Allows requests from: + * - http://localhost:* (development) + * - /kiosk/[companyId] pages (same-site, kiosk mode) + * + * Rate limiting: Applies to all /api/* routes using the sliding window + * counter from @/lib/auth. Uses x-forwarded-for / cf-connecting-ip to + * key clients. Configure via PULSE_RATE_LIMIT and PULSE_RATE_WINDOW env vars. + * + * Cookie auth: All /api/* routes (except /api/authorize) require a valid + * pulse_auth cookie computed as HMAC-SHA256(PULSE_SECRET). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { checkRateLimit } from '@/lib/auth'; +import { verifyCookie } from '@/lib/auth-cookie'; + +const ALLOWED_METHODS = ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']; +const ALLOWED_HEADERS = ['Content-Type', 'x-requested-with', 'x-pulse-csrf']; +const PUBLIC_PATHS = new Set(['/login', '/api/authorize', '/api/health']); +const WRITE_METHODS = new Set(['POST', 'PATCH', 'DELETE']); + +function corsHeaders(request: NextRequest) { + return { + 'Access-Control-Allow-Origin': getCorsOrigin(request.headers.get('origin'), request), + 'Access-Control-Allow-Methods': ALLOWED_METHODS.join(', '), + 'Access-Control-Allow-Headers': ALLOWED_HEADERS.join(', '), + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Max-Age': '86400', + }; +} + +export function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (request.method === 'OPTIONS') { + return new NextResponse(null, { status: 204, headers: corsHeaders(request) }); + } + + if (pathname === '/api/health') { + const response = NextResponse.next(); + for (const [key, value] of Object.entries(corsHeaders(request))) { + response.headers.set(key, value); + } + return response; + } + + if (pathname === '/api/authorize') { + const rateLimitError = checkRateLimit(request); + if (rateLimitError) return withCors(rateLimitError, request); + if (WRITE_METHODS.has(request.method) && !isTrustedWriteRequest(request)) { + return withCors( + NextResponse.json({ error: 'Forbidden', code: 'CSRF_BLOCKED' }, { status: 403 }), + request, + ); + } + + const response = NextResponse.next(); + return withCors(response, request); + } + + if (pathname.startsWith('/api/')) { + if (!verifyCookie(request)) { + return withCors( + NextResponse.json({ error: 'Unauthorized', code: 'AUTH_REQUIRED' }, { status: 401 }), + request, + ); + } + + if (WRITE_METHODS.has(request.method) && !isTrustedWriteRequest(request)) { + return withCors( + NextResponse.json({ error: 'Forbidden', code: 'CSRF_BLOCKED' }, { status: 403 }), + request, + ); + } + + const rateLimitError = checkRateLimit(request); + if (rateLimitError) return withCors(rateLimitError, request); + + const response = NextResponse.next(); + return withCors(response, request); + } + + if (PUBLIC_PATHS.has(pathname)) { + return NextResponse.next(); + } + + if (!verifyCookie(request)) { + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('next', request.nextUrl.pathname + request.nextUrl.search); + return NextResponse.redirect(loginUrl); + } + + if (pathname === '/') { + return NextResponse.redirect(new URL('/admin', request.url)); + } + + if (pathname === '/kiosk') { + return NextResponse.redirect(new URL('/admin/manage', request.url)); + } + + return NextResponse.next(); +} + +function withCors(response: NextResponse, request: NextRequest): NextResponse { + for (const [key, value] of Object.entries(corsHeaders(request))) { + response.headers.set(key, value); + } + return response; +} + +function getCorsOrigin(requestedOrigin: string | null, request: NextRequest): string { + const isDev = process.env.NODE_ENV !== 'production'; + + if (isDev) { + if (requestedOrigin) { + if (URL.canParse(requestedOrigin)) { + const url = new URL(requestedOrigin); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + return requestedOrigin; + } + } + } + + const host = request.headers.get('host'); + if (host && (host.includes('localhost') || host.includes('127.0.0.1'))) { + return `http://${host}`; + } + return 'http://localhost:3000'; + } + + if (requestedOrigin && isSameOrigin(requestedOrigin, request)) { + return requestedOrigin; + } + + const host = request.headers.get('host'); + if (!requestedOrigin && host) { + return `https://${host}`; + } + + return 'null'; +} + +function isSameOrigin(origin: string, request: NextRequest): boolean { + if (!URL.canParse(origin)) return false; + const originUrl = new URL(origin); + const host = request.headers.get('host'); + return originUrl.host === host; +} + +function isTrustedWriteRequest(request: NextRequest): boolean { + const origin = request.headers.get('origin'); + if (origin && !isSameOrigin(origin, request)) return false; + + const csrfHeader = request.headers.get('x-pulse-csrf'); + const fetchSite = request.headers.get('sec-fetch-site'); + if (csrfHeader === '1') return true; + return fetchSite === 'same-origin' || fetchSite === 'same-site' || fetchSite === 'none'; +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..24b9147 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +}