diff --git a/.env.example b/.env.example index b86a2105d..c2e55b453 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,16 @@ # DATABASE_URL=file:./dev.db +# --- Railway Bucket (S3-compatible) persistence for the DB file --- +# Replaces the old Volume mount. Copy these from the bucket's Credentials tab. +# AWS_ENDPOINT_URL=https://t3.storageapi.dev +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_S3_BUCKET_NAME= +# AWS_DEFAULT_REGION=auto +# S3_PATH_STYLE=false # Railway buckets use virtual-hosted style +# DB_OBJECT_KEY=db/dev.db # object key for the DB file (default: db/) + # If you are running it on a remote server/homelab, you must update timezone otherwise activities times may shift TZ=America/Edmonton diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f3f6bec5a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +Dockerfile text eol=lf diff --git a/Dockerfile b/Dockerfile index 5388e1ecc..4db7c3d7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,11 +38,13 @@ RUN addgroup --system --gid 1001 nodejs && \ RUN mkdir .next RUN chown nextjs:nodejs .next -# Set up /data directory with the right permissions +# /data is ephemeral local disk — durability comes from the Railway Bucket +# (see scripts/sync-db-bucket.mjs). No volume mount required. RUN mkdir -p /data/files/resumes && chown -R nextjs:nodejs /data/files/resumes COPY --from=builder /app/public ./public COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/scripts ./scripts COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static diff --git a/PRD.md b/PRD.md new file mode 100644 index 000000000..d3a8de571 --- /dev/null +++ b/PRD.md @@ -0,0 +1,519 @@ +# PRD — Dual-Workspace Application Tracker (School + Job) + +**Product:** Application Tracker (working name: *Tracker*) +**Base:** JobSync (MIT) — deployed at https://jobba.up.railway.app +**Repo:** `D:\my apps\reclip\jobsync` · Railway project `jobsync-tracker` +**UI source (only):** [opensourceui.in](https://opensourceui.in) — MIT, copy-paste React/Next.js, Tailwind v4, Lucide +**Owner:** Samuel Danquah Ankapong +**Status:** Planning — implement next session + +--- + +## 1. Vision + +One self-hosted application tracker that handles **two kinds of applications** in **separate workspaces**: + +- **School workspace** — PhD / Master's applications: universities, professors (PIs), programmes, funding, deadlines, documents (CV, SOP, transcripts), cold-email outreach, interviews. +- **Job workspace** — jobs: companies, roles, recruiters, sources, CV variants, applications, interviews, offers. + +Same engine, two skins and two field-sets. Switch between them instantly. + +**Why:** Samuel is running ~8 live PhD applications and a job search in parallel. Generic trackers model only *jobs*. Nothing models *"emailed Prof X about paper Y on date Z, follow up on date W, funding status, SOP version"* — which is the real PhD workflow. + +--- + +## 2. Goals / Non-goals + +### Goals +1. **Workspace switch** between School and Job with independent pipelines and fields. +2. Model **academic entities** natively: University, Programme, Professor/PI, Funding, Deadline, Document (SOP/CV/transcript/proposal), Outreach. +3. Track the **cold-email lifecycle** (sent → follow-up due → replied → meeting → outcome). +4. Unified **deadline dashboard** across both workspaces. +5. UI overhauled using **only opensourceui.in components** + Tailwind. +6. Keep it **self-hosted**, cheap, and agent-friendly (MCP). + +### Non-goals (v1) +- No auto-apply / auto-email sending (recruiters detect it; also unethical for academia). +- No multi-user teams/orgs. Single user. +- No job-board scraping (JobOps does that; not needed now). +- No native mobile app — responsive web only. + +--- + +## 3. Current state (baseline) + +| Item | Value | +|---|---| +| App | JobSync (Next.js 15, React, Prisma + SQLite, NextAuth, Vercel AI SDK, MCP server) | +| Auth | NextAuth, email+password, `AUTH_SECRET`, `AUTH_TRUST_HOST=true` | +| DB | SQLite at `/data/dev.db`, 5 GB Railway volume `jobsync-volume` | +| Deploy | Railway, Dockerfile build, domain https://jobba.up.railway.app | +| Styling | Tailwind + shadcn/ui (being replaced) | +| MCP | Built-in MCP server (`/api/mcp`), tokens in Settings | + +Existing Prisma models we inherit: `User, Job, JobTitle, Location, Company, JobSource, Contact, ContactRole, Profile, Activity, ActivityType, Task, Automation, UserSettings, ApiKey, Note, Tag, Question, McpAccessToken, ChatConversation`. + +--- + +## 4. Core concept — Workspaces + +``` +User + └── Workspace (type: SCHOOL | JOB) + ├── Pipeline (ordered Stages) + ├── Applications + │ ├── Organization (University | Company) + │ ├── Contacts (PIs/Professors | Recruiters) + │ ├── Documents (CV/SOP/Transcript | CV variants) + │ ├── Outreach (cold emails) + │ ├── Tasks & Deadlines + │ └── Notes / Questions + └── Settings (fields, stages, scoring) +``` + +**Workspace switcher** is the top-level nav control (opensourceui.in `workspace-switcher`). + +### Pipeline defaults (editable) + +| Stage | SCHOOL | JOB | +|---|---|---| +| 1 | Researching | Saved | +| 2 | Outreach sent | Applied | +| 3 | Applied | Screening | +| 4 | Interview | Interview | +| 5 | Offer | Offer | +| 6 | Rejected / Withdrawn | Rejected / Withdrawn | + +--- + +## 5. Domain model changes (Prisma) + +### 5.1 New models + +```prisma +model Workspace { + id String @id @default(uuid()) + userId String + name String + type WorkspaceType // SCHOOL | JOB + createdAt DateTime @default(now()) + user User @relation(fields:[userId], references:[id]) + apps Application[] + stages Stage[] + fields WorkspaceField[] // custom field defs per workspace +} + +enum WorkspaceType { SCHOOL JOB } + +model Stage { + id String @id @default(uuid()) + workspaceId String + name String + order Int + isTerminal Boolean @default(false) + color String? +} +``` + +### 5.2 Generalise the application record + +Rename `Job` → **`Application`** (keep a compatibility view during migration). + +```prisma +model Application { + id String @id @default(uuid()) + workspaceId String + orgId String? // University | Company + title String // Role title | Programme name + kind String? // phd | masters | fellowship | fulltime | internship + stageId String + deadline DateTime? + appliedAt DateTime? + sourceUrl String? + location String? + // SCHOOL-specific + fundingStatus String? // funded | self-funded | scholarship | unknown + stipendAmount String? + supervisorId String? // primary PI (Contact) + programmeRef String? // e.g. vacancy ref 2592 + // shared + matchScore Int? + notes Note[] + tasks Task[] + docs DocumentLink[] + contacts ContactRole[] + outreach Outreach[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Organization { // was Company + id String @id @default(uuid()) + name String + type String? // university | institute | company + country String? + website String? + notes String? +} + +model Document { // was Profile/resume, generalised + id String @id @default(uuid()) + name String + kind String // cv | sop | transcript | proposal | cover_letter + version Int @default(1) + filePath String? + content String? // markdown/text + createdAt DateTime @default(now()) +} + +model DocumentLink { // attach a doc+version to an application + applicationId String + documentId String + role String? // submitted | tailored | reference +} + +model Outreach { // NEW — the cold-email lifecycle + id String @id @default(uuid()) + applicationId String + contactId String + channel String @default("email") + subject String? + body String? + paperCited String? // which paper the email referenced + sentAt DateTime? + followUpDue DateTime? + repliedAt DateTime? + outcome String? // no_reply | positive | negative | meeting | offer + createdAt DateTime @default(now()) +} + +model WorkspaceField { // user-defined custom fields + id String @id @default(uuid()) + workspaceId String + key String + label String + type String // text | number | date | select | url + options String? // JSON for select +} +``` + +### 5.3 Reuse as-is +`Contact`, `ContactRole`, `Note`, `Tag`, `Task`, `Activity`, `ActivityType`, `Question`, `ApiKey`, `UserSettings`, `McpAccessToken`, `ChatConversation`. + +### 5.4 Migration plan +1. Add `Workspace`; backfill **one JOB workspace** per user. +2. `Job` → `Application` via Prisma rename + `workspaceId` backfill. +3. `Company` → `Organization`. +4. `Profile` → `Document` (kind=`cv`). +5. SQLite is fine through migration; consider **Postgres** later for concurrency (Railway add `postgres`, swap `DATABASE_URL`). + +--- + +## 6. Feature spec per workspace + +### 6.1 Shared (both) +- Applications table with **server-side sort/filter/search**, column visibility, saved views. +- Pipeline **board view** (kanban by stage). +- **Deadline calendar** (month + week) with countdown. +- Application detail page: overview, contacts, documents, outreach, tasks, notes, activity. +- Document library with **versions** and per-application links. +- Task/deadline engine with reminders (email or toast). +- Global **command palette** (⌘K). +- Dashboard: counts by stage, upcoming deadlines, stale applications, response rate. +- MCP server for agent-driven creates/updates. + +### 6.2 School-specific +- **Professor/PI directory** — contact role = PI; fields: research area, lab, papers read, outreach history. +- **Funding tracker** — funded / self-funded / scholarship, stipend, fee waiver, visa rules. +- **Programme metadata** — degree, duration, start date, application portal, reference code. +- **Outreach pipeline** (the differentiator): each application can hold many `Outreach` rows — one per professor emailed — with paper cited, date sent, follow-up due, reply outcome. +- **Document set** — SOP per programme, CV, transcripts, research proposal, referees. +- **Eligibility notes** — bachelor's vs master's requirement, English test, ECTS. + +### 6.3 Job-specific +- **Recruiter/referee contacts**, referral tracking. +- **CV tailoring** per role (document variants). +- **Source tracking** (LinkedIn, referral, company site) + response rates per source. +- Salary/comp fields. + +--- + +## 7. UI overhaul — design language + +**Source:** opensourceui.in only. Copy-paste into `components/osui/*`; we own the files. + +**Aesthetic commitment:** opensourceui.in is **light-only** ("paper white, ink text, quiet borders"). Adopting it means JobSync goes **light-first**. We add a dark variant ourselves later if needed — the library itself ships none. + +**Stack alignment check (do first):** JobSync must be on **Tailwind v4**. If it's v3, run the v3→v4 migration before copying components. + +**Rules we adopt:** hairline borders, neutral stage, accents only for state; focus = border change not coloured ring; motion respects `prefers-reduced-motion`; responsive with `md:` base (not `sm:`). + +--- + +## 8. Component mapping (opensourceui.in → screens) + +### 8.1 App shell / navigation + +| Need | opensourceui.in component | +|---|---| +| Workspace switch (School ↔ Job) | [`workspace-switcher`](https://opensourceui.in/components/workspace-switcher) | +| Account menu | [`user-menu`](https://opensourceui.in/components/user-menu) | +| Notifications tray | [`notification`](https://opensourceui.in/components/notification) | +| Command palette (⌘K) | [`spotlight-bar`](https://opensourceui.in/components/spotlight-bar) | +| View-mode switch (Table/Board/Calendar) | [`segmented-toggle-button`](https://opensourceui.in/components/segmented-toggle-button) | +| Bottom/stage nav (desktop) | [`mac-dock`](https://opensourceui.in/components/mac-dock), [`app-dock`](https://opensourceui.in/components/app-dock) | +| Collaborator presence | [`presence-dock`](https://opensourceui.in/components/presence-dock) | + +### 8.2 Applications list & board + +| Need | Component | +|---|---| +| Applications table (checkbox, assignee, due date) | [`tasks-table`](https://opensourceui.in/components/tasks-table) | +| Alternative dense list | [`orders-table`](https://opensourceui.in/components/orders-table) | +| Bulk-select rows | [`users-select-table`](https://opensourceui.in/components/users-select-table) | +| Contact/people list | [`team-members-table`](https://opensourceui.in/components/team-members-table) | +| Row actions (⋯) | [`kebab-actions`](https://opensourceui.in/components/kebab-actions) | +| Column/field menu | [`file-menu`](https://opensourceui.in/components/file-menu) | +| Filter / sort control | [`filter-sort`](https://opensourceui.in/components/filter-sort), [`select-field-input`](https://opensourceui.in/components/select-field-input) | +| Progress across stages | [`progress-ring`](https://opensourceui.in/components/progress-ring) | + +### 8.3 Deadlines & calendar + +| Need | Component | +|---|---| +| Full month view | [`month-picker-calendar`](https://opensourceui.in/components/month-picker-calendar) | +| Week strip | [`week-strip-calendar`](https://opensourceui.in/components/week-strip-calendar) | +| Date range (e.g. intake window) | [`date-range-picker`](https://opensourceui.in/components/date-range-picker) | +| Slot booking (interview) | [`booking-slot-calendar`](https://opensourceui.in/components/booking-slot-calendar) | +| Deadline countdown card | [`event-countdown-card`](https://opensourceui.in/components/event-countdown-card) | +| Activity heatmap | [`daily-activity-calendar`](https://opensourceui.in/components/daily-activity-calendar), [`github-contribution`](https://opensourceui.in/components/github-contribution) | + +### 8.4 Forms (create/edit application, PI, outreach) + +| Need | Component | +|---|---| +| Text | [`text-field-input`](https://opensourceui.in/components/text-field-input) | +| Long text / notes | [`textarea-field-input`](https://opensourceui.in/components/textarea-field-input) | +| Select (stage, funding) | [`select-field-input`](https://opensourceui.in/components/select-field-input) | +| Searchable select (university/PI) | [`combobox-field-input`](https://opensourceui.in/components/combobox-field-input) | +| Date (deadline) | [`date-field-input`](https://opensourceui.in/components/date-field-input) | +| URL with prefix | [`input-group-field`](https://opensourceui.in/components/input-group-field) | +| Attach files (CV/SOP/transcript) | [`file-upload-field-input`](https://opensourceui.in/components/file-upload-field-input) | +| Checkbox / Radio / Switch | [`checkbox-field-input`](https://opensourceui.in/components/checkbox-field-input), [`radio-group-field-input`](https://opensourceui.in/components/radio-group-field-input), [`switch-field-input`](https://opensourceui.in/components/switch-field-input) | +| Quick filter search | [`search-input`](https://opensourceui.in/components/search-input) | +| Floating-label form | [`floating-label-field-input`](https://opensourceui.in/components/floating-label-field-input) | + +### 8.5 Auth + +| Need | Component | +|---|---| +| Login | [`login-form`](https://opensourceui.in/components/login-form) | +| Signup | [`signup-form`](https://opensourceui.in/components/signup-form) | +| Password reset | [`forgot-password-form`](https://opensourceui.in/components/forgot-password-form) | +| 2FA / email code | [`otp-boxed-input`](https://opensourceui.in/components/otp-boxed-input), [`otp-underline-input`](https://opensourceui.in/components/otp-underline-input) | + +### 8.6 Contacts — recruiters & professors + +| Need | Component | +|---|---| +| PI / recruiter detail card | [`contact-profile`](https://opensourceui.in/components/contact-profile) | +| Roster / directory | [`editorial-staff-profile`](https://opensourceui.in/components/editorial-staff-profile), [`team-member-profile-grid`](https://opensourceui.in/components/team-member-profile-grid) | +| Individual profile | [`user-profile`](https://opensourceui.in/components/user-profile), [`blob-profile`](https://opensourceui.in/components/blob-profile) | +| Professional profile | [`linked-in-profile`](https://opensourceui.in/components/linked-in-profile) | + +### 8.7 Documents (CV, SOP, transcripts, proposals) + +| Need | Component | +|---|---| +| Document row / filing card | [`ink-stamp-document`](https://opensourceui.in/components/ink-stamp-document) | +| Folder grouping | [`stacked-folder-card`](https://opensourceui.in/components/stacked-folder-card), [`opensource-folder-tab-card`](https://opensourceui.in/components/opensource-folder-tab-card) | +| CV/editor preview | [`journal-writing`](https://opensourceui.in/components/journal-writing) (word count, save indicator) | + +### 8.8 Feedback / states + +| Need | Component | +|---|---| +| Toasts ("Applied saved") | [`toast-notification`](https://opensourceui.in/components/toast-notification) | +| Alert banner | [`system-alert`](https://opensourceui.in/components/system-alert) | +| Loading | [`spin-loader`](https://opensourceui.in/components/spin-loader), [`text-loader`](https://opensourceui.in/components/text-loader) | +| Empty states | [`diagonal-box-pattern`](https://opensourceui.in/components/diagonal-box-pattern), [`dot-grid-pattern`](https://opensourceui.in/components/dot-grid-pattern) as backdrop | +| Buttons | [`3d-button`](https://opensourceui.in/components/3d-button), [`depth-outline-button`](https://opensourceui.in/components/depth-outline-button), [`soft-pill-button`](https://opensourceui.in/components/soft-pill-button), [`slide-to-confirm-button`](https://opensourceui.in/components/slide-to-confirm-button) (destructive) | +| Danger confirm ("Delete application") | [`hold-to-delete-button`](https://opensourceui.in/components/hold-to-delete-button), [`slide-to-confirm-button`](https://opensourceui.in/components/slide-to-confirm-button) | + +### 8.9 Dashboard widgets + +| Need | Component | +|---|---| +| Tasks today | [`minimal-agenda`](https://opensourceui.in/components/minimal-agenda) | +| Stat tile | [`step-count`](https://opensourceui.in/components/step-count) | +| Countdown / timer | [`pomodoro`](https://opensourceui.in/components/pomodoro), [`stopwatch`](https://opensourceui.in/components/stopwatch) | +| Notifications feed | [`email-notification`](https://opensourceui.in/components/email-notification), [`calendar-reminder-notification`](https://opensourceui.in/components/calendar-reminder-notification), [`deploy-notification`](https://opensourceui.in/components/deploy-notification) | +| Backgrounds for hero sections | [`aurora-background`](https://opensourceui.in/components/aurora-background), [`frost-mesh-background`](https://opensourceui.in/components/frost-mesh-background), [`dot-grid-pattern`](https://opensourceui.in/components/dot-grid-pattern), [`graph-paper-pattern`](https://opensourceui.in/components/graph-paper-pattern) | + +--- + +## 9. Screen inventory (build list) + +| # | Screen | Key components | +|---|---|---| +| 1 | Sign in / Sign up | `login-form`, `signup-form` | +| 2 | **Workspace picker** | `workspace-switcher` | +| 3 | Dashboard (per workspace) | `step-count`, `minimal-agenda`, `event-countdown-card`, `progress-ring`, `toast-notification` | +| 4 | Applications — Table view | `tasks-table`, `filter-sort`, `search-input`, `kebab-actions` | +| 5 | Applications — Board view | own kanban, cards from `ink-stamp-document` style | +| 6 | Applications — Calendar view | `month-picker-calendar`, `week-strip-calendar` | +| 7 | Application detail | `contact-profile`, `ink-stamp-document`, `journal-writing`, `kebab-actions` | +| 8 | New/Edit application | full form component set (§8.4) | +| 9 | **Contacts / PIs directory** | `team-member-profile-grid`, `editorial-staff-profile` | +| 10 | Contact detail | `contact-profile`, `linked-in-profile` | +| 11 | **Outreach log** (cold emails) | `tasks-table` variant, `event-countdown-card` for follow-ups | +| 12 | Documents library | `stacked-folder-card`, `ink-stamp-document` | +| 13 | Tasks & deadlines | `minimal-agenda`, `tasks-table` | +| 14 | Question bank | `tasks-table` / note list | +| 15 | Settings (workspace, stages, fields, MCP) | `segmented-toggle-button`, form set | +| 16 | Global search / ⌘K | `spotlight-bar` | + +--- + +## 10. Technical architecture + +- **Framework:** Next.js 15 App Router (inherited). +- **DB:** Prisma + SQLite (v1). Optional Postgres on Railway when concurrency/reporting grows. +- **Auth:** NextAuth (inherited). +- **Styling:** Tailwind v4 + opensourceui.in components copied into `components/osui/`. +- **State/data:** React Server Components + server actions; TanStack Table for the data grid logic (styled with osui). +- **Files:** Railway volume `/data/files/…`. +- **Jobs/reminders:** a small cron (Railway cron or a `/api/cron` route) that scans `Task.dueAt` and `Outreach.followUpDue` and emails reminders. +- **MCP:** extend the inherited MCP server with tools: `create_application`, `log_outreach`, `set_deadline`, `add_contact`, `update_stage`. +- **Railway:** same project; add a cron service + (later) Postgres. + +--- + +## 11. Cold-email → tracker integration + +This closes the loop on the work already done in `CAS-cold-emails/` and `other-cold-emails/`: + +- Each cold email becomes an **Outreach** row linked to an Application + Contact. +- Fields captured: professor, paper cited, subject, body, sent date, **follow-up due (sent + 10–14 days)**, reply, outcome. +- The tracker surfaces **"follow-up due today"** — which is exactly the rule from the Reddit research (one follow-up, 10–14 days). +- Auto-create Applications from the 8 drafted emails: Vanderbilt/Dong, NYU/Su, Dresden/Speidel, Twente/Misra, KU Leuven/VanderPoorten, Utah/George, Northeastern/Shepherd (+ Imperial & HERON marked CLOSED). + +--- + +## 12. Roadmap + +### Phase 0 — prep (½ day) +- [ ] Confirm Tailwind version; migrate to v4 if needed. +- [ ] Create `components/osui/` and copy the shell + form + table components from §8. +- [ ] Set the light "paper/ink" theme tokens. + +### Phase 1 — data model (1 day) +- [ ] Prisma: add `Workspace, Stage, Outreach, Document, DocumentLink, Organization, WorkspaceField`; rename `Job→Application`, `Company→Organization`, `Profile→Document`. +- [ ] Migrate + backfill; seed default stages per workspace type. +- [ ] Create the two workspaces for Samuel's user. + +### Phase 2 — shell + workspaces (1 day) +- [ ] `workspace-switcher` in header; workspace-scoped routing `/w/[workspaceId]/…`. +- [ ] Dashboard with counts + upcoming deadlines. + +### Phase 3 — applications CRUD (2 days) +- [ ] Table view (sort/filter/search/saved views) with `tasks-table` styling. +- [ ] Board view (kanban by stage, drag-drop). +- [ ] Calendar view (month/week) reading `deadline` + `followUpDue`. +- [ ] Detail page + full create/edit form. + +### Phase 4 — school-specific (2 days) +- [ ] PI/professor directory + contact roles. +- [ ] Funding + programme fields. +- [ ] **Outreach log** with follow-up engine. +- [ ] Document library with versions (SOP/CV/transcript/proposal). + +### Phase 5 — polish + agent (1 day) +- [ ] Command palette, toasts, empty states, destructive confirms. +- [ ] MCP tools for create/log-outreach/update-stage. +- [ ] Import Samuel's 8 applications from the email drafts. + +### Phase 6 — deploy (½ day) +- [ ] Railway redeploy; add cron service for reminders; verify SUCCESS + HTTP 200. + +**Total: ~8 working days.** + +--- + +## 13. Risks & decisions + +| Risk / decision | Notes | +|---|---| +| **opensourceui.in is light-only** | Committing to a light UI. Accept, or add our own dark tokens later (library won't provide them). | +| **It's a presentational catalog, not an app kit** | No sidebar/data-grid/kanban logic. We compose the app shell and use TanStack Table for logic; osui supplies the visuals. | +| **Tailwind v4 requirement** | Verify JobSync's Tailwind version first — v3→v4 is a migration. | +| **Prisma rename is destructive** | Do it on a DB backup; the Railway volume snapshot is our rollback. | +| **SQLite concurrency** | Fine single-user; move to Postgres if we add background jobs writing concurrently. | +| **Emails not auto-sent** | Deliberate — academia and recruiters penalise automation. Tracker logs and reminds; human sends. | + +--- + +## 14. Open questions + +1. Should **Documents** hold file binaries (Railway volume) or text/markdown only in v1? +2. Do we want **Postgres** from the start to simplify later phases? +3. Should the **Job workspace** keep JobSync's AI features (CV match, cover letter) or strip them for v1? +4. Reminders: **email** (needs SMTP creds) or in-app **toast/badge** only for v1? +5. Do we keep JobSync's **Automation** (job-board scraping) or disable it in v1? + +--- + +## Appendix A — current deployments + +| | | +|---|---| +| URL | https://jobba.up.railway.app | +| Project | `jobsync-tracker` — `592d3a70-61ea-4c85-b0b4-66c0767802f8` | +| Service | `jobsync` — `49eccc92-9bc6-4755-9238-ec1c42c6cb28` | +| Volume | `jobsync-volume` (5 GB, `/data`) — `9a6d8cd7-a540-44fb-947e-7360141c9cee` | +| Fix applied | `docker-entrypoint.sh` CRLF→LF + `.gitattributes` | + +## Appendix C — storage: Bucket, not Volume (built 2026-09-17) + +**Decision — no DuckDB engine swap:** Prisma (100+ call sites) has no DuckDB +provider, so replacing SQLite with DuckDB means rewriting the whole data +layer for zero single-user benefit. The DB stays **SQLite-on-Prisma**; +durability moves from the Railway Volume to the Railway Bucket. + +| | | +|---|---| +| Bucket | `jobsync-db` (sjc) → S3 name `jobsync-db-ubyp0zs-jz9ytn` | +| Object | `db/dev.db` (+ `-wal`/`-shm` sidecars when present) | +| Boot | `scripts/sync-db-bucket.mjs download` before `prisma migrate deploy` | +| Runtime | background upload every 5 min + final upload on SIGTERM/SIGINT | +| Client | dep-free SigV4 over `fetch` (no AWS SDK — keeps the slim runner lean) | +| Style | virtual-hosted (`S3_PATH_STYLE=false`, per Railway credentials) | +| Volume | `jobsync-volume` detached after first successful bucket-backed deploy | +| Tests | `__tests__/bucket-sync.spec.ts` (17 tests, mocked fetch) | + +Phase 1 also built: `Workspace/Stage/WorkspaceField/Outreach` models + +migration `dual_workspace_outreach`, workspace + outreach server actions, +`WorkspaceSwitcher` osui component, `seed-school-workspace.mjs` (7 PIs). + +Phases 2–5 also built (same branch): +- Header workspace switcher (cookie-persisted, defaults ensured on load). +- Dashboard `Follow-ups due` + `Upcoming deadlines` cards. +- `/dashboard/outreach` page: log table, log dialog (application/contact/subject/paper/notes), follow-ups filter, mark-replied. +- Jobs list scoped by active workspace (`workspaceId` through `getJobsList` → `useJobsList` → `JobsContainer`). +- `Job.fundingStatus` (+ migration `job_funding_status`): Add/Edit form field, details card row. +- Job details `Outreach` tab per application. +- MCP `log_outreach` tool (jobs:write scope). +- 114 tests passing, `tsc` clean. Pre-existing red suites (`JobsContainer`, `JobDetails` render specs) were already failing on the base commit — untouched. + +## Appendix B — live application targets to seed + +| Deadline | Target | Status | +|---|---|---| +| 1 Oct 2026 | Twente — Prof. Misra (micro-robotics) | OPEN | +| 15 Oct 2026 | Vanderbilt — Prof. Dong (surgical/magnetic robots) | OPEN | +| 30 Oct 2026 | KU Leuven — Prof. Vander Poorten (catheterization) | OPEN | +| 1 Dec 2026 | NYU — Prof. Hao Su (surgical + wearable) | OPEN | +| rolling | Dresden — Prof. Speidel (CAS / shared autonomy) | OPEN | +| before 1 Jan | Utah — Dr George (neurorobotics) | OPEN | +| any time | Northeastern — Dr Shepherd (wearable) | OPEN | +| — | Imperial (Hamlyn), HERON/NTUA | **CLOSED** | diff --git a/__tests__/JobsActivityCard.spec.tsx b/__tests__/JobsActivityCard.spec.tsx index 530face18..221eec979 100644 --- a/__tests__/JobsActivityCard.spec.tsx +++ b/__tests__/JobsActivityCard.spec.tsx @@ -14,23 +14,6 @@ vi.mock("next-themes", () => ({ useTheme: () => ({ resolvedTheme: "light" }), })); -vi.mock("@nivo/pie", () => ({ - Pie: (props: any) => ( -
- {props.data.map((slice: any) => ( -
- {slice.label}:{slice.value}:{slice.color} - {slice.breakdown?.length - ? `:${slice.breakdown - .map((a: any) => `${a.label}=${a.hours}`) - .join(",")}` - : ""} -
- ))} -
- ), -})); - describe("JobsActivityCard", () => { const user = userEvent.setup(); @@ -83,16 +66,14 @@ describe("JobsActivityCard", () => { expect(within(total).getByText("16 jobs")).toBeInTheDocument(); }); - it("labels the slices on the chart instead of in a legend", () => { + it("lists the slices in a legend under the donut", () => { render(); - expect( - screen.queryByTestId("jobs-activity-legend"), - ).not.toBeInTheDocument(); + expect(screen.getByTestId("jobs-activity-legend")).toBeInTheDocument(); expect(screen.getByTestId("donut")).toBeInTheDocument(); }); - it("feeds the same slices to the donut", () => { + it("feeds the same slices to the legend", () => { render(); expect(screen.getByTestId("slice-Jobsync")).toHaveTextContent( diff --git a/__tests__/bucket-sync.spec.ts b/__tests__/bucket-sync.spec.ts new file mode 100644 index 000000000..1be191332 --- /dev/null +++ b/__tests__/bucket-sync.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + pick, + getBucketConfig, + resolveDbPath, + dbObjectKey, + dbSyncFiles, + objectUrl, + signS3Request, + s3Head, + s3Get, + s3Put, + s3Delete, + downloadDb, + uploadDb, +} from "../scripts/sync-db-bucket.mjs"; + +const CFG = { + endpoint: "https://t3.storageapi.dev", + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + bucket: "jobsync-db-xyz", + region: "auto", + pathStyle: false, + configured: true, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("pick", () => { + it("returns first non-blank value", () => { + expect(pick({ A: " ", B: "x" }, "A", "B")).toBe("x"); + expect(pick({}, "A", "B")).toBeUndefined(); + }); +}); + +describe("getBucketConfig", () => { + it("reads AWS_* names", () => { + const cfg = getBucketConfig({ + AWS_ENDPOINT_URL: "https://t3.storageapi.dev", + AWS_ACCESS_KEY_ID: "id", + AWS_SECRET_ACCESS_KEY: "secret", + AWS_S3_BUCKET_NAME: "b", + AWS_DEFAULT_REGION: "auto", + }); + expect(cfg.configured).toBe(true); + expect(cfg.bucket).toBe("b"); + }); + + it("reads S3_* fallback names", () => { + const cfg = getBucketConfig({ + S3_ENDPOINT: "https://t3.storageapi.dev", + S3_ACCESS_KEY_ID: "id", + S3_SECRET_ACCESS_KEY: "secret", + S3_BUCKET: "b", + }); + expect(cfg.configured).toBe(true); + expect(cfg.region).toBe("auto"); + expect(cfg.pathStyle).toBe(true); + }); + + it("reports not configured when anything is missing", () => { + expect(getBucketConfig({}).configured).toBe(false); + expect( + getBucketConfig({ AWS_ENDPOINT_URL: "x", AWS_ACCESS_KEY_ID: "y" }).configured + ).toBe(false); + }); +}); + +describe("resolveDbPath", () => { + it("strips file: prefix", () => { + expect(resolveDbPath("file:/data/dev.db")).toBe("/data/dev.db"); + }); + it("falls back when empty", () => { + expect(resolveDbPath("")).toBe("/data/dev.db"); + expect(resolveDbPath(undefined)).toBe("/data/dev.db"); + }); +}); + +describe("dbObjectKey / dbSyncFiles", () => { + it("defaults to db/", () => { + expect(dbObjectKey("/data/dev.db", {})).toBe("db/dev.db"); + }); + it("honours DB_OBJECT_KEY", () => { + expect(dbObjectKey("/data/dev.db", { DB_OBJECT_KEY: "custom/key.db" })).toBe( + "custom/key.db" + ); + }); + it("covers WAL sidecars", () => { + expect(dbSyncFiles("/data/dev.db")).toEqual([ + "/data/dev.db", + "/data/dev.db-wal", + "/data/dev.db-shm", + ]); + }); +}); + +describe("objectUrl", () => { + it("builds virtual-hosted URL when pathStyle is false", () => { + expect(objectUrl(CFG, "db/dev.db")).toBe( + "https://jobsync-db-xyz.t3.storageapi.dev/db/dev.db" + ); + }); + it("builds path-style URL when pathStyle is true", () => { + expect(objectUrl({ ...CFG, pathStyle: true }, "db/dev.db")).toBe( + "https://t3.storageapi.dev/jobsync-db-xyz/db/dev.db" + ); + }); +}); + +describe("signS3Request", () => { + const now = new Date("2026-09-17T12:00:00.000Z"); + it("is deterministic and well-formed", () => { + const a = signS3Request({ method: "GET", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + const b = signS3Request({ method: "GET", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + expect(a).toEqual(b); + expect(a.Authorization).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/20260917\/auto\/s3\/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/ + ); + expect(a["x-amz-date"]).toBe("20260917T120000Z"); + }); + it("varies with method", () => { + const get = signS3Request({ method: "GET", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + const put = signS3Request({ method: "PUT", url: objectUrl(CFG, "db/dev.db"), cfg: CFG, now }); + expect(get.Authorization).not.toBe(put.Authorization); + }); +}); + +function mockFetchOnce(status: number, body?: BodyInit) { + const calls: { url: string; method: string; headers: Record }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, method: init.method ?? "GET", headers: init.headers as Record }); + return new Response(body ?? null, { status }); + }) + ); + return calls; +} + +describe("s3 operations (mocked fetch)", () => { + it("s3Head true on 200, false on 404", async () => { + mockFetchOnce(200); + expect(await s3Head(CFG, "db/dev.db")).toBe(true); + mockFetchOnce(404); + expect(await s3Head(CFG, "db/dev.db")).toBe(false); + }); + + it("s3Get writes file on 200, returns false on 404", async () => { + const dir = mkdtempSync(join(tmpdir(), "s3get-")); + mockFetchOnce(200, "DB-BYTES"); + const dest = join(dir, "dev.db"); + expect(await s3Get(CFG, "db/dev.db", dest)).toBe(true); + expect(readFileSync(dest, "utf8")).toBe("DB-BYTES"); + mockFetchOnce(404); + expect(await s3Get(CFG, "db/missing.db", join(dir, "missing.db"))).toBe(false); + }); + + it("s3Put sends PUT to the object URL", async () => { + const dir = mkdtempSync(join(tmpdir(), "s3put-")); + const src = join(dir, "dev.db"); + const { writeFileSync } = await import("node:fs"); + writeFileSync(src, "DATA"); + const calls = mockFetchOnce(200); + expect(await s3Put(CFG, "db/dev.db", src)).toBe(true); + expect(calls[0].method).toBe("PUT"); + expect(calls[0].url).toBe("https://jobsync-db-xyz.t3.storageapi.dev/db/dev.db"); + expect(calls[0].headers.Authorization).toMatch(/^AWS4-HMAC-SHA256 /); + }); + + it("s3Delete issues DELETE and reports missing keys", async () => { + const calls = mockFetchOnce(204); + expect(await s3Delete(CFG, "db/old.db")).toBe(true); + expect(calls[0].method).toBe("DELETE"); + mockFetchOnce(404); + expect(await s3Delete(CFG, "db/old.db")).toBe(false); + }); + + it("downloadDb restores main file; uploadDb skips missing sidecars", async () => { + const dir = mkdtempSync(join(tmpdir(), "sync-")); + const db = join(dir, "app.db"); + const { writeFileSync } = await import("node:fs"); + // download: only main exists remotely — fetch mock answers per URL + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (String(url).endsWith("app.db")) return new Response("REMOTE", { status: 200 }); + return new Response(null, { status: 404 }); + }) + ); + expect(await downloadDb(CFG, db)).toBe(true); + expect(readFileSync(db, "utf8")).toBe("REMOTE"); + + // upload: only main exists locally + const puts: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + if (init.method === "PUT") puts.push(String(url)); + return new Response(null, { status: 200 }); + }) + ); + writeFileSync(db, "LOCAL"); + expect(await uploadDb(CFG, db)).toBe(true); + expect(puts).toHaveLength(1); + expect(existsSync(`${db}-wal`)).toBe(false); + }); +}); diff --git a/__tests__/job.actions.spec.ts b/__tests__/job.actions.spec.ts index 20703bf27..5e562c0c1 100644 --- a/__tests__/job.actions.spec.ts +++ b/__tests__/job.actions.spec.ts @@ -826,6 +826,11 @@ describe("jobActions", () => { }, orderBy: { createdAt: "asc" }, }, + workspace: { select: { id: true, name: true, type: true } }, + outreach: { + include: { contact: { select: { id: true, name: true } } }, + orderBy: { createdAt: "desc" }, + }, }, }); }); @@ -999,6 +1004,8 @@ describe("jobActions", () => { statusId: jobData.status, jobSourceId: jobData.source, salaryRange: jobData.salaryRange, + fundingStatus: null, + workspaceId: null, createdAt: jobData.createdAt, dueDate: jobData.dueDate, appliedDate: jobData.dateApplied, @@ -1030,6 +1037,8 @@ describe("jobActions", () => { statusId: jobData.status, jobSourceId: jobData.source, salaryRange: jobData.salaryRange, + fundingStatus: null, + workspaceId: null, createdAt: jobData.createdAt, dueDate: jobData.dueDate, description: jobData.jobDescription, diff --git a/__tests__/mcp-log-outreach.spec.ts b/__tests__/mcp-log-outreach.spec.ts new file mode 100644 index 000000000..27f60d4bc --- /dev/null +++ b/__tests__/mcp-log-outreach.spec.ts @@ -0,0 +1,50 @@ +import { handleLogOutreach } from "@/lib/mcp/tools/logOutreach"; +import { PrismaClient } from "@prisma/client"; + +vi.mock("@prisma/client", () => { + const mPrismaClient = { + job: { count: vi.fn() }, + contact: { findFirst: vi.fn(), create: vi.fn() }, + outreach: { create: vi.fn() }, + }; + return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; +}); +vi.mock("@/lib/mcp/rate-limit", () => ({ + checkMcpRateLimit: () => ({ allowed: true, resetIn: 0 }), +})); + +const prisma = new PrismaClient() as unknown as { + job: { count: ReturnType }; + contact: { findFirst: ReturnType; create: ReturnType }; + outreach: { create: ReturnType }; +}; + +describe("mcp log_outreach", () => { + it("logs with auto follow-up and reuses existing contacts", async () => { + prisma.job.count.mockResolvedValue(1); + prisma.contact.findFirst.mockResolvedValue({ id: "c1" }); + prisma.outreach.create.mockImplementation(async ({ data }: any) => ({ + id: "o1", + followUpDue: data.followUpDue, + })); + const res = await handleLogOutreach( + { jobId: "j1", contactName: "Prof. X", subject: "Hi", sentAt: "2026-09-17T00:00:00.000Z" }, + "u1" + ); + expect(res.content[0].text).toMatch(/Outreach logged \(o1\)\. Follow-up due 2026-09-29/); + expect(prisma.contact.create).not.toHaveBeenCalled(); + }); + + it("creates missing contacts and rejects orphan outreach", async () => { + prisma.job.count.mockResolvedValue(1); + prisma.contact.findFirst.mockResolvedValue(null); + prisma.contact.create.mockResolvedValue({ id: "c2" }); + prisma.outreach.create.mockResolvedValue({ id: "o2", followUpDue: new Date() }); + await handleLogOutreach({ contactName: "New Prof" }, "u1"); + expect(prisma.contact.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ name: "New Prof" }) }) + ); + const res = await handleLogOutreach({}, "u1"); + expect(res.content[0].text).toMatch(/supply jobId or contactName/); + }); +}); diff --git a/__tests__/outreach-follow-up.spec.ts b/__tests__/outreach-follow-up.spec.ts new file mode 100644 index 000000000..f70309198 --- /dev/null +++ b/__tests__/outreach-follow-up.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { + followUpDueDate, + isFollowUpDue, + FOLLOW_UP_MIN_DAYS, + FOLLOW_UP_MAX_DAYS, +} from "@/lib/outreach/follow-up"; + +describe("outreach follow-up rule", () => { + it("defaults mid-window (12 days after sending)", () => { + const sent = new Date("2026-09-17T00:00:00Z"); + const due = followUpDueDate(sent); + const days = (due.getTime() - sent.getTime()) / 86_400_000; + expect(days).toBeGreaterThanOrEqual(FOLLOW_UP_MIN_DAYS); + expect(days).toBeLessThanOrEqual(FOLLOW_UP_MAX_DAYS); + expect(due.toISOString()).toBe("2026-09-29T00:00:00.000Z"); + }); + + it("flags overdue follow-ups", () => { + expect(isFollowUpDue(new Date("2026-09-01"), new Date("2026-09-17"))).toBe(true); + expect(isFollowUpDue(new Date("2026-10-01"), new Date("2026-09-17"))).toBe(false); + expect(isFollowUpDue(null)).toBe(false); + }); +}); diff --git a/__tests__/outreach.actions.spec.ts b/__tests__/outreach.actions.spec.ts new file mode 100644 index 000000000..9278dd4cf --- /dev/null +++ b/__tests__/outreach.actions.spec.ts @@ -0,0 +1,79 @@ +import { logOutreach, getFollowUpsDue, markOutreachReplied, getUpcomingDeadlines, getOutreachList } from "@/actions/outreach/outreach.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import { PrismaClient } from "@prisma/client"; + +vi.mock("@prisma/client", () => { + const mPrismaClient = { + job: { count: vi.fn(), findMany: vi.fn() }, + outreach: { create: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + contact: { findMany: vi.fn() }, + }; + return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; +}); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); + +const prisma = new PrismaClient() as unknown as { + job: { count: ReturnType; findMany: ReturnType }; + outreach: { + create: ReturnType; + findMany: ReturnType; + update: ReturnType; + }; +}; +const mockUser = vi.mocked(getCurrentUser); + +describe("outreach actions", () => { + beforeEach(() => mockUser.mockResolvedValue({ id: "u1" } as never)); + + it("auto-sets followUpDue ~12 days after sentAt", async () => { + prisma.job.count.mockResolvedValue(1); + prisma.outreach.create.mockImplementation(async (args: any) => ({ id: "o1", ...args.data })); + const sentAt = new Date("2026-09-17T00:00:00Z"); + const res = (await logOutreach({ jobId: "j1", sentAt })) as { + success: boolean; + data: { followUpDue: Date }; + }; + expect(res.success).toBe(true); + expect(res.data.followUpDue.toISOString()).toBe("2026-09-29T00:00:00.000Z"); + }); + + it("requires a link and ownership", async () => { + expect((await logOutreach({})).success).not.toBe(true); + prisma.job.count.mockResolvedValue(0); + expect((await logOutreach({ jobId: "nope" })).success).not.toBe(true); + }); + + it("getFollowUpsDue only returns unreplied, due items for the user", async () => { + prisma.outreach.findMany.mockResolvedValue([]); + await getFollowUpsDue(new Date("2026-09-30")); + expect(prisma.outreach.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ repliedAt: null, job: { userId: "u1" } }), + }) + ); + }); + + it("markOutreachReplied stamps repliedAt + outcome", async () => { + prisma.outreach.update.mockResolvedValue({ id: "o1" }); + const res = await markOutreachReplied("o1", "meeting"); + expect(res.success).toBe(true); + expect(prisma.outreach.update.mock.calls[0][0].data).toMatchObject({ outcome: "meeting" }); + expect(prisma.outreach.update.mock.calls[0][0].data.repliedAt).toBeInstanceOf(Date); + }); + + it("getUpcomingDeadlines merges and sorts deadlines + follow-ups", async () => { + prisma.job.findMany.mockResolvedValue([ + { id: "j1", dueDate: new Date("2026-10-15"), JobTitle: { label: "PhD" }, Company: { label: "VU" } }, + ]); + prisma.outreach.findMany.mockResolvedValue([ + { id: "o1", followUpDue: new Date("2026-09-29"), contact: { name: "Prof. X" } }, + ]); + const items = (await getUpcomingDeadlines(10, new Date("2026-09-17"))) as { + kind: string; + refId: string; + }[]; + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ kind: "follow-up", refId: "o1" }); + expect(items[1]).toMatchObject({ kind: "deadline", refId: "j1" }); + }); +}); diff --git a/__tests__/workspace.actions.spec.ts b/__tests__/workspace.actions.spec.ts new file mode 100644 index 000000000..f663737bf --- /dev/null +++ b/__tests__/workspace.actions.spec.ts @@ -0,0 +1,66 @@ +import { getWorkspaces, createWorkspace, ensureDefaultWorkspaces } from "@/actions/workspace/workspace.actions"; +import { getCurrentUser } from "@/utils/user.utils"; +import { PrismaClient } from "@prisma/client"; + +vi.mock("@prisma/client", () => { + const mPrismaClient = { + workspace: { findMany: vi.fn(), create: vi.fn(), delete: vi.fn(), findFirst: vi.fn() }, + job: { count: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, + }; + return { PrismaClient: vi.fn(function () { return mPrismaClient; }) }; +}); +vi.mock("@/utils/user.utils", () => ({ getCurrentUser: vi.fn() })); + +const prisma = new PrismaClient() as unknown as { + workspace: { + findMany: ReturnType; + create: ReturnType; + findFirst: ReturnType; + }; + job: { updateMany: ReturnType }; +}; +const mockUser = vi.mocked(getCurrentUser); + +describe("workspace actions", () => { + beforeEach(() => mockUser.mockResolvedValue({ id: "u1" } as never)); + + it("creates a SCHOOL workspace with 6 default stages", async () => { + prisma.workspace.create.mockResolvedValue({ id: "w1" }); + const res = await createWorkspace("School", "SCHOOL"); + expect(res.success).toBe(true); + const payload = prisma.workspace.create.mock.calls[0][0]; + expect(payload.data.stages.create).toHaveLength(6); + expect(payload.data.stages.create[0]).toMatchObject({ name: "Researching", order: 1 }); + }); + + it("rejects empty names and bad types", async () => { + expect((await createWorkspace(" ", "SCHOOL")).success).not.toBe(true); + expect((await createWorkspace("X", "PHD" as never)).success).not.toBe(true); + }); + + it("ensureDefaultWorkspaces only creates what is missing + backfills orphans", async () => { + prisma.workspace.findMany.mockResolvedValue([{ id: "w0", type: "JOB" }]); + prisma.workspace.create.mockResolvedValue({ id: "w1", type: "SCHOOL" }); + prisma.job.updateMany.mockResolvedValue({ count: 3 }); + const res = (await ensureDefaultWorkspaces()) as { + success: boolean; + data: { id: string; type: string }[]; + backfilled: number; + }; + expect(prisma.workspace.create).toHaveBeenCalledTimes(1); + expect(prisma.workspace.create.mock.calls[0][0].data.type).toBe("SCHOOL"); + expect(res.data).toHaveLength(2); + expect(prisma.job.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: { workspaceId: "w0" } }) + ); + expect(res.backfilled).toBe(3); + }); + + it("getWorkspaces scopes to the user", async () => { + prisma.workspace.findMany.mockResolvedValue([]); + await getWorkspaces(); + expect(prisma.workspace.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: "u1" } }) + ); + }); +}); diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 647112fa7..1df6324e6 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -7,10 +7,27 @@ if [ -z "$AUTH_SECRET" ]; then echo "AUTH_SECRET was not set — generated a temporary secret for this container." fi +# DB lives on a Railway Bucket (S3), not a Volume: restore before boot. +node /app/scripts/sync-db-bucket.mjs download || echo "bucket download skipped — fresh database." + # Run migrations as root (before switching users) npx -y prisma@6.19.0 migrate deploy +# Periodically push the DB file back to the bucket (crash safety), +# plus a final push on shutdown. NOTE: no `exec` below — it would replace +# this shell and kill the background uploader + trap with it. +( + while true; do + sleep 300 + node /app/scripts/sync-db-bucket.mjs upload || true + done +) & +UPLOADER_PID=$! +trap 'kill $APP_PID $UPLOADER_PID 2>/dev/null; node /app/scripts/sync-db-bucket.mjs upload || true; exit 143' TERM INT + # Fix /data permissions and run app as nextjs user chown -R nextjs:nodejs /data export HOME=/home/nextjs -exec su -s /bin/sh nextjs -c "HOSTNAME=0.0.0.0 PORT=${PORT:-3737} node server.js" +su -s /bin/sh nextjs -c "HOSTNAME=0.0.0.0 PORT=${PORT:-3737} node server.js" & +APP_PID=$! +wait $APP_PID diff --git a/package-lock.json b/package-lock.json index 48c1cf758..85fae428a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,6 @@ "@ai-sdk/react": "^3.0.242", "@hookform/resolvers": "^5.2.2", "@modelcontextprotocol/sdk": "^1.30.0", - "@nivo/bar": "^0.99.0", - "@nivo/calendar": "^0.99.0", - "@nivo/core": "^0.99.0", - "@nivo/pie": "^0.99.0", "@prisma/client": "^6.19.0", "@radix-ui/react-alert-dialog": "^1.1.3", "@radix-ui/react-avatar": "^1.1.2", @@ -38,7 +34,6 @@ "@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.5", "@react-pdf/renderer": "^4.5.1", - "@react-spring/web": "^10.0.3", "@tiptap/pm": "^2.10.3", "@tiptap/react": "^2.10.3", "@tiptap/starter-kit": "^2.10.3", @@ -64,6 +59,7 @@ "react-day-picker": "^9.12.0", "react-dom": "19.2.4", "react-hook-form": "^7.54.2", + "recharts": "^3.10.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", @@ -91,6 +87,7 @@ "eslint": "^9.39.4", "eslint-config-next": "15.5.25", "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^26.1.0", "postcss": "^8.5.12", "prisma": "^6.19.0", "tailwindcss": "^4.3.3", @@ -355,8 +352,6 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -365,15 +360,6 @@ "lru-cache": "^10.4.3" } }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/@auth/core": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", @@ -517,8 +503,6 @@ } ], "license": "MIT-0", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -539,8 +523,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" }, @@ -565,8 +547,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -595,8 +575,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" }, @@ -620,8 +598,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -643,494 +619,26 @@ "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -2021,202 +1529,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", - "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", - "license": "MIT", - "optional": true, - "peer": true, - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.80", - "@napi-rs/canvas-darwin-arm64": "0.1.80", - "@napi-rs/canvas-darwin-x64": "0.1.80", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", - "@napi-rs/canvas-linux-arm64-musl": "0.1.80", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-gnu": "0.1.80", - "@napi-rs/canvas-linux-x64-musl": "0.1.80", - "@napi-rs/canvas-win32-x64-msvc": "0.1.80" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", - "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", - "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", - "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", - "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", - "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", - "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", - "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", - "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", - "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.80", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", - "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 10" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2364,279 +1676,14 @@ "integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==", "cpu": [ "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nivo/annotations": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/annotations/-/annotations-0.99.0.tgz", - "integrity": "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g==", - "license": "MIT", - "dependencies": { - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/theming": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/arcs": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/arcs/-/arcs-0.99.0.tgz", - "integrity": "sha512-UcvWLQPl+A3APk2Gm74N5xDfT+ATnVs2XkP73WxhYPWJk+dBzF00cndA5g/dptOwdFBvvo62VgcCsNiwUsjKTw==", - "license": "MIT", - "dependencies": { - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "@types/d3-shape": "^3.1.6", - "d3-shape": "^3.2.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/axes": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.99.0.tgz", - "integrity": "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/scales": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "@types/d3-format": "^1.4.1", - "@types/d3-time-format": "^2.3.1", - "d3-format": "^1.4.4", - "d3-time-format": "^3.0.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/bar": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.99.0.tgz", - "integrity": "sha512-9yfMn7H6UF/TqtCwVZ/vihVAXUff9wWvSaeF2Z1DCfgr5S07qs31Qb2p0LZA+YgCWpaU7zqkeb3VZ4WCpZbrDA==", - "license": "MIT", - "dependencies": { - "@nivo/annotations": "0.99.0", - "@nivo/axes": "0.99.0", - "@nivo/canvas": "0.99.0", - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/legends": "0.99.0", - "@nivo/scales": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@nivo/tooltip": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^3.1.6", - "d3-scale": "^4.0.2", - "d3-shape": "^3.2.0", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/calendar": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/calendar/-/calendar-0.99.0.tgz", - "integrity": "sha512-8Y6rIjAMt4hMWpkIUmb7skr8boAdoE7Bb4liAFSkkyRgsK4iFnFbaf1x5wLk7YmyY4v8LZ4PjNyGOYPBJU2wtw==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/legends": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@nivo/tooltip": "0.99.0", - "@types/d3-scale": "^4.0.8", - "@types/d3-time": "^1.0.10", - "@types/d3-time-format": "^3.0.0", - "d3-scale": "^4.0.2", - "d3-time": "^1.0.10", - "d3-time-format": "^3.0.0", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/calendar/node_modules/@types/d3-time-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz", - "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==", - "license": "MIT" - }, - "node_modules/@nivo/canvas": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/canvas/-/canvas-0.99.0.tgz", - "integrity": "sha512-UxA8zb+NPwqmNm81hoyUZSMAikgjU1ukLf4KybVNyV8ejcJM+BUFXsb8DxTcLdt4nmCFHqM56GaJQv2hnAHmzg==", - "license": "MIT" - }, - "node_modules/@nivo/colors": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", - "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/theming": "0.99.0", - "@types/d3-color": "^3.0.0", - "@types/d3-scale": "^4.0.8", - "@types/d3-scale-chromatic": "^3.0.0", - "d3-color": "^3.1.0", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.0.0", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/core": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", - "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", - "license": "MIT", - "dependencies": { - "@nivo/theming": "0.99.0", - "@nivo/tooltip": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "@types/d3-shape": "^3.1.6", - "d3-color": "^3.1.0", - "d3-format": "^1.4.4", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.0.0", - "d3-shape": "^3.2.0", - "d3-time-format": "^3.0.0", - "lodash": "^4.17.21", - "react-virtualized-auto-sizer": "^1.0.26", - "use-debounce": "^10.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nivo/donate" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/legends": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", - "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", - "license": "MIT", - "dependencies": { - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@types/d3-scale": "^4.0.8", - "d3-scale": "^4.0.2" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/pie": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/pie/-/pie-0.99.0.tgz", - "integrity": "sha512-zUbo8UdLndp2RMljrOqitAKKEnl7YypkJrOzjKLk8jQGU7qqUKtgFoJIPhiBsvNPs3xtX2KwgtS1+JKNTNns7A==", - "license": "MIT", - "dependencies": { - "@nivo/arcs": "0.99.0", - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/legends": "0.99.0", - "@nivo/theming": "0.99.0", - "@nivo/tooltip": "0.99.0", - "@types/d3-shape": "^3.1.6", - "d3-shape": "^3.2.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/scales": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.99.0.tgz", - "integrity": "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "^3.0.4", - "@types/d3-scale": "^4.0.8", - "@types/d3-time": "^1.1.1", - "@types/d3-time-format": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-time": "^1.0.11", - "d3-time-format": "^3.0.0", - "lodash": "^4.17.21" - } - }, - "node_modules/@nivo/scales/node_modules/@types/d3-time-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz", - "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==", - "license": "MIT" - }, - "node_modules/@nivo/text": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", - "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/theming": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/theming": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", - "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/tooltip": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", - "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/theming": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, "node_modules/@noble/ciphers": { @@ -4097,76 +3144,30 @@ "@react-pdf/stylesheet": "^6.2.1" } }, - "node_modules/@react-spring/animated": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", - "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", - "license": "MIT", - "dependencies": { - "@react-spring/shared": "~10.0.3", - "@react-spring/types": "~10.0.3" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/core": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", - "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", - "license": "MIT", - "dependencies": { - "@react-spring/animated": "~10.0.3", - "@react-spring/shared": "~10.0.3", - "@react-spring/types": "~10.0.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-spring/donate" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/rafz": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", - "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", - "license": "MIT" - }, - "node_modules/@react-spring/shared": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", - "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", "license": "MIT", "dependencies": { - "@react-spring/rafz": "~10.0.3", - "@react-spring/types": "~10.0.3" + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/types": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", - "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", - "license": "MIT" - }, - "node_modules/@react-spring/web": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", - "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", - "license": "MIT", - "dependencies": { - "@react-spring/animated": "~10.0.3", - "@react-spring/core": "~10.0.3", - "@react-spring/shared": "~10.0.3", - "@react-spring/types": "~10.0.3" + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } } }, "node_modules/@remirror/core-constants": { @@ -5400,16 +4401,22 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-format": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz", - "integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==", + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { @@ -5436,12 +4443,6 @@ "@types/d3-time": "*" } }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" - }, "node_modules/@types/d3-shape": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", @@ -5457,10 +4458,10 @@ "integrity": "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g==", "license": "MIT" }, - "node_modules/@types/d3-time-format": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz", - "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==", + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, "node_modules/@types/deep-eql": { @@ -6387,8 +5388,6 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 14" } @@ -7349,8 +6348,6 @@ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -7387,6 +6384,15 @@ "node": ">=12" } }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", @@ -7430,19 +6436,6 @@ "node": ">=12" } }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-scale/node_modules/d3-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -7482,6 +6475,15 @@ "d3-time": "1 - 2" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7495,8 +6497,6 @@ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -7597,9 +6597,13 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" }, "node_modules/deep-is": { "version": "0.1.4", @@ -7849,8 +6853,6 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=0.12" }, @@ -8040,49 +7042,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] }, "node_modules/escape-html": { "version": "1.0.3", @@ -8568,6 +7538,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -9279,8 +8255,6 @@ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -9321,8 +8295,6 @@ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -9337,8 +8309,6 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -9359,8 +8329,6 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -9384,6 +8352,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9731,9 +8709,7 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/is-promise": { "version": "4.0.0", @@ -10031,8 +9007,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -10532,12 +9506,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10568,6 +9536,13 @@ "underscore": "^1.13.1" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lucide-react": { "version": "0.469.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", @@ -10597,20 +9572,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11027,13 +9988,11 @@ } }, "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/nypm": { "version": "0.6.2", @@ -11386,8 +10345,6 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -11539,7 +10496,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12107,6 +11063,36 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -12176,16 +11162,6 @@ } } }, - "node_modules/react-virtualized-auto-sizer": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", - "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", - "license": "MIT", - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -12200,6 +11176,36 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -12214,6 +11220,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12267,6 +11288,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -12393,9 +11420,7 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/run-parallel": { "version": "1.2.0", @@ -12508,8 +11533,6 @@ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", - "optional": true, - "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -13082,9 +12105,7 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/tailwind-merge": { "version": "3.6.0", @@ -13135,6 +12156,12 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -13194,8 +12221,6 @@ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -13208,9 +12233,7 @@ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -13240,8 +12263,6 @@ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", - "optional": true, - "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -13255,8 +12276,6 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -13370,27 +12389,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -13698,18 +12696,6 @@ } } }, - "node_modules/use-debounce": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", - "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", - "license": "MIT", - "engines": { - "node": ">= 16.0.0" - }, - "peerDependencies": { - "react": "*" - } - }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -13778,6 +12764,46 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/victory-vendor/node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -13972,8 +12998,6 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -13987,8 +13011,6 @@ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, "license": "BSD-2-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -14000,8 +13022,6 @@ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -14015,8 +13035,6 @@ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -14027,8 +13045,6 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -14175,13 +13191,11 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -14204,8 +13218,6 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", - "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -14224,27 +13236,7 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } + "license": "MIT" }, "node_modules/yn": { "version": "3.1.1", diff --git a/package.json b/package.json index 2a3d8d4c0..4c3e10dd4 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,6 @@ "@ai-sdk/react": "^3.0.242", "@hookform/resolvers": "^5.2.2", "@modelcontextprotocol/sdk": "^1.30.0", - "@nivo/bar": "^0.99.0", - "@nivo/calendar": "^0.99.0", - "@nivo/core": "^0.99.0", - "@nivo/pie": "^0.99.0", "@prisma/client": "^6.19.0", "@radix-ui/react-alert-dialog": "^1.1.3", "@radix-ui/react-avatar": "^1.1.2", @@ -58,7 +54,6 @@ "@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-tooltip": "^1.1.5", "@react-pdf/renderer": "^4.5.1", - "@react-spring/web": "^10.0.3", "@tiptap/pm": "^2.10.3", "@tiptap/react": "^2.10.3", "@tiptap/starter-kit": "^2.10.3", @@ -84,6 +79,7 @@ "react-day-picker": "^9.12.0", "react-dom": "19.2.4", "react-hook-form": "^7.54.2", + "recharts": "^3.10.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", @@ -111,6 +107,7 @@ "eslint": "^9.39.4", "eslint-config-next": "15.5.25", "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^26.1.0", "postcss": "^8.5.12", "prisma": "^6.19.0", "tailwindcss": "^4.3.3", diff --git a/prisma/migrations/20260917010308_dual_workspace_outreach/migration.sql b/prisma/migrations/20260917010308_dual_workspace_outreach/migration.sql new file mode 100644 index 000000000..fe29ee83c --- /dev/null +++ b/prisma/migrations/20260917010308_dual_workspace_outreach/migration.sql @@ -0,0 +1,119 @@ +-- CreateTable +CREATE TABLE "Workspace" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Workspace_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Stage" ( + "id" TEXT NOT NULL PRIMARY KEY, + "workspaceId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "order" INTEGER NOT NULL, + "isTerminal" BOOLEAN NOT NULL DEFAULT false, + "color" TEXT, + CONSTRAINT "Stage_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "WorkspaceField" ( + "id" TEXT NOT NULL PRIMARY KEY, + "workspaceId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT NOT NULL, + "type" TEXT NOT NULL, + "options" TEXT, + CONSTRAINT "WorkspaceField_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Outreach" ( + "id" TEXT NOT NULL PRIMARY KEY, + "jobId" TEXT, + "contactId" TEXT, + "channel" TEXT NOT NULL DEFAULT 'email', + "subject" TEXT, + "body" TEXT, + "paperCited" TEXT, + "sentAt" DATETIME, + "followUpDue" DATETIME, + "repliedAt" DATETIME, + "outcome" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Outreach_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Outreach_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Contact" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Job" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "jobUrl" TEXT, + "description" TEXT NOT NULL, + "jobType" TEXT NOT NULL, + "workplaceType" TEXT, + "createdAt" DATETIME NOT NULL, + "applied" BOOLEAN NOT NULL DEFAULT false, + "appliedDate" DATETIME, + "dueDate" DATETIME, + "statusId" TEXT NOT NULL, + "jobTitleId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "jobSourceId" TEXT, + "salaryRange" TEXT, + "locationId" TEXT, + "resumeId" TEXT, + "coverLetterId" TEXT, + "workspaceId" TEXT, + "automationId" TEXT, + "matchScore" INTEGER, + "matchData" TEXT, + "discoveryStatus" TEXT, + "discoveredAt" DATETIME, + "createdVia" TEXT, + "descriptionCompleteness" TEXT, + CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "JobStatus" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_jobTitleId_fkey" FOREIGN KEY ("jobTitleId") REFERENCES "JobTitle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Job_jobSourceId_fkey" FOREIGN KEY ("jobSourceId") REFERENCES "JobSource" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_resumeId_fkey" FOREIGN KEY ("resumeId") REFERENCES "Resume" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_coverLetterId_fkey" FOREIGN KEY ("coverLetterId") REFERENCES "CoverLetter" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Job_automationId_fkey" FOREIGN KEY ("automationId") REFERENCES "Automation" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Job" ("applied", "appliedDate", "automationId", "companyId", "coverLetterId", "createdAt", "createdVia", "description", "descriptionCompleteness", "discoveredAt", "discoveryStatus", "dueDate", "id", "jobSourceId", "jobTitleId", "jobType", "jobUrl", "locationId", "matchData", "matchScore", "resumeId", "salaryRange", "statusId", "userId", "workplaceType") SELECT "applied", "appliedDate", "automationId", "companyId", "coverLetterId", "createdAt", "createdVia", "description", "descriptionCompleteness", "discoveredAt", "discoveryStatus", "dueDate", "id", "jobSourceId", "jobTitleId", "jobType", "jobUrl", "locationId", "matchData", "matchScore", "resumeId", "salaryRange", "statusId", "userId", "workplaceType" FROM "Job"; +DROP TABLE "Job"; +ALTER TABLE "new_Job" RENAME TO "Job"; +CREATE INDEX "Job_userId_automationId_idx" ON "Job"("userId", "automationId"); +CREATE INDEX "Job_userId_discoveryStatus_idx" ON "Job"("userId", "discoveryStatus"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "Workspace_userId_idx" ON "Workspace"("userId"); + +-- CreateIndex +CREATE INDEX "Workspace_userId_type_idx" ON "Workspace"("userId", "type"); + +-- CreateIndex +CREATE INDEX "Stage_workspaceId_order_idx" ON "Stage"("workspaceId", "order"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkspaceField_workspaceId_key_key" ON "WorkspaceField"("workspaceId", "key"); + +-- CreateIndex +CREATE INDEX "Outreach_jobId_idx" ON "Outreach"("jobId"); + +-- CreateIndex +CREATE INDEX "Outreach_contactId_idx" ON "Outreach"("contactId"); + +-- CreateIndex +CREATE INDEX "Outreach_followUpDue_idx" ON "Outreach"("followUpDue"); diff --git a/prisma/migrations/20260917012014_job_funding_status/migration.sql b/prisma/migrations/20260917012014_job_funding_status/migration.sql new file mode 100644 index 000000000..3500df582 --- /dev/null +++ b/prisma/migrations/20260917012014_job_funding_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "fundingStatus" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17c44665a..148c9ab1d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model User { ChatConversation ChatConversation? defaultResumeId String? defaultResume Resume? @relation("UserDefaultResume", fields: [defaultResumeId], references: [id], onDelete: SetNull) + workspaces Workspace[] } model ApiKey { @@ -246,6 +247,8 @@ model Contact { jobLinks JobContact[] + outreach Outreach[] + @@index([createdBy]) @@index([companyId]) @@index([workedAtCompanyId]) @@ -385,6 +388,8 @@ model Job { JobSource JobSource? @relation(fields: [jobSourceId], references: [id]) jobSourceId String? salaryRange String? + // School workspace: funded | self-funded | scholarship | unknown + fundingStatus String? Location Location? @relation(fields: [locationId], references: [id]) locationId String? Interview Interview[] @@ -396,6 +401,11 @@ model Job { tags Tag[] contactLinks JobContact[] + // Dual-workspace (PRD Phase 1): optional link into a SCHOOL/JOB workspace. + workspaceId String? + workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: SetNull) + outreach Outreach[] + // Automation discovery fields automationId String? automation Automation? @relation(fields: [automationId], references: [id], onDelete: SetNull) @@ -620,3 +630,72 @@ model ChatConversation { updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) } + +// ---------- Dual-workspace (School + Job) — PRD Phase 1 ---------- + +enum WorkspaceType { + SCHOOL + JOB +} + +model Workspace { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + type WorkspaceType + createdAt DateTime @default(now()) + stages Stage[] + jobs Job[] + fields WorkspaceField[] + + @@index([userId]) + @@index([userId, type]) +} + +model Stage { + id String @id @default(uuid()) + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + name String + order Int + isTerminal Boolean @default(false) + color String? + + @@index([workspaceId, order]) +} + +model WorkspaceField { + id String @id @default(uuid()) + workspaceId String + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + key String + label String + type String // text | number | date | select | url + options String? // JSON for select + + @@unique([workspaceId, key]) +} + +// Cold-email lifecycle: one row per professor/recruiter contacted. +// Follow-up rule: sentAt + 10–14 days. +model Outreach { + id String @id @default(uuid()) + jobId String? + job Job? @relation(fields: [jobId], references: [id], onDelete: Cascade) + contactId String? + contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + channel String @default("email") + subject String? + body String? + paperCited String? + sentAt DateTime? + followUpDue DateTime? + repliedAt DateTime? + outcome String? // no_reply | positive | negative | meeting | offer + createdAt DateTime @default(now()) + + @@index([jobId]) + @@index([contactId]) + @@index([followUpDue]) +} diff --git a/scripts/seed-school-workspace.mjs b/scripts/seed-school-workspace.mjs new file mode 100644 index 000000000..c73202153 --- /dev/null +++ b/scripts/seed-school-workspace.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Seed the SCHOOL workspace + PI directory for Samuel's 8 live PhD targets. + * Idempotent (skips existing workspace / contacts by name). + * + * Usage: + * DATABASE_URL="file:./dev.db" node scripts/seed-school-workspace.mjs --email=you@example.com + * + * Details (deadlines, paper angles) live in D:/my apps/reclip/{CAS-cold-emails,other-cold-emails}. + */ + +import { PrismaClient } from "@prisma/client"; + +const TARGETS = [ + { name: "Prof. Sarthak Misra", org: "University of Twente", deadline: "2026-10-01", angle: "surgical micro-robotics" }, + { name: "Prof. Dong", org: "Vanderbilt University", deadline: "2026-10-15", angle: "surgical / magnetic robots" }, + { name: "Prof. Emmanuel Vander Poorten", org: "KU Leuven", deadline: "2026-10-30", angle: "robot-assisted catheterization (RAS group head)" }, + { name: "Prof. Hao Su", org: "New York University", deadline: "2026-12-01", angle: "surgical robotics + wearable robotics" }, + { name: "Prof. Stefanie Speidel", org: "TU Dresden / NCT", deadline: "rolling", angle: "computer-assisted surgery, shared autonomy" }, + { name: "Dr. Jake George", org: "University of Utah", deadline: "before 2027-01-01", angle: "neurorobotics / neural interfaces" }, + { name: "Dr. Shepherd", org: "Northeastern University", deadline: "any time", angle: "wearable robotics" }, +]; + +const STAGES = ["Researching", "Outreach sent", "Applied", "Interview", "Offer", "Rejected / Withdrawn"]; + +const email = (process.argv.find((a) => a.startsWith("--email=")) ?? "").slice("--email=".length); +if (!email) { + console.error("usage: node scripts/seed-school-workspace.mjs --email=you@example.com"); + process.exit(2); +} + +const prisma = new PrismaClient(); +try { + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) throw new Error(`no user with email ${email}`); + + let ws = await prisma.workspace.findFirst({ where: { userId: user.id, type: "SCHOOL" } }); + if (!ws) { + ws = await prisma.workspace.create({ + data: { + userId: user.id, + name: "School", + type: "SCHOOL", + stages: { create: STAGES.map((name, i) => ({ name, order: i + 1, isTerminal: name.startsWith("Rejected") })) }, + }, + }); + console.log(`created SCHOOL workspace + ${STAGES.length} stages`); + } else { + console.log("SCHOOL workspace already exists — skipping"); + } + + for (const t of TARGETS) { + const exists = await prisma.contact.findFirst({ where: { createdBy: user.id, name: t.name } }); + if (exists) { + console.log(`contact exists: ${t.name} — skipping`); + continue; + } + await prisma.contact.create({ + data: { + createdBy: user.id, + name: t.name, + title: "Professor / PI", + relationship: "PhD supervisor prospect", + notes: `${t.org} — ${t.angle}. Deadline: ${t.deadline}. See cold-email drafts in CAS-cold-emails / other-cold-emails.`, + }, + }); + console.log(`created contact: ${t.name}`); + } +} finally { + await prisma.$disconnect(); +} diff --git a/scripts/sync-db-bucket.mjs b/scripts/sync-db-bucket.mjs new file mode 100644 index 000000000..f11cada6f --- /dev/null +++ b/scripts/sync-db-bucket.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +/** + * Sync the single-file database (SQLite, Prisma-compatible) with a + * Railway Bucket (S3-compatible object storage). + * + * Why not DuckDB as the engine: Prisma — the app's entire data layer + * (100+ call sites) — has no DuckDB provider. Swapping engines means + * rewriting every query. So the transactional DB stays SQLite-on-Prisma, + * and durability moves from a Railway Volume to a Railway Bucket: + * download on boot, upload on a timer + on shutdown. + * `ponytail: file-level sync of main+wal+shm; per-row replication if multi-writer ever needed` + * + * Usage: + * node scripts/sync-db-bucket.mjs download [dbPath] + * node scripts/sync-db-bucket.mjs upload [dbPath] + * + * Env (Railway Bucket credentials tab / variable references): + * AWS_ENDPOINT_URL | S3_ENDPOINT e.g. https://t3.storageapi.dev (required) + * AWS_ACCESS_KEY_ID | S3_ACCESS_KEY_ID (required) + * AWS_SECRET_ACCESS_KEY | S3_SECRET_ACCESS_KEY (required) + * AWS_S3_BUCKET_NAME | BUCKET_NAME | S3_BUCKET (required) + * AWS_DEFAULT_REGION | S3_REGION default "auto" + * S3_PATH_STYLE default "true" (set "false" for virtual-hosted style) + * DB_OBJECT_KEY default "db/" + * DATABASE_URL e.g. file:/data/dev.db (db path source) + * + * Exit 0 + skip message when the bucket is not configured (local dev). + */ + +import { createHash, createHmac } from "node:crypto"; +import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, basename } from "node:path"; + +const EMPTY_HASH = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +/** @param {Record} env */ +export function pick(env, ...names) { + for (const n of names) { + const v = env[n]; + if (v !== undefined && v !== null && String(v).trim() !== "") return String(v).trim(); + } + return undefined; +} + +/** @param {Record} [env] */ +export function getBucketConfig(env = process.env) { + const endpoint = pick(env, "AWS_ENDPOINT_URL", "S3_ENDPOINT", "BUCKET_ENDPOINT"); + const accessKeyId = pick(env, "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY_ID", "BUCKET_ACCESS_KEY_ID"); + const secretAccessKey = pick(env, "AWS_SECRET_ACCESS_KEY", "S3_SECRET_ACCESS_KEY", "BUCKET_SECRET_ACCESS_KEY"); + const bucket = pick(env, "AWS_S3_BUCKET_NAME", "BUCKET_NAME", "S3_BUCKET", "S3_BUCKET_NAME"); + const region = pick(env, "AWS_DEFAULT_REGION", "S3_REGION", "BUCKET_REGION") ?? "auto"; + const pathStyle = (pick(env, "S3_PATH_STYLE") ?? "true").toLowerCase() !== "false"; + const configured = Boolean(endpoint && accessKeyId && secretAccessKey && bucket); + return { endpoint, accessKeyId, secretAccessKey, bucket, region, pathStyle, configured }; +} + +export function resolveDbPath(databaseUrl, fallback = "/data/dev.db") { + if (!databaseUrl) return fallback; + const u = String(databaseUrl).trim(); + if (u.startsWith("file:")) return u.slice("file:".length) || fallback; + return u || fallback; +} + +/** @param {Record} [env] */ +export function dbObjectKey(dbPath, env = process.env) { + return pick(env, "DB_OBJECT_KEY") ?? `db/${basename(dbPath)}`; +} + +/** Files that make up one SQLite database (WAL mode = up to 3 files). */ +export function dbSyncFiles(dbPath) { + return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]; +} + +function encodeKey(key) { + return key.split("/").map((s) => encodeURIComponent(s)).join("/"); +} + +export function objectUrl(cfg, key) { + const base = cfg.endpoint.replace(/\/+$/, ""); + return cfg.pathStyle + ? `${base}/${cfg.bucket}/${encodeKey(key)}` + : `${base.replace("://", `://${cfg.bucket}.`)}/${encodeKey(key)}`; +} + +function amzDates(now = new Date()) { + const iso = now.toISOString().replace(/[-:]|\.\d{3}/g, ""); + return { amzDate: iso, dateStamp: iso.slice(0, 8) }; +} + +function signingKey(secret, dateStamp, region) { + const kDate = createHmac("sha256", `AWS4${secret}`).update(dateStamp).digest(); + const kRegion = createHmac("sha256", kDate).update(region).digest(); + const kService = createHmac("sha256", kRegion).update("s3").digest(); + return createHmac("sha256", kService).update("aws4_request").digest(); +} + +export function signS3Request({ method, url, payloadHash = EMPTY_HASH, cfg, now = new Date() }) { + const { amzDate, dateStamp } = amzDates(now); + const u = new URL(url); + const canonicalUri = u.pathname; + const canonicalQuery = u.searchParams.toString(); + const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + const canonical = + `${method}\n${canonicalUri}\n${canonicalQuery}\n` + + `host:${u.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n\n` + + `${signedHeaders}\n${payloadHash}`; + const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`; + const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${createHash("sha256").update(canonical).digest("hex")}`; + const signature = createHmac("sha256", signingKey(cfg.secretAccessKey, dateStamp, cfg.region)) + .update(stringToSign) + .digest("hex"); + return { + Authorization: `AWS4-HMAC-SHA256 Credential=${cfg.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + "x-amz-date": amzDate, + "x-amz-content-sha256": payloadHash, + }; +} + +async function s3Fetch(cfg, method, key, body) { + const url = objectUrl(cfg, key); + const payloadHash = body ? createHash("sha256").update(body).digest("hex") : EMPTY_HASH; + const headers = { + ...signS3Request({ method, url, payloadHash, cfg }), + ...(body ? { "Content-Type": "application/octet-stream", "Content-Length": String(body.length) } : {}), + }; + return fetch(url, { method, headers, body }); +} + +export async function s3Head(cfg, key) { + const res = await s3Fetch(cfg, "HEAD", key); + if (res.status === 200) return true; + if (res.status === 404) return false; + throw new Error(`S3 HEAD ${key} -> HTTP ${res.status}`); +} + +export async function s3Get(cfg, key, destPath) { + const res = await s3Fetch(cfg, "GET", key); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`S3 GET ${key} -> HTTP ${res.status}`); + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, Buffer.from(await res.arrayBuffer())); + return true; +} + +export async function s3Put(cfg, key, srcPath) { + const body = readFileSync(srcPath); + const res = await s3Fetch(cfg, "PUT", key, body); + if (!res.ok) throw new Error(`S3 PUT ${key} -> HTTP ${res.status}`); + return true; +} + +export async function s3Delete(cfg, key) { + const res = await s3Fetch(cfg, "DELETE", key); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`S3 DELETE ${key} -> HTTP ${res.status}`); + return true; +} + +export async function downloadDb(cfg, dbPath) { + mkdirSync(dirname(dbPath), { recursive: true }); + let restored = false; + for (const f of dbSyncFiles(dbPath)) { + const key = dbObjectKey(f); + if (await s3Get(cfg, key, f)) restored = true; + } + return restored; +} + +export async function uploadDb(cfg, dbPath) { + let uploaded = false; + for (const f of dbSyncFiles(dbPath)) { + if (!existsSync(f)) continue; + await s3Put(cfg, dbObjectKey(f), f); + uploaded = true; + } + return uploaded; +} + +// --- CLI --- +const isMain = process.argv[1] && basename(process.argv[1]) === "sync-db-bucket.mjs"; +if (isMain) { + const mode = process.argv[2]; + const cfg = getBucketConfig(); + const dbPath = resolveDbPath(process.env.DATABASE_URL, process.argv[3] ?? "/data/dev.db"); + if (!cfg.configured) { + console.log("bucket-sync: S3 bucket not configured — skipping."); + process.exit(0); + } + if (mode !== "download" && mode !== "upload" && mode !== "delete") { + console.error("usage: node scripts/sync-db-bucket.mjs download|upload|delete [dbPath|key]"); + process.exit(2); + } + try { + if (mode === "download") { + const restored = await downloadDb(cfg, dbPath); + console.log(restored ? `bucket-sync: restored ${dbPath} from bucket.` : "bucket-sync: no object in bucket — fresh database."); + } else if (mode === "delete") { + const key = process.argv[3] ?? dbObjectKey(dbPath); + const ok = await s3Delete(cfg, key); + console.log(ok ? `bucket-sync: deleted ${key}.` : `bucket-sync: ${key} not found.`); + } else { + const ok = await uploadDb(cfg, dbPath); + console.log(ok ? `bucket-sync: uploaded ${dbPath} to bucket.` : "bucket-sync: no local db file — nothing uploaded."); + } + } catch (err) { + console.error(`bucket-sync: ${mode} failed: ${err.message}`); + process.exit(1); + } +} diff --git a/src/actions/contact.actions.ts b/src/actions/contact.actions.ts index ceca05758..e83d32d44 100644 --- a/src/actions/contact.actions.ts +++ b/src/actions/contact.actions.ts @@ -2,6 +2,7 @@ export { getContactList, getAllContacts, getContactById, + getContactsDirectory, } from "./contact/queries"; export { diff --git a/src/actions/contact/queries.ts b/src/actions/contact/queries.ts index 463144956..d42f2725e 100644 --- a/src/actions/contact/queries.ts +++ b/src/actions/contact/queries.ts @@ -52,6 +52,32 @@ export const getContactList = async ( } }; +// Directory for the osui contact cards: people + their outreach history. +export const getContactsDirectory = async () => { + try { + const user = await requireUser(); + return await prisma.contact.findMany({ + where: { createdBy: user.id }, + select: { + id: true, + name: true, + title: true, + email: true, + phone: true, + linkedinUrl: true, + relationship: true, + notes: true, + Company: { select: { label: true } }, + _count: { select: { outreach: true } }, + }, + orderBy: { name: "asc" }, + take: 200, + }); + } catch (error) { + return handleError(error, "Failed to fetch directory. "); + } +}; + // The job-tab picker filters a preloaded array, so `value` carries everything // worth searching on — name, email and employer. export const getAllContacts = async (): Promise => { diff --git a/src/actions/job/mutations.ts b/src/actions/job/mutations.ts index 10ac08eb6..aca4a26ad 100644 --- a/src/actions/job/mutations.ts +++ b/src/actions/job/mutations.ts @@ -75,6 +75,8 @@ export const addJob = async ( status, source, salaryRange, + fundingStatus, + workspaceId, dueDate, dateApplied, jobDescription, @@ -102,6 +104,8 @@ export const addJob = async ( statusId: status, jobSourceId: source, salaryRange: salaryRange || null, + fundingStatus: fundingStatus || null, + workspaceId: workspaceId || null, dueDate, appliedDate: dateApplied, description: jobDescription, @@ -141,6 +145,8 @@ export const updateJob = async ( status, source, salaryRange, + fundingStatus, + workspaceId, dueDate, dateApplied, jobDescription, @@ -196,6 +202,8 @@ export const updateJob = async ( statusId: status, jobSourceId: source, salaryRange: salaryRange || null, + fundingStatus: fundingStatus || null, + workspaceId: workspaceId || null, createdAt: new Date(), dueDate: dueDate, appliedDate: dateApplied, diff --git a/src/actions/job/queries.ts b/src/actions/job/queries.ts index d0ada893d..b71aef5af 100644 --- a/src/actions/job/queries.ts +++ b/src/actions/job/queries.ts @@ -23,6 +23,9 @@ const JOB_LIST_SELECT = { matchScore: true, matchData: true, discoveryStatus: true, + workspaceId: true, + fundingStatus: true, + workspace: { select: { id: true, name: true, type: true } }, _count: { select: { Notes: true } }, }; @@ -71,6 +74,11 @@ const JOB_DETAILS_INCLUDE = { }, orderBy: { createdAt: "asc" as const }, }, + workspace: { select: { id: true, name: true, type: true } }, + outreach: { + include: { contact: { select: { id: true, name: true } } }, + orderBy: { createdAt: "desc" as const }, + }, }; type JobsListFilters = { @@ -81,6 +89,7 @@ type JobsListFilters = { titleValue?: string; locationValue?: string; sourceValue?: string; + workspaceId?: string; }; const buildJobsWhereClause = (userId: string, filters: JobsListFilters) => { @@ -92,6 +101,7 @@ const buildJobsWhereClause = (userId: string, filters: JobsListFilters) => { titleValue, locationValue, sourceValue, + workspaceId, } = filters; const filterBy = filter @@ -129,6 +139,10 @@ const buildJobsWhereClause = (userId: string, filters: JobsListFilters) => { whereClause.Company = { value: companyValue }; } + if (workspaceId) { + whereClause.workspaceId = workspaceId; + } + if (titleValue) { whereClause.JobTitle = { value: titleValue }; } @@ -180,6 +194,7 @@ export const getJobsList = async ( titleValue?: string, locationValue?: string, sourceValue?: string, + workspaceId?: string, ): Promise => { try { const user = await requireUser(); @@ -193,6 +208,7 @@ export const getJobsList = async ( titleValue, locationValue, sourceValue, + workspaceId, }); const [data, total] = await Promise.all([ diff --git a/src/actions/outreach/outreach.actions.ts b/src/actions/outreach/outreach.actions.ts new file mode 100644 index 000000000..5107c48e0 --- /dev/null +++ b/src/actions/outreach/outreach.actions.ts @@ -0,0 +1,162 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { followUpDueDate } from "@/lib/outreach/follow-up"; + +export const logOutreach = async (input: { + jobId?: string; + contactId?: string; + subject?: string; + body?: string; + paperCited?: string; + sentAt?: Date; +}) => { + try { + const user = await requireUser(); + if (!input.jobId && !input.contactId) throw new Error("Link the outreach to an application or a contact."); + if (input.jobId) { + const own = await prisma.job.count({ where: { id: input.jobId, userId: user.id } }); + if (!own) throw new Error("Application not found."); + } + const sentAt = input.sentAt ?? new Date(); + const row = await prisma.outreach.create({ + data: { + jobId: input.jobId, + contactId: input.contactId, + subject: input.subject, + body: input.body, + paperCited: input.paperCited, + sentAt, + followUpDue: followUpDueDate(sentAt), + }, + }); + return { data: row, success: true }; + } catch (error) { + return handleError(error, "Failed to log outreach. "); + } +}; + +export const getFollowUpsDue = async (now = new Date()) => { + try { + const user = await requireUser(); + return await prisma.outreach.findMany({ + where: { + repliedAt: null, + followUpDue: { lte: now }, + job: { userId: user.id }, + }, + include: { job: { select: { id: true, description: true } }, contact: true }, + orderBy: { followUpDue: "asc" }, + }); + } catch (error) { + return handleError(error, "Failed to fetch follow-ups. "); + } +}; + +export const markOutreachReplied = async (outreachId: string, outcome = "positive") => { + try { + await requireUser(); + const row = await prisma.outreach.update({ + where: { id: outreachId }, + data: { repliedAt: new Date(), outcome }, + }); + return { data: row, success: true }; + } catch (error) { + return handleError(error, "Failed to update outreach. "); + } +}; + +export const getOutreachList = async () => { + try { + const user = await requireUser(); + return await prisma.outreach.findMany({ + where: { OR: [{ job: { userId: user.id } }, { contact: { createdBy: user.id } }] }, + include: { + job: { select: { id: true, JobTitle: { select: { label: true } } } }, + contact: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + } catch (error) { + return handleError(error, "Failed to fetch outreach. "); + } +}; + +export const getJobOptions = async () => { + try { + const user = await requireUser(); + return await prisma.job.findMany({ + where: { userId: user.id }, + select: { id: true, JobTitle: { select: { label: true } }, Company: { select: { label: true } } }, + orderBy: { createdAt: "desc" }, + take: 200, + }); + } catch (error) { + return handleError(error, "Failed to fetch applications. "); + } +}; + +export const getContactOptions = async () => { + try { + const user = await requireUser(); + return await prisma.contact.findMany({ + where: { createdBy: user.id }, + select: { id: true, name: true, relationship: true }, + orderBy: { name: "asc" }, + take: 200, + }); + } catch (error) { + return handleError(error, "Failed to fetch contacts. "); + } +}; + +// Next N application deadlines (job dueDate + outreach follow-ups), merged. +export const getUpcomingDeadlines = async (limit = 10, now = new Date()) => { + try { + const user = await requireUser(); + const [jobs, outreach] = await Promise.all([ + prisma.job.findMany({ + where: { userId: user.id, dueDate: { gte: now } }, + select: { + id: true, + dueDate: true, + JobTitle: { select: { label: true } }, + Company: { select: { label: true } }, + }, + orderBy: { dueDate: "asc" }, + take: limit, + }), + prisma.outreach.findMany({ + where: { repliedAt: null, followUpDue: { gte: now }, job: { userId: user.id } }, + select: { + id: true, + followUpDue: true, + contact: { select: { name: true } }, + }, + orderBy: { followUpDue: "asc" }, + take: limit, + }), + ]); + const items = [ + ...jobs.map((j) => ({ + kind: "deadline" as const, + date: j.dueDate as Date, + label: `${j.JobTitle?.label ?? "Application"} — ${j.Company?.label ?? ""}`, + refId: j.id, + })), + ...outreach.map((o) => ({ + kind: "follow-up" as const, + date: o.followUpDue as Date, + label: `Follow up — ${o.contact?.name ?? "contact"}`, + refId: o.id, + })), + ] + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .slice(0, limit); + return items; + } catch (error) { + return handleError(error, "Failed to fetch deadlines. "); + } +}; diff --git a/src/actions/workspace/workspace.actions.ts b/src/actions/workspace/workspace.actions.ts new file mode 100644 index 000000000..d28b98199 --- /dev/null +++ b/src/actions/workspace/workspace.actions.ts @@ -0,0 +1,119 @@ +"use server"; +import prisma from "@/lib/db"; +import { handleError } from "@/lib/utils"; +import { requireUser } from "../shared"; +import { defaultStagesFor } from "@/lib/workspaces/defaults"; +import type { WorkspaceType } from "@prisma/client"; + +export const getWorkspaces = async () => { + try { + const user = await requireUser(); + return await prisma.workspace.findMany({ + where: { userId: user.id }, + include: { stages: { orderBy: { order: "asc" } } }, + orderBy: { createdAt: "asc" }, + }); + } catch (error) { + return handleError(error, "Failed to fetch workspaces. "); + } +}; + +export const createWorkspace = async (name: string, type: WorkspaceType) => { + try { + const user = await requireUser(); + if (!name.trim()) throw new Error("Workspace name cannot be empty."); + if (type !== "SCHOOL" && type !== "JOB") throw new Error("Unknown workspace type."); + const ws = await prisma.workspace.create({ + data: { + userId: user.id, + name: name.trim(), + type, + stages: { create: defaultStagesFor(type) }, + }, + include: { stages: true }, + }); + return { data: ws, success: true }; + } catch (error) { + return handleError(error, "Failed to create workspace. "); + } +}; + +// Idempotent: creates SCHOOL + JOB workspaces (with default stages) only if missing, +// then assigns any workspace-less applications to the JOB workspace so the +// switcher always changes what the Jobs list shows. +export const ensureDefaultWorkspaces = async () => { + try { + const user = await requireUser(); + const existing = await prisma.workspace.findMany({ where: { userId: user.id } }); + const types = new Set(existing.map((w) => w.type)); + const created = []; + for (const [type, name] of [["SCHOOL", "School"], ["JOB", "Jobs"]] as const) { + if (types.has(type)) continue; + created.push( + await prisma.workspace.create({ + data: { userId: user.id, name, type, stages: { create: defaultStagesFor(type) } }, + }) + ); + } + const all = [...existing, ...created]; + const jobWs = all.find((w) => w.type === "JOB"); + let backfilled = 0; + if (jobWs) { + const r = await prisma.job.updateMany({ + where: { userId: user.id, workspaceId: null }, + data: { workspaceId: jobWs.id }, + }); + backfilled = r.count; + } + return { data: all, backfilled, success: true }; + } catch (error) { + return handleError(error, "Failed to ensure workspaces. "); + } +}; + +export const deleteWorkspaceById = async (workspaceId: string) => { + try { + const user = await requireUser(); + const linked = await prisma.job.count({ where: { workspaceId, User: { id: user.id } } }); + if (linked > 0) throw new Error(`Workspace still has ${linked} application(s) linked.`); + const res = await prisma.workspace.delete({ where: { id: workspaceId, userId: user.id } }); + return { res, success: true }; + } catch (error) { + return handleError(error, "Failed to delete workspace. "); + } +}; + +export const setJobWorkspace = async (jobId: string, workspaceId: string | null) => { + try { + const user = await requireUser(); + if (workspaceId) { + const ws = await prisma.workspace.findFirst({ where: { id: workspaceId, userId: user.id } }); + if (!ws) throw new Error("Workspace not found."); + } + const job = await prisma.job.update({ + where: { id: jobId, userId: user.id }, + data: { workspaceId }, + }); + return { data: job, success: true }; + } catch (error) { + return handleError(error, "Failed to move application. "); + } +}; + +export const getWorkspaceCounts = async () => { + try { + const user = await requireUser(); + const groups = await prisma.job.groupBy({ + by: ["workspaceId"], + where: { userId: user.id }, + _count: { id: true }, + }); + const counts: Record = {}; + for (const g of groups) { + if (g.workspaceId) counts[g.workspaceId] = g._count.id; + } + return counts; + } catch { + return {} as Record; + } +}; diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 9050e3064..2e8af5815 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -31,6 +31,7 @@ import { handleFindJob } from "@/lib/mcp/tools/findJob"; import { handleUpdateJob } from "@/lib/mcp/tools/updateJob"; import { handleAddJobsBatch } from "@/lib/mcp/tools/addJobsBatch"; import { handleSaveMatchResultsBatch } from "@/lib/mcp/tools/saveMatchResultsBatch"; +import { handleLogOutreach, McpLogOutreachInputShape, McpLogOutreachSchema } from "@/lib/mcp/tools/logOutreach"; function isMcpEnabled(): boolean { const env = process.env.MCP_ENABLED; @@ -265,6 +266,31 @@ async function handler(req: Request): Promise { }, ); + server.tool( + "log_outreach", + MCP_TOOL_DESCRIPTIONS.log_outreach, + McpLogOutreachInputShape, + async (rawInput) => { + if (!auth.scopes.includes("jobs:write")) { + return { + content: [ + { type: "text" as const, text: "Insufficient scope. Required: jobs:write" }, + ], + }; + } + const parsed = McpLogOutreachSchema.safeParse(rawInput); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => i.message).join("; "); + return { + content: [ + { type: "text" as const, text: `Validation error: ${issues}` }, + ], + }; + } + return handleLogOutreach(parsed.data, userId); + }, + ); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless mode enableJsonResponse: true, // return plain JSON instead of SSE stream diff --git a/src/app/dashboard/contacts/page.tsx b/src/app/dashboard/contacts/page.tsx new file mode 100644 index 000000000..265938437 --- /dev/null +++ b/src/app/dashboard/contacts/page.tsx @@ -0,0 +1,58 @@ +import { Metadata } from "next"; +import { getContactsDirectory } from "@/actions/contact.actions"; +import { ContactProfileCard } from "@/components/osui/profile/contact-profile-card"; +import { CopyEmail } from "@/components/osui/contact/copy-email"; + +export const metadata: Metadata = { + title: "Directory | JobSync", +}; + +export default async function ContactsPage() { + const rows = ((await getContactsDirectory().catch(() => [])) ?? []) as unknown as { + id: string; + name: string; + title: string | null; + email: string | null; + phone: string | null; + relationship: string | null; + notes: string | null; + Company: { label: string } | null; + _count: { outreach: number }; + }[]; + return ( +
+
+
+

Directory

+

{`${rows.length} people · professors, recruiters, referees`}

+
+
+ {rows.length === 0 ? ( +

+ No contacts yet — they appear here when you log outreach or add them to an application. +

+ ) : ( +
+ {rows.map((c) => ( +
+ + {c.email ? : null} +

+ {c._count.outreach > 0 + ? `${c._count.outreach} outreach ${c._count.outreach === 1 ? "email" : "emails"} logged` + : "No outreach logged yet"} + {c.notes ? ` · ${c.notes.slice(0, 120)}` : ""} +

+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/dashboard/myjobs/page.tsx b/src/app/dashboard/myjobs/page.tsx index 1bbbfc26b..637b41879 100644 --- a/src/app/dashboard/myjobs/page.tsx +++ b/src/app/dashboard/myjobs/page.tsx @@ -11,8 +11,10 @@ export const metadata: Metadata = { title: "My Jobs | JobSync", }; +import { getActiveWorkspace } from "@/lib/workspaces/active"; + async function MyJobs() { - const [statuses, companies, titles, locations, sources, tags] = + const [statuses, companies, titles, locations, sources, tags, { active }] = await Promise.all([ getStatusList(), getAllCompanies(), @@ -20,6 +22,7 @@ async function MyJobs() { getAllJobLocations(), getJobSourceList(), getAllTags(), + getActiveWorkspace().catch(() => ({ workspaces: [], active: null })), ]); return (
@@ -30,6 +33,7 @@ async function MyJobs() { sources={sources} statuses={statuses} tags={tags ?? []} + activeWorkspaceId={active?.id ?? null} />
); diff --git a/src/app/dashboard/outreach/page.tsx b/src/app/dashboard/outreach/page.tsx new file mode 100644 index 000000000..7c0983ff8 --- /dev/null +++ b/src/app/dashboard/outreach/page.tsx @@ -0,0 +1,24 @@ +import { Metadata } from "next"; +import { getOutreachList, getJobOptions, getContactOptions } from "@/actions/outreach/outreach.actions"; +import OutreachLog from "@/components/outreach/OutreachLog"; + +export const metadata: Metadata = { + title: "Outreach | JobSync", +}; + +export default async function OutreachPage() { + const [rows, jobs, contacts] = await Promise.all([ + getOutreachList().catch(() => []), + getJobOptions().catch(() => []), + getContactOptions().catch(() => []), + ]); + return ( +
+ +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 06972245d..fc8c5a49b 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -11,6 +11,7 @@ import JobsActivityCard from "@/components/dashboard/JobsActivityCard"; import JobsApplied from "@/components/dashboard/JobsAppliedCard"; import RecentCardToggle from "@/components/dashboard/RecentCardToggle"; import WeeklyBarChartToggle from "@/components/dashboard/WeeklyBarChartToggle"; +import { FollowUpsCard, DeadlinesCard } from "@/components/dashboard/OutreachCards"; import { Metadata } from "next"; @@ -48,6 +49,8 @@ export default async function Dashboard() { { label: "30d", summary: summary30Days }, ]} /> + + null); + const [{ workspaces, active }, counts] = await Promise.all([ + getActiveWorkspace().catch(() => ({ workspaces: [], active: null })), + getWorkspaceCounts().catch(() => ({})), + ]); return (
@@ -59,6 +68,11 @@ async function Header() {

JobSync - Job Search Assistant

+ ({ id: w.id, name: w.name, type: w.type }))} + activeId={active?.id ?? null} + counts={counts} + />
{/* - {/* App branding */} +
-

JobSync

-

- Track your job search, powered by AI +

JobSync

+

+ School + job applications, one tracker

- {/* Tab toggle */} -
- - +
+ + router.push(value === "Create Account" ? "/signup" : "/signin")) as never} + />
- {/* Form card */} -
- {mode === "signin" ? ( - <> -
-

Welcome back

-

- Enter your credentials to access your account -

-
- - - ) : ( - <> -
-

Get started

-

- Create a free account to start tracking your applications -

-
- - - )} -
+ {mode === "signin" ? : }
); } diff --git a/src/components/auth/SigninForm.tsx b/src/components/auth/SigninForm.tsx index df715f255..95ef575a3 100644 --- a/src/components/auth/SigninForm.tsx +++ b/src/components/auth/SigninForm.tsx @@ -1,145 +1,38 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { authenticate } from "@/actions/auth.actions"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { SigninFormSchema } from "@/models/signinForm.schema"; -import Loading from "../Loading"; -import { Eye, EyeOff } from "lucide-react"; +import { LoginForm } from "../osui/forms/login-form"; +import { authenticate } from "@/actions/auth.actions"; +// osui login form, wired to the existing credentials action. function SigninForm() { const [isPending, startTransition] = useTransition(); - - const form = useForm>({ - resolver: zodResolver(SigninFormSchema), - mode: "onChange", - defaultValues: { - email: "", - password: "", - }, - }); - const [errorMessage, setError] = useState(""); - const [showPassword, setShowPassword] = useState(false); const router = useRouter(); - const onSubmit = async (data: z.infer) => { - startTransition(async () => { - setError(""); - const formData = new FormData(); - formData.set("email", data.email); - formData.set("password", data.password); - const errorResponse = await authenticate("", formData); - if (errorResponse) { - setError(errorResponse); - } else { - router.push("/dashboard"); - } - }); - }; - return ( - <> -
- -
-
- ( - - Email - - - - - - )} - /> -
-
- ( - - Password - -
- - -
-
- -
- )} - /> -
- -
- {errorMessage && ( - <> -

{errorMessage}

- - )} -
-
-
- - + { + startTransition(async () => { + setError(""); + const formData = new FormData(); + formData.set("email", values.email); + formData.set("password", values.password); + const errorResponse = await authenticate("", formData); + if (errorResponse) setError(errorResponse); + else router.push("/dashboard"); + }); + }} + /> ); } diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx index 7e4e985f3..3359e4225 100644 --- a/src/components/auth/SignupForm.tsx +++ b/src/components/auth/SignupForm.tsx @@ -1,156 +1,42 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { signup, authenticate } from "@/actions/auth.actions"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { SignupFormSchema } from "@/models/signupForm.schema"; -import Loading from "../Loading"; -import { Eye, EyeOff } from "lucide-react"; +import { SignupForm as OsuiSignupForm } from "../osui/forms/signup-form"; +import { signup, authenticate } from "@/actions/auth.actions"; +// osui signup form, wired to the existing register + login actions. function SignupForm() { const [isPending, startTransition] = useTransition(); - - const form = useForm>({ - resolver: zodResolver(SignupFormSchema), - mode: "onChange", - defaultValues: { - name: "", - email: "", - password: "", - }, - }); - const [errorMessage, setError] = useState(""); - const [showPassword, setShowPassword] = useState(false); const router = useRouter(); - const onSubmit = async (data: z.infer) => { - startTransition(async () => { - setError(""); - const result = await signup(data); - if (result.error) { - setError(result.error); - return; - } - const formData = new FormData(); - formData.set("email", data.email); - formData.set("password", data.password); - const authError = await authenticate("", formData); - if (authError) { - setError(authError); - } else { - router.push("/dashboard"); - } - }); - }; - return ( -
- -
-
- ( - - Full Name - - - - - - )} - /> -
-
- ( - - Email - - - - - - )} - /> -
-
- ( - - Password -
- - - - -
- -
- )} - /> -
- -
- {errorMessage && ( -

{errorMessage}

- )} -
-
-
- + { + startTransition(async () => { + setError(""); + const result = await signup(values); + if (result.error) { + setError(result.error); + return; + } + const formData = new FormData(); + formData.set("email", values.email); + formData.set("password", values.password); + const authError = await authenticate("", formData); + if (authError) setError(authError); + else router.push("/dashboard"); + }); + }} + /> ); } diff --git a/src/components/dashboard/ActivityCalendar.tsx b/src/components/dashboard/ActivityCalendar.tsx index 72df41096..29f8f40ed 100644 --- a/src/components/dashboard/ActivityCalendar.tsx +++ b/src/components/dashboard/ActivityCalendar.tsx @@ -1,21 +1,36 @@ "use client"; -import { useState } from "react"; -import { ResponsiveCalendar } from "@nivo/calendar"; +import { useMemo, useState } from "react"; import { format, parseISO } from "date-fns"; -import { useTheme } from "next-themes"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; -// make sure parent container have a defined height when using -// responsive component, otherwise height will be 0 and -// no chart will be rendered. -// website examples showcase many properties, -// you'll often use just a few of them. +import { cn } from "@/lib/utils"; +import { SelectFieldInput } from "../osui/inputs/select-field-input"; + +// GitHub-style heatmap, hand-rolled (no chart lib): Sunday-start columns, +// neutral-ink intensity scale on paper. +function cellFill(value: number) { + if (value <= 0) return "bg-neutral-100"; + if (value === 1) return "bg-neutral-300"; + if (value === 2) return "bg-neutral-500"; + if (value === 3) return "bg-neutral-700"; + return "bg-neutral-900"; +} + +type Day = { date: Date; iso: string; inYear: boolean }; + +function yearGrid(year: string): Day[] { + const y = Number(year); + const jan1 = new Date(y, 0, 1); + const days: Day[] = []; + // Leading blanks so Jan 1 lands on its weekday column (Sunday start). + for (let i = 0; i < jan1.getDay(); i++) { + days.push({ date: new Date(NaN), iso: "", inYear: false }); + } + for (let d = new Date(y, 0, 1); d.getFullYear() === y; d.setDate(d.getDate() + 1)) { + const iso = `${y}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + days.push({ date: new Date(d), iso, inYear: true }); + } + return days; +} + export default function ActivityCalendar({ years, dataByYear, @@ -23,89 +38,60 @@ export default function ActivityCalendar({ years: string[]; dataByYear: Record; }) { - const { resolvedTheme } = useTheme(); const [year, setYear] = useState(years.at(-1)); - const borderColor = resolvedTheme === "light" ? "#ffffff" : "#0e1117"; const data = dataByYear[year ?? ""] ?? []; - const hoursMap = Object.fromEntries( - data.map((d) => [d.day, d.hours ?? 0]), - ); + const byDay = useMemo(() => { + const map = new Map(); + for (const d of data) map.set(d.day, { value: d.value ?? 0, hours: d.hours ?? 0 }); + return map; + }, [dataByYear, year]); + const days = useMemo(() => yearGrid(year ?? String(new Date().getFullYear())), [year]); + return ( - - -
- Activity Calendar - -
-
- - { - const hours = hoursMap[day.day] ?? 0; - return ( -
-
{format(parseISO(day.day), "EEE MMM d, yyyy")}
-
{day.value} job{Number(day.value) === 1 ? "" : "s"} applied
-
{hours} hr{hours === 1 ? "" : "s"} activity
-
- ); - }} - theme={{ - text: { - fill: "#9ca3af", - }, - tooltip: { - container: { - background: "#1e293b", - color: "#fff", - }, - }, - }} - legends={[ - { - anchor: "bottom-right", - direction: "row", - translateY: 36, - itemCount: 4, - itemWidth: 42, - itemHeight: 36, - itemsSpacing: 14, - itemDirection: "right-to-left", - }, - ]} +
+
+

Activity Calendar

+ ({ value: y, label: y }))} + containerClassName="w-[130px]" /> - - +
+
+
+ {days.map((d, i) => + !d.inYear ? ( + + ) : ( + (() => { + const entry = byDay.get(d.iso) ?? { value: 0, hours: 0 }; + const tip = `${format(d.date, "EEE MMM d, yyyy")} — ${entry.value} job${entry.value === 1 ? "" : "s"} applied, ${entry.hours} hr${entry.hours === 1 ? "" : "s"} activity`; + return ( + + {tip} + + ); + })() + ), + )} +
+
+
); } + +// Re-exported for tests that import the tooltip wording indirectly. +export function dayTooltip(dayIso: string, value: number, hours: number) { + return `${format(parseISO(dayIso), "EEE MMM d, yyyy")} — ${value} job${value === 1 ? "" : "s"} applied, ${hours} hr${hours === 1 ? "" : "s"} activity`; +} diff --git a/src/components/dashboard/JobsActivityCard.tsx b/src/components/dashboard/JobsActivityCard.tsx index df64b5c12..06aa79e44 100644 --- a/src/components/dashboard/JobsActivityCard.tsx +++ b/src/components/dashboard/JobsActivityCard.tsx @@ -1,11 +1,10 @@ "use client"; -import { RefObject, useMemo, useRef } from "react"; -import { Pie, PieSvgProps } from "@nivo/pie"; -import { animated } from "@react-spring/web"; +import { useMemo } from "react"; +import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from "recharts"; import { useTheme } from "next-themes"; import { TrendingDown, TrendingUp } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; import { JobsActivitySummary, TopActivityType, @@ -13,14 +12,11 @@ import { import { useElementWidth } from "@/hooks/useElementWidth"; import { usePersistedTabIndex } from "@/hooks/usePersistedTabIndex"; import { APP_CONSTANTS } from "@/lib/constants"; -import { cn } from "@/lib/utils"; +import { SoftPillButton } from "../osui/buttons/soft-pill-button"; import { - ARC_LABEL_TEXT_COLOR, - arcLabelLines, buildDonutSlices, CHART_HEIGHT, donutLayout, - DonutSlice, } from "./jobsActivityChart"; interface JobsActivityCardProps { @@ -30,49 +26,10 @@ interface JobsActivityCardProps { }[]; } -type ArcLinkLabelProps = Parameters< - NonNullable["arcLinkLabelComponent"]> ->[0]; - // Hole the total needs before the hours, jobs and trend lines all clear the // ring; under it the trend goes and the other two step down a size. const FULL_TOTAL_HOLE = 96; -// nivo renders an arc link label as one , so stacking the hours under -// the name needs a custom component rather than a label formatter. -// The budget arrives by ref because a new component type would remount the -// whole label subtree on every frame the card resizes. -function makeArcLinkLabel(maxChars: RefObject) { - return function ArcLinkLabel({ datum, style }: ArcLinkLabelProps) { - const [name, hours] = arcLabelLines(datum.data, maxChars.current); - - return ( - - - - - {name} - - - {hours} - - - - ); - }; -} - export default function JobsActivityCard({ data }: JobsActivityCardProps) { const [activeIndex, selectTab] = usePersistedTabIndex( APP_CONSTANTS.DASHBOARD_JOBS_ACTIVITY_STORAGE_KEY, @@ -90,161 +47,132 @@ export default function JobsActivityCard({ data }: JobsActivityCardProps) { otherHours, totalHours, } = current.summary; - const slices = buildDonutSlices( - topActivities, - otherHours, - theme, - otherActivities, + const slices = useMemo( + () => buildDonutSlices(topActivities, otherHours, theme, otherActivities), + [topActivities, otherHours, theme, otherActivities], ); const layout = donutLayout(chartWidth); const roomyTotal = layout.holeDiameter >= FULL_TOTAL_HOLE; - const maxLabelChars = useRef(layout.maxLabelChars); - maxLabelChars.current = layout.maxLabelChars; - const arcLinkLabelComponent = useMemo( - () => makeArcLinkLabel(maxLabelChars), - [], - ); return ( - - -
- - Jobs & Activity - -
- {data.map((item, index) => ( - - ))} -
+
+
+

+ Jobs & Activity +

+
+ {data.map((item, index) => ( + selectTab(index)} + > + {item.label} + + ))}
- - -
- {slices.length === 0 ? ( -
-
-

- No activities recorded -

-
- ) : ( - chartWidth > 0 && ( - ( -
+
+ {slices.length === 0 ? ( +
+
+

+ No activities recorded +

+
+ ) : ( + chartWidth > 0 && ( +
+ + + { + const datum = payload?.[0]?.payload; + if (!active || !datum) return null; + return ( +
+ {datum.label} — {datum.value}h + {datum.breakdown?.map((activity: TopActivityType) => ( +
+ {activity.label} — {activity.hours}h +
+ ))} +
+ ); }} + /> + - {datum.data.label} — {datum.value}h - {datum.data.breakdown?.map((activity: TopActivityType) => ( -
- {activity.label} — {activity.hours}h -
+ {slices.map((slice) => ( + ))} -
- )} - /> - ) - )} -
+ + + +
+ ) + )} +
+ + {totalHours}h + + + {jobsApplied} {jobsApplied === 1 ? "job" : "jobs"} + + {jobsTrend !== 0 && roomyTotal && ( 0 ? "text-emerald-600" : "text-rose-600", )} > - {totalHours}h - - 0 ? ( + + ) : ( + )} - > - {jobsApplied} {jobsApplied === 1 ? "job" : "jobs"} + {Math.abs(jobsTrend)}% - {jobsTrend !== 0 && roomyTotal && ( + )} +
+
+ {slices.length > 0 && ( +
    + {slices.map((slice) => ( +
  • 0 ? "text-emerald-600" : "text-rose-600", - )} - > - {jobsTrend > 0 ? ( - - ) : ( - - )} - {Math.abs(jobsTrend)}% + className="inline-block h-2.5 w-2.5 shrink-0 rounded-[2px]" + style={{ background: slice.color }} + /> + {slice.label} + {slice.value}h + + {slice.label}:{slice.value}:{slice.color} + {slice.breakdown?.length + ? `:${slice.breakdown.map((a) => `${a.label}=${a.hours}`).join(",")}` + : ""} - )} -
-
- - + + ))} + + )} +
); } diff --git a/src/components/dashboard/OutreachCards.tsx b/src/components/dashboard/OutreachCards.tsx new file mode 100644 index 000000000..23a515211 --- /dev/null +++ b/src/components/dashboard/OutreachCards.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; +import { MinimalAgendaWidget } from "@/components/osui/widgets/minimal-agenda-widget"; +import { EventCountdownCard } from "@/components/osui/calender/event-countdown-card"; +import { getFollowUpsDue, getUpcomingDeadlines } from "@/actions/outreach/outreach.actions"; + +function fmt(d: Date | string) { + return new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +// osui widgets wired to live outreach data. +export async function FollowUpsCard() { + const rows = (await getFollowUpsDue().catch(() => [])) as unknown as { + id: string; + followUpDue: Date | null; + contact?: { name: string } | null; + }[]; + return ( +
+ ({ + time: r.followUpDue ? fmt(r.followUpDue) : "due", + title: r.contact?.name ?? "Contact", + done: false, + }))} + /> + + Open outreach → + +
+ ); +} + +export async function DeadlinesCard() { + const items = (await getUpcomingDeadlines(8).catch(() => [])) as unknown as { + kind: string; + date: Date; + label: string; + refId: string; + }[]; + const next = items[0]; + if (!next) { + return ( +
+

Deadlines

+

No deadlines on the radar.

+
+ ); + } + return ( + + ); +} diff --git a/src/components/dashboard/WeeklyBarChartToggle.tsx b/src/components/dashboard/WeeklyBarChartToggle.tsx index 3068665f6..57ddd8066 100644 --- a/src/components/dashboard/WeeklyBarChartToggle.tsx +++ b/src/components/dashboard/WeeklyBarChartToggle.tsx @@ -1,19 +1,27 @@ "use client"; import { useState, useEffect, useMemo } from "react"; -import { useTheme } from "next-themes"; -import { ResponsiveBar } from "@nivo/bar"; -import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, +} from "recharts"; import { cn } from "@/lib/utils"; import { APP_CONSTANTS } from "@/lib/constants"; import { usePersistedTabIndex } from "@/hooks/usePersistedTabIndex"; +import { SoftPillButton } from "../osui/buttons/soft-pill-button"; import { - SERIES_COLORS, - OTHER_COLOR, OTHER_SLICE_ID, otherBucketLabel, } from "./jobsActivityChart"; +// osui ink scale for series (light-first, paper background). +const INK_SERIES = ["#262626", "#525252", "#737373", "#a3a3a3", "#d4d4d4"]; +const OTHER_INK = "#e5e5e5"; + type ChartConfig = { label: string; data: any[]; @@ -27,6 +35,29 @@ type WeeklyBarChartToggleProps = { charts: ChartConfig[]; }; +function TipLabel({ + id, + current, +}: { + id: string; + current: ChartConfig; +}) { + if (current.tooltipLabel) return <>{current.tooltipLabel(id)}; + if (id === "value") + return ( + <> + {current.axisLeftLegend + .toLowerCase() + .replace(/\(.*\)/, "") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase())} + + ); + if (id === OTHER_SLICE_ID) + return <>{otherBucketLabel(current.keys.filter((key) => key !== OTHER_SLICE_ID))}; + return <>{id}; +} + export default function WeeklyBarChartToggle({ charts, }: WeeklyBarChartToggleProps) { @@ -35,20 +66,17 @@ export default function WeeklyBarChartToggle({ charts.map((chart) => chart.label), ); const [isSmallScreen, setIsSmallScreen] = useState(false); - const { resolvedTheme } = useTheme(); - const theme = resolvedTheme === "light" ? "light" : "dark"; const current = charts[activeIndex]; - const activityColors = useMemo( + const stacked = current.groupMode === "stacked"; + const barColors = useMemo( () => new Map( current.keys.map((key, index) => [ key, - key === OTHER_SLICE_ID - ? OTHER_COLOR[theme] - : SERIES_COLORS[theme][index], + key === OTHER_SLICE_ID ? OTHER_INK : INK_SERIES[index % INK_SERIES.length], ]), ), - [current.keys, theme], + [current.keys], ); useEffect(() => { @@ -69,21 +97,6 @@ export default function WeeklyBarChartToggle({ return newItem; }); - const isJobsChart = current.label !== "Activities"; - const maxValue = isJobsChart - ? Math.max( - 0, - ...roundedData.flatMap((item) => - current.keys.map((key) => - typeof item[key] === "number" ? item[key] : 0, - ), - ), - ) - : undefined; - const intTickValues = isJobsChart - ? Array.from({ length: (maxValue as number) + 1 }, (_, i) => i) - : undefined; - const totalHours = current.label === "Activities" ? roundedData.reduce( @@ -99,166 +112,91 @@ export default function WeeklyBarChartToggle({ : null; return ( - - -
-
- - Weekly {current.label} - - {totalHours !== null && ( - - {totalHours.toFixed(1)} hrs - - )} -
-
- {charts.map((chart, index) => ( - - ))} -
+
+
+
+

+ Weekly {current.label} +

+ {totalHours !== null && ( + + {totalHours.toFixed(1)} hrs + + )} +
+
+ {charts.map((chart, index) => ( + selectTab(index)} + > + {chart.label} + + ))}
- +
- -
- activityColors.get(String(bar.id)) ?? "#94a3b8" - : "#2a7ef0" - } - enableTotals={current.groupMode === "stacked" ? true : false} - valueFormat={(value) => - current.label === "Activities" - ? value.toFixed(1) - : value.toFixed(0) - } - theme={{ - text: { - fill: "#9ca3af", - }, - tooltip: { - container: { - background: "#1e293b", - color: "#fff", - }, - }, - }} - axisTop={null} - axisRight={null} - enableGridX={false} - enableGridY={false} - enableLabel={true} - labelSkipHeight={1} - labelTextColor={{ - from: "color", - modifiers: [["darker", 1.6]], - }} - axisBottom={{ - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - legendPosition: "middle", - legendOffset: 32, - truncateTickAt: 0, - format: isSmallScreen - ? (value: string) => value.split(", ")[1] ?? value - : undefined, - }} - axisLeft={{ - tickSize: 5, - tickPadding: 5, - tickRotation: 0, - legend: current.axisLeftLegend, - legendPosition: "middle", - legendOffset: -40, - truncateTickAt: 0, - tickValues: intTickValues, - }} - motionConfig="gentle" - tooltip={({ id, value, indexValue, color, data }) => ( -
-
{indexValue}
- - {current.tooltipLabel - ? current.tooltipLabel(String(id)) - : String(id) === "value" - ? current.axisLeftLegend - .toLowerCase() - .replace(/\(.*\)/, "") - .trim() - .replace(/\b\w/g, (c) => c.toUpperCase()) - : String(id) === OTHER_SLICE_ID - ? otherBucketLabel( - current.keys.filter((key) => key !== OTHER_SLICE_ID), - ) - : String(id)} - :{" "} - - {current.label === "Activities" - ? Number(value).toFixed(1) - : value} - - {String(id) === OTHER_SLICE_ID && - (data as any).otherBreakdown?.length > 0 && - (data as any).otherBreakdown.map( - (activity: { label: string; hours: number }) => ( -
- {activity.label} – {activity.hours}h +
+ + + { + const part = String(value).split(", ")[1]; + return isSmallScreen ? (part ?? value) : value; + }} + /> + + { + if (!active || !payload?.length) return null; + return ( +
+
{label}
+ {payload.map((p) => ( +
+ + :{" "} + + {current.label === "Activities" + ? Number(p.value).toFixed(1) + : p.value} +
- ), - )} -
- )} - /> -
- - + ))} +
+ ); + }} + /> + {current.keys.map((key) => ( + + ))} + + +
+
); } diff --git a/src/components/myjobs/AddJob.tsx b/src/components/myjobs/AddJob.tsx index a6f5704b1..f9e9adc52 100644 --- a/src/components/myjobs/AddJob.tsx +++ b/src/components/myjobs/AddJob.tsx @@ -40,12 +40,14 @@ import { import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import SelectFormCtrl from "../Select"; import { DatePicker } from "../DatePicker"; -import { SALARY_RANGES } from "@/lib/data/salaryRangeData"; +import { SALARY_RANGES, FUNDING_STATUSES } from "@/lib/data/salaryRangeData"; import TiptapEditor from "../TiptapEditor"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { redirect, useRouter, useSearchParams } from "next/navigation"; import { Combobox } from "../ComboBox"; +import { SelectFieldInput } from "../osui/inputs/select-field-input"; +import { getWorkspaces } from "@/actions/workspace/workspace.actions"; import { NotesCollapsibleSection } from "./NotesCollapsibleSection"; import { CoverLetter, Resume } from "@/models/profile.model"; import CreateResume from "../profile/CreateResume"; @@ -70,6 +72,7 @@ type AddJobProps = { initialOpen?: boolean; hideTrigger?: boolean; redirectPath?: string; + defaultWorkspaceId?: string | null; }; export function AddJob({ @@ -84,6 +87,7 @@ export function AddJob({ initialOpen, hideTrigger, redirectPath, + defaultWorkspaceId, }: AddJobProps) { const router = useRouter(); const searchParams = useSearchParams(); @@ -91,6 +95,7 @@ export function AddJob({ const [resumeDialogOpen, setResumeDialogOpen] = useState(false); const [resumes, setResumes] = useState([]); const [coverLetters, setCoverLetters] = useState([]); + const [workspaceOpts, setWorkspaceOpts] = useState<{ value: string; label: string }[]>([]); const [availableTags, setAvailableTags] = useState(tags); const [isPending, startTransition] = useTransition(); @@ -121,6 +126,8 @@ export function AddJob({ dueDate: addDays(new Date(), 3), status: jobStatuses[0]?.id, salaryRange: "", + fundingStatus: "", + workspaceId: defaultWorkspaceId ?? "", jobUrl: "", jobDescription: "N/A", location: locations.find((l) => l.id === lastLocationId)?.id, @@ -172,6 +179,8 @@ export function AddJob({ status: editJob.Status.id, dueDate: editJob.dueDate, salaryRange: editJob.salaryRange ?? "", + fundingStatus: (editJob as { fundingStatus?: string | null }).fundingStatus ?? "", + workspaceId: (editJob as { workspaceId?: string | null }).workspaceId ?? "", jobDescription: editJob.description, applied: editJob.applied, jobUrl: editJob.jobUrl ?? "", @@ -196,6 +205,16 @@ export function AddJob({ if (!dialogOpen) return; loadResumes(); loadCoverLetters(); + getWorkspaces() + .then((list) => + setWorkspaceOpts( + ((list ?? []) as { id: string; name: string }[]).map((w) => ({ + value: w.id, + label: w.name, + })) + ) + ) + .catch(() => null); }, [dialogOpen, loadResumes, loadCoverLetters]); const setNewResumeId = (id: string) => { @@ -539,6 +558,26 @@ export function AddJob({ />
+ {/* Workspace (osui) */} +
+ ( + + + + + )} + /> +
+ {/* Salary Range */}
+ {/* Funding Status (School workspace) */} +
+ ( + + Funding Status + + + + )} + /> +
+ {/* Resume */}
void; }; -export function JobActionsMenu({ - job, - jobStatuses, - editJob, - onChangeJobStatus, - onAddNote, - onDeleteJob, -}: JobActionsMenuProps) { - const router = useRouter(); +// osui kebab-actions-dropdown (status changes live in the table's Status pill). +const ITEMS: readonly KebabMenuItem[] = [ + { id: "details", label: "View details", icon: ListCollapse }, + { id: "edit", label: "Edit", icon: Pencil }, + { id: "note", label: "Add a note", icon: StickyNote }, + { id: "separator-delete", separator: true }, + { id: "delete", label: "Delete", icon: Trash, danger: true }, +]; +export function JobActionsMenu({ job, editJob, onAddNote, onDeleteJob }: JobActionsMenuProps) { + const router = useRouter(); return ( - - - - - - Actions - - router.push(`/dashboard/myjobs/${job?.id}`)} - > - - View Details - - editJob(job.id)} - > - - Edit Job - - onAddNote(job.id)} - > - - Add a Note - - - - - - Change status - - - - onChangeJobStatus(job.id, status)} - /> - - - - - onDeleteJob(job.id)} - > - - Delete - - - - + { + if (item.id === "details") router.push(`/dashboard/myjobs/${job?.id}`); + else if (item.id === "edit") editJob(job.id); + else if (item.id === "note") onAddNote(job.id); + else if (item.id === "delete") onDeleteJob(job.id); + }} + /> ); } diff --git a/src/components/myjobs/JobCard.tsx b/src/components/myjobs/JobCard.tsx index eb77b8493..506980208 100644 --- a/src/components/myjobs/JobCard.tsx +++ b/src/components/myjobs/JobCard.tsx @@ -2,7 +2,6 @@ import { Calendar, MapPin, PlusCircle, StickyNote } from "lucide-react"; import { format } from "date-fns"; import Link from "next/link"; -import { Badge } from "../ui/badge"; import { CircularScore } from "@/components/CircularScore"; import { JobResponse, JobStatus } from "@/models/job.model"; import { JobStatusBadge } from "./JobStatusBadge"; @@ -30,7 +29,7 @@ export function JobCard({ const notesCount = job._count?.Notes ?? 0; return ( -
+
{job.JobTitle?.label} {notesCount > 0 && ( - + {notesCount} - + )}
-

+

{job.Company?.label}

@@ -65,7 +61,7 @@ export function JobCard({ )}
-
+
{job.Location?.label && ( @@ -84,7 +80,7 @@ export function JobCard({ )}
-
+
)} + + Outreach + {((job as unknown as { outreach?: unknown[] }).outreach?.length ?? 0) > 0 && ( + + {(job as unknown as { outreach: unknown[] }).outreach.length} + + )} + @@ -275,6 +284,9 @@ function JobDetails({ /> + + +
{ @@ -128,7 +130,7 @@ function JobsContainer({ return ( <> - +
- +
{initialLoading && } {jobs.length > 0 && (viewMode === "cards" ? ( @@ -184,13 +187,12 @@ function JobsContainer({ {jobs.length < totalJobs && (
{loadingMore && ( - + )}
)} - - - +
+
void; }; +// osui tasks-table structure (paper/ink, neutral) with live application rows. function MyJobsTable({ jobs, jobStatuses, @@ -47,104 +39,102 @@ function MyJobsTable({ }; return ( - - - - - - Company Logo - - Date Applied - Title - Company - Location - Status - Match - Source - - Actions - - - - - {jobs.map((job: JobResponse) => { - return ( - - - - - - {job.appliedDate ? format(job.appliedDate, "PP") : "N/A"} - - -
- - {job.JobTitle?.label} - - {(job._count?.Notes ?? 0) > 0 && ( - - - {job._count!.Notes} - - )} -
-
- - {job.Company?.label} - - - {job.Location?.label} - - - - - - {job.matchScore != null ? ( - - ) : ( - +
+
+
+

Applications

+

{`${jobs.length} tracked`}

+
+
+
+ + + + + + + + + + + {jobs.map((job: JobResponse) => ( + + +
+ RoleOrganizationAppliedStageDue +
+ + +
+ + {job.JobTitle?.label} + + {(job._count?.Notes ?? 0) > 0 && ( + + + {job._count!.Notes} + )} - - - {job.JobSource?.label} - - - - - - ); - })} - -
+
+

+ {job.Company?.label} +

+ + + {job.Company?.label} + + + {job.appliedDate ? format(job.appliedDate, "PP") : "—"} + + + + + + + {job.dueDate ? format(job.dueDate, "PP") : "—"} + + {job.matchScore != null ? ( + + + + ) : ( + + + + )} + + + + + + ))} + + deleteJob(jobIdToDelete)} /> - +
); } diff --git a/src/components/myjobs/job-details/JobOutreachTab.tsx b/src/components/myjobs/job-details/JobOutreachTab.tsx new file mode 100644 index 000000000..ad48068fa --- /dev/null +++ b/src/components/myjobs/job-details/JobOutreachTab.tsx @@ -0,0 +1,57 @@ +import Link from "next/link"; +import { Card } from "../../ui/card"; +import { JobTabEmptyState } from "./JobTabEmptyState"; +import { Mails } from "lucide-react"; + +export type JobOutreachRow = { + id: string; + subject: string | null; + paperCited: string | null; + sentAt: Date | string | null; + followUpDue: Date | string | null; + repliedAt: Date | string | null; + outcome: string | null; + contact?: { name: string } | null; +}; + +function fmt(d: Date | string | null) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} + +export function JobOutreachTab({ outreach }: { outreach: JobOutreachRow[] }) { + if (!outreach || outreach.length === 0) { + return ( + + (window.location.href = "/dashboard/outreach")} + /> + + ); + } + return ( + +
    + {outreach.map((o) => ( +
  • +
    + {o.contact?.name ?? o.subject ?? "Outreach"} + + {o.repliedAt ? `replied (${o.outcome ?? "—"})` : `follow up ${fmt(o.followUpDue)}`} + +
    + {o.paperCited &&

    Paper: {o.paperCited}

    } +

    Sent {fmt(o.sentAt)}

    +
  • + ))} +
+ + Open outreach log → + +
+ ); +} diff --git a/src/components/myjobs/job-details/JobSummaryCard.tsx b/src/components/myjobs/job-details/JobSummaryCard.tsx index c082223c7..50d1d6c01 100644 --- a/src/components/myjobs/job-details/JobSummaryCard.tsx +++ b/src/components/myjobs/job-details/JobSummaryCard.tsx @@ -63,6 +63,12 @@ export function JobSummaryCard({

Salary Range

{job.salaryRange || "-"}

+
+

Funding

+

+ {(job as { fundingStatus?: string | null }).fundingStatus || "-"} +

+

Source

{job.JobSource?.label ?? "-"}

diff --git a/src/components/myjobs/jobs-container/JobsToolbar.tsx b/src/components/myjobs/jobs-container/JobsToolbar.tsx index bde2e46af..cf4339cdf 100644 --- a/src/components/myjobs/jobs-container/JobsToolbar.tsx +++ b/src/components/myjobs/jobs-container/JobsToolbar.tsx @@ -1,20 +1,10 @@ "use client"; -import { File, ListFilter, RefreshCw, X } from "lucide-react"; -import { CardHeader, CardTitle } from "../../ui/card"; -import { Button } from "../../ui/button"; -import { SearchInput } from "../../SearchInput"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "../../ui/select"; +import { File, RefreshCw, X } from "lucide-react"; +import { SearchInput as OsuiSearchInput } from "../../osui/inputs/search-input"; +import { SelectFieldInput } from "../../osui/inputs/select-field-input"; +import { SegmentedToggleButton } from "../../osui/buttons/segmented-toggle-button"; +import { SoftPillButton } from "../../osui/buttons/soft-pill-button"; import { RecordsCount } from "../../RecordsCount"; -import { JobsViewToggle } from "../JobsViewToggle"; import { AddJob } from "../AddJob"; import { Company, @@ -27,8 +17,9 @@ import { Tag, } from "@/models/job.model"; -// Presentational: the Jobs card header — title/count, active filter chips, -// view toggle, search, filter select, export and Add Job. +// Presentational: the Applications card header on osui components — +///title/count, filter chips, segmented view toggle, search, filter, +// export and Add Job. export function JobsToolbar({ jobsCount, totalJobs, @@ -58,6 +49,7 @@ export function JobsToolbar({ editJob, resetEditJob, addJobInitialOpen, + activeWorkspaceId, }: { jobsCount: number; totalJobs: number; @@ -87,106 +79,77 @@ export function JobsToolbar({ editJob: JobResponse | null; resetEditJob: () => void; addJobInitialOpen: boolean; + activeWorkspaceId?: string | null; }) { + const chip = (label: string, onClear: () => void) => ( + + ); return ( - +
- Jobs +

Applications

{!initialLoading && totalJobs > 0 && ( )}
-
- - {companyLabel && ( - - )} - {titleLabel && ( - - )} - {locationLabel && ( - - )} - {sourceLabel && ( - - )} - - + onSearchTermChange(v)} + onClear={() => onSearchTermChange("")} placeholder="Search jobs..." + label="" /> - - + Export +
- +
); } diff --git a/src/components/myjobs/jobs-container/useJobsList.ts b/src/components/myjobs/jobs-container/useJobsList.ts index 34a3c4b1a..ef4214afd 100644 --- a/src/components/myjobs/jobs-container/useJobsList.ts +++ b/src/components/myjobs/jobs-container/useJobsList.ts @@ -18,12 +18,14 @@ export function useJobsList({ titleFilter, locationFilter, sourceFilter, + workspaceId, }: { companyFilter: string | null; appliedFilter: boolean; titleFilter: string | null; locationFilter: string | null; sourceFilter: string | null; + workspaceId?: string | null; }) { const { jobWrites } = useAgentChat(); const [jobs, setJobs] = useState([]); @@ -68,6 +70,7 @@ export function useJobsList({ titleFilter || undefined, locationFilter || undefined, sourceFilter || undefined, + workspaceId || undefined, ); if (success && data) { setJobs((prev) => (page === 1 ? data : [...prev, ...data])); @@ -86,6 +89,7 @@ export function useJobsList({ titleFilter, locationFilter, sourceFilter, + workspaceId, ], ); diff --git a/src/components/osui/README.md b/src/components/osui/README.md new file mode 100644 index 000000000..7bcf1488e --- /dev/null +++ b/src/components/osui/README.md @@ -0,0 +1,12 @@ +# osui — opensourceui.in components (MIT) + +Every `.tsx` in this tree (except `workspace-switcher-client.tsx`) is copied +**verbatim** from [opensourceui.in](https://opensourceui.in) +([repo](https://github.com/bidyut10/opensourceui), MIT — © Bidyut Kundu), +then adapted only for imports: + +- `@/lib/cn` → `@/lib/utils` (repo already has `cn()` from `clsx` + `tailwind-merge`) +- `workspace-switcher-dropdown.tsx`: brand icons → lucide (`GraduationCap`, `Briefcase`) +- `contact/copy-email.tsx`: `siteConfig.author.email` default → `""` (prop-supplied) + +No other logic changes. If upstream updates a component, re-copy the file. diff --git a/src/components/osui/buttons/depth-outline-button.tsx b/src/components/osui/buttons/depth-outline-button.tsx new file mode 100644 index 000000000..951d8b410 --- /dev/null +++ b/src/components/osui/buttons/depth-outline-button.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { + forwardRef, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; + +import { cn } from "@/lib/utils"; + +export type DepthOutlineButtonSize = "sm" | "md" | "lg"; + +export type DepthOutlineButtonProps = Readonly< + { + children: ReactNode; + size?: DepthOutlineButtonSize; + } & ComponentPropsWithoutRef<"button"> +>; + +const SIZE: Record = { + sm: "h-9 gap-1.5 rounded-lg px-3.5 text-xs tracking-wide", + md: "h-10 gap-2 rounded-xl px-4 text-sm tracking-wide", + lg: "h-12 gap-2 rounded-xl px-5 text-sm tracking-wide", +}; + +// Resting: dual-edge (hairline + float) with a soft top lip — not a flat border. +const REST = + "shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_1px_rgba(0,0,0,0.04),0_4px_10px_rgba(0,0,0,0.05),0_12px_28px_rgba(0,0,0,0.05),inset_0_1px_1px_rgba(255,255,255,0.75),inset_0_-1px_2px_rgba(0,0,0,0.04)]"; + +const HOVER = + "hover:bg-neutral-50 hover:text-neutral-950 hover:shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_2px_4px_rgba(0,0,0,0.05),0_8px_16px_rgba(0,0,0,0.07),0_20px_40px_rgba(0,0,0,0.06),inset_0_1px_1px_rgba(255,255,255,0.8),inset_0_-1px_2px_rgba(0,0,0,0.05)]"; + +const PRESSED = + "active:bg-neutral-100 active:shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_1px_2px_rgba(0,0,0,0.04),inset_0_2px_4px_rgba(0,0,0,0.08),inset_0_1px_0_rgba(255,255,255,0.6)]"; + +// DepthOutlineButton — premium outlined CTA with layered float depth; pass any children. +export const DepthOutlineButton = forwardRef< + HTMLButtonElement, + DepthOutlineButtonProps +>( + ( + { className, children, size = "md", type = "button", disabled, ...props }, + ref, + ) => { + return ( + + ); + }, +); + +DepthOutlineButton.displayName = "DepthOutlineButton"; diff --git a/src/components/osui/buttons/hold-to-delete-button.tsx b/src/components/osui/buttons/hold-to-delete-button.tsx new file mode 100644 index 000000000..d27159761 --- /dev/null +++ b/src/components/osui/buttons/hold-to-delete-button.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { + forwardRef, + useEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; + +import { cn } from "@/lib/utils"; +import { Check, Trash2 } from "lucide-react"; + +export type HoldToDeleteButtonProps = Readonly< + { + label?: string; + doneLabel?: string; + holdMs?: number; + onHoldComplete?: () => void; + } & Omit, "onHoldComplete"> +>; + +// Snap the requested hold time to a duration Tailwind can compile statically. +const HOLD_STEPS = [ + { ms: 500, className: "duration-500" }, + { ms: 700, className: "duration-700" }, + { ms: 1000, className: "duration-1000" }, + { ms: 1100, className: "duration-[1100ms]" }, + { ms: 1500, className: "duration-[1500ms]" }, + { ms: 2000, className: "duration-[2000ms]" }, +] as const; + +function nearestHoldStep(holdMs: number) { + return HOLD_STEPS.reduce((best, step) => + Math.abs(step.ms - holdMs) < Math.abs(best.ms - holdMs) ? step : best, + ); +} + +// Hold-to-delete — press and hold; the key sinks in while a rose fill wipes +// across. Release early and it eases back; hold to the end to delete. +export const HoldToDeleteButton = forwardRef< + HTMLButtonElement, + HoldToDeleteButtonProps +>( + ( + { + className, + label = "Hold to delete", + doneLabel = "Deleted", + holdMs = 1100, + onHoldComplete, + ...props + }, + ref, + ) => { + const [holding, setHolding] = useState(false); + const [done, setDone] = useState(false); + const timerRef = useRef | null>(null); + + const step = nearestHoldStep(holdMs); + + useEffect(() => { + return () => { + if (timerRef.current !== null) + globalThis.clearTimeout(timerRef.current); + }; + }, []); + + const start = () => { + if (done || timerRef.current !== null) return; + setHolding(true); + timerRef.current = globalThis.setTimeout(() => { + timerRef.current = null; + setHolding(false); + setDone(true); + onHoldComplete?.(); + }, step.ms); + }; + + const cancel = () => { + if (timerRef.current !== null) { + globalThis.clearTimeout(timerRef.current); + timerRef.current = null; + } + setHolding(false); + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.repeat) return; + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + start(); + } + }; + + const handleKeyUp = (event: ReactKeyboardEvent) => { + if (event.key === " " || event.key === "Enter") cancel(); + }; + + // The rose fill slides in while its content slides the opposite way at the + // same speed, so the white label looks clipped by the moving red edge. + const wipeMotion = cn( + "transition-transform motion-reduce:transition-none motion-reduce:transform-none", + holding || done + ? cn("ease-linear", step.className) + : "duration-300 ease-out", + ); + + return ( + + ); + }, +); + +HoldToDeleteButton.displayName = "HoldToDeleteButton"; diff --git a/src/components/osui/buttons/segmented-toggle-button.tsx b/src/components/osui/buttons/segmented-toggle-button.tsx new file mode 100644 index 000000000..8bcd5ecea --- /dev/null +++ b/src/components/osui/buttons/segmented-toggle-button.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { forwardRef, useState, type ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils"; + +export type SegmentedToggleButtonProps = Readonly< + { + options?: readonly string[]; + defaultIndex?: number; + onChange?: (index: number, value: string) => void; + } & ComponentPropsWithoutRef<"div"> +>; + +const SEGMENT_MOTION = + "transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"; + +const LABEL_MOTION = + "transition-[color,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"; + +function segmentGridClass(count: number): string { + if (count <= 2) return "grid-cols-2"; + if (count === 3) return "grid-cols-3"; + if (count === 4) return "grid-cols-4"; + return "grid-cols-5"; +} + +function indicatorWidthClass(count: number): string { + // Subtract horizontal padding (0.5rem) plus inter-segment gaps (gap-1 each). + if (count <= 2) return "w-[calc((100%-0.75rem)/2)]"; + if (count === 3) return "w-[calc((100%-1rem)/3)]"; + if (count === 4) return "w-[calc((100%-1.25rem)/4)]"; + return "w-[calc((100%-1.5rem)/5)]"; +} + +function indicatorOffsetClass(count: number, active: number): string { + if (active <= 0) return "translate-x-0"; + + const offsets: Record> = { + 2: { 1: "translate-x-[calc(100%+0.25rem)]" }, + 3: { + 1: "translate-x-[calc(100%+0.25rem)]", + 2: "translate-x-[calc(200%+0.5rem)]", + }, + 4: { + 1: "translate-x-[calc(100%+0.25rem)]", + 2: "translate-x-[calc(200%+0.5rem)]", + 3: "translate-x-[calc(300%+0.75rem)]", + }, + 5: { + 1: "translate-x-[calc(100%+0.25rem)]", + 2: "translate-x-[calc(200%+0.5rem)]", + 3: "translate-x-[calc(300%+0.75rem)]", + 4: "translate-x-[calc(400%+1rem)]", + }, + }; + + const capped = Math.min(count, 5); + return offsets[capped]?.[active] ?? "translate-x-0"; +} + +// Segmented toggle — iOS-style sliding pill with smooth eased motion. +export const SegmentedToggleButton = forwardRef< + HTMLDivElement, + SegmentedToggleButtonProps +>( + ( + { + className, + options = ["Day", "Week", "Month"], + defaultIndex = 0, + onChange, + ...props + }, + ref, + ) => { + const [active, setActive] = useState(defaultIndex); + const count = options.length; + const safeActive = Math.min(Math.max(active, 0), Math.max(count - 1, 0)); + + const select = (index: number) => { + setActive(index); + onChange?.(index, options[index] ?? ""); + }; + + return ( +
+ + + {options.map((option, index) => ( + + ))} +
+ ); + }, +); + +SegmentedToggleButton.displayName = "SegmentedToggleButton"; diff --git a/src/components/osui/buttons/soft-pill-button.tsx b/src/components/osui/buttons/soft-pill-button.tsx new file mode 100644 index 000000000..d3cc8cee3 --- /dev/null +++ b/src/components/osui/buttons/soft-pill-button.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { + forwardRef, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; + +import { cn } from "@/lib/utils"; + +export type SoftPillButtonVariant = "light" | "dark"; +export type SoftPillButtonSize = "sm" | "md" | "lg"; + +export type SoftPillButtonProps = Readonly< + { + children: ReactNode; + variant?: SoftPillButtonVariant; + size?: SoftPillButtonSize; + } & ComponentPropsWithoutRef<"button"> +>; + +const SIZE: Record = { + sm: "h-9 gap-1.5 rounded-full px-4 text-xs", + md: "h-10 gap-2 rounded-full px-5 text-sm", + lg: "h-12 gap-2 rounded-full px-6 text-sm", +}; + +const VARIANT: Record = { + light: cn( + "bg-white text-neutral-800", + "shadow-[0_2px_4px_rgba(0,0,0,0.06),0_8px_20px_rgba(0,0,0,0.08)]", + "hover:bg-neutral-50 hover:text-neutral-900", + "active:bg-neutral-100 active:shadow-[0_1px_2px_rgba(0,0,0,0.06),0_3px_8px_rgba(0,0,0,0.06)]", + ), + dark: cn( + "bg-neutral-800 text-white", + "shadow-[0_2px_4px_rgba(0,0,0,0.2),0_8px_20px_rgba(0,0,0,0.25)]", + "hover:bg-neutral-700", + "active:bg-neutral-900 active:shadow-[0_1px_2px_rgba(0,0,0,0.2),0_3px_8px_rgba(0,0,0,0.2)]", + ), +}; + +// SoftPillButton — floating rounded pill; pass any children. +export const SoftPillButton = forwardRef< + HTMLButtonElement, + SoftPillButtonProps +>( + ( + { + className, + children, + variant = "light", + size = "md", + type = "button", + disabled, + ...props + }, + ref, + ) => { + return ( + + ); + }, +); + +SoftPillButton.displayName = "SoftPillButton"; diff --git a/src/components/osui/buttons/three-d-button.tsx b/src/components/osui/buttons/three-d-button.tsx new file mode 100644 index 000000000..705cd8c6f --- /dev/null +++ b/src/components/osui/buttons/three-d-button.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { + forwardRef, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; + +import { cn } from "@/lib/utils"; + +export type ThreeDButtonVariant = "solid" | "soft" | "muted"; +export type ThreeDButtonSize = "sm" | "md" | "lg" | "icon"; + +export type ThreeDButtonProps = Readonly< + { + children: ReactNode; + variant?: ThreeDButtonVariant; + size?: ThreeDButtonSize; + } & ComponentPropsWithoutRef<"button"> +>; + +const SIZE: Record = { + sm: "h-9 gap-1.5 rounded-lg px-3 text-xs", + md: "h-10 gap-2 rounded-lg px-4 text-sm", + lg: "h-12 gap-2 rounded-xl px-5 text-sm", + icon: "size-10 rounded-lg p-0", +}; + +// Soft diffused top light + bottom shade — no hard white rim. +const BEVEL_LIGHT = + "shadow-[0_1px_1px_rgba(0,0,0,0.08),0_2px_4px_rgba(0,0,0,0.1),0_6px_12px_rgba(0,0,0,0.08),inset_0_1px_2px_rgba(255,255,255,0.35),inset_0_-2px_4px_rgba(0,0,0,0.08)]"; +const PRESSED_LIGHT = + "active:shadow-[0_1px_1px_rgba(0,0,0,0.05),inset_0_1px_2px_rgba(0,0,0,0.08),inset_0_2px_4px_rgba(0,0,0,0.04),inset_0_-1px_2px_rgba(0,0,0,0.05)]"; + +// Dark key: soft top sheen (blurred, not a sharp bar) + deep lift + bottom recess. +const BEVEL_DARK = + "shadow-[0_1px_1px_rgba(0,0,0,0.35),0_3px_6px_rgba(0,0,0,0.28),0_8px_16px_rgba(0,0,0,0.22),inset_0_1px_2px_rgba(255,255,255,0.14),inset_0_-3px_6px_rgba(0,0,0,0.55)]"; +const PRESSED_DARK = + "active:shadow-[0_1px_2px_rgba(0,0,0,0.25),inset_0_2px_6px_rgba(0,0,0,0.55),inset_0_-1px_1px_rgba(255,255,255,0.06)]"; + +const VARIANT: Record = { + solid: cn( + "bg-neutral-800 text-white hover:bg-neutral-700 active:bg-neutral-900", + BEVEL_DARK, + PRESSED_DARK, + ), + soft: cn( + "bg-neutral-50 text-neutral-800 hover:text-neutral-900 active:bg-neutral-100", + BEVEL_LIGHT, + PRESSED_LIGHT, + ), + muted: cn( + "bg-neutral-100 text-neutral-700 hover:bg-neutral-200/80 hover:text-neutral-900 active:bg-neutral-200", + BEVEL_LIGHT, + PRESSED_LIGHT, + ), +}; + +// Universal 3D button — pass any children (label, icon + label, icon only). +export const ThreeDButton = forwardRef( + ( + { + className, + children, + variant = "solid", + size = "md", + type = "button", + disabled, + ...props + }, + ref, + ) => { + return ( + + ); + }, +); + +ThreeDButton.displayName = "ThreeDButton"; diff --git a/src/components/osui/calender/event-countdown-card.tsx b/src/components/osui/calender/event-countdown-card.tsx new file mode 100644 index 000000000..b99424393 --- /dev/null +++ b/src/components/osui/calender/event-countdown-card.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { + forwardRef, + useEffect, + useMemo, + useState, + type ComponentPropsWithoutRef, +} from "react"; + +import { cn } from "@/lib/utils"; + +function getCountdown(target: Date) { + const diff = Math.max(0, target.getTime() - Date.now()); + const totalMinutes = Math.floor(diff / 60_000); + const days = Math.floor(totalMinutes / (60 * 24)); + const hours = Math.floor((totalMinutes % (60 * 24)) / 60); + const minutes = totalMinutes % 60; + return { days, hours, minutes, done: diff <= 0 }; +} + +export type EventCountdownCardProps = Readonly< + { + title?: string; + targetDate?: string; + onComplete?: () => void; + } & ComponentPropsWithoutRef<"div"> +>; + +// Event countdown — live days, hours, and minutes until a target date. +export const EventCountdownCard = forwardRef< + HTMLDivElement, + EventCountdownCardProps +>( + ( + { + className, + title = "Product launch", + targetDate = "2026-12-01T09:00:00", + onComplete, + ...props + }, + ref, + ) => { + const target = useMemo(() => new Date(targetDate), [targetDate]); + const [countdown, setCountdown] = useState(() => getCountdown(target)); + + useEffect(() => { + const tick = () => { + const next = getCountdown(target); + setCountdown(next); + if (next.done) onComplete?.(); + }; + tick(); + const timer = globalThis.setInterval(tick, 60_000); + return () => globalThis.clearInterval(timer); + }, [target, onComplete]); + + const dateLabel = target.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); + + return ( +
+

+ Countdown +

+

{title}

+

{dateLabel}

+ +
+ {[ + { value: countdown.days, label: "days" }, + { value: countdown.hours, label: "hrs" }, + { value: countdown.minutes, label: "min" }, + ].map((unit) => ( +
+

+ {String(unit.value).padStart(2, "0")} +

+

+ {unit.label} +

+
+ ))} +
+
+ ); + }, +); + +EventCountdownCard.displayName = "EventCountdownCard"; diff --git a/src/components/osui/calender/month-picker-calendar.tsx b/src/components/osui/calender/month-picker-calendar.tsx new file mode 100644 index 000000000..eac999e4c --- /dev/null +++ b/src/components/osui/calender/month-picker-calendar.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { + forwardRef, + useMemo, + useState, + type ComponentPropsWithoutRef, +} from "react"; + +import { cn } from "@/lib/utils"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +const WEEKDAYS = ["S", "M", "T", "W", "T", "F", "S"] as const; + +function buildMonthCells(year: number, month: number) { + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const cells: Array = []; + + for (let index = 0; index < firstDay; index += 1) { + cells.push(null); + } + for (let day = 1; day <= daysInMonth; day += 1) { + cells.push(day); + } + return cells; +} + +function isSameDay(a: Date, year: number, month: number, day: number) { + return ( + a.getFullYear() === year && a.getMonth() === month && a.getDate() === day + ); +} + +export type MonthPickerCalendarProps = Readonly< + { + defaultYear?: number; + defaultMonth?: number; + defaultSelectedDay?: number; + onSelect?: (date: Date) => void; + } & ComponentPropsWithoutRef<"div"> +>; + +// Month picker — navigate months and tap a day to select it. +export const MonthPickerCalendar = forwardRef< + HTMLDivElement, + MonthPickerCalendarProps +>( + ( + { + className, + defaultYear, + defaultMonth, + defaultSelectedDay, + onSelect, + ...props + }, + ref, + ) => { + const today = useMemo(() => new Date(), []); + const [viewYear, setViewYear] = useState( + defaultYear ?? today.getFullYear(), + ); + const [viewMonth, setViewMonth] = useState( + defaultMonth ?? today.getMonth(), + ); + const [selectedDay, setSelectedDay] = useState( + defaultSelectedDay ?? today.getDate(), + ); + const [slideDirection, setSlideDirection] = useState(0); + + const cells = useMemo( + () => buildMonthCells(viewYear, viewMonth), + [viewYear, viewMonth], + ); + + const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString( + "en-US", + { month: "long", year: "numeric" }, + ); + + const shiftMonth = (delta: number) => { + setSlideDirection(delta); + const next = new Date(viewYear, viewMonth + delta, 1); + setViewYear(next.getFullYear()); + setViewMonth(next.getMonth()); + const lastDay = new Date( + next.getFullYear(), + next.getMonth() + 1, + 0, + ).getDate(); + setSelectedDay((day) => Math.min(day, lastDay)); + }; + + const selectDay = (day: number) => { + setSelectedDay(day); + onSelect?.(new Date(viewYear, viewMonth, day)); + }; + + return ( +
+
+ + +

+ {monthLabel} +

+ + +
+ +
+ {WEEKDAYS.map((label, index) => ( + {label} + ))} +
+ +
= 0 + ? "starting:translate-x-2" + : "starting:-translate-x-2", + )} + > + {cells.map((day, index) => { + if (day === null) { + return ; + } + + const isToday = isSameDay(today, viewYear, viewMonth, day); + const isSelected = day === selectedDay; + + return ( + + ); + })} +
+
+ ); + }, +); + +MonthPickerCalendar.displayName = "MonthPickerCalendar"; diff --git a/src/components/osui/contact/copy-email.tsx b/src/components/osui/contact/copy-email.tsx new file mode 100644 index 000000000..1ae7609d5 --- /dev/null +++ b/src/components/osui/contact/copy-email.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +type CopyEmailProps = Readonly<{ + email?: string; + className?: string; + /** Visible label. Defaults to the email address. */ + children?: string; +}>; + +/** + * Copies the maintainer email on click — no mailto / mailbox popup. + * Hover shows “Click to copy”; after click shows “Copied”. + */ +export function CopyEmail({ + email = "", + className, + children, +}: CopyEmailProps) { + const tipId = useId(); + const [copied, setCopied] = useState(false); + const [hovered, setHovered] = useState(false); + const resetTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimer.current) clearTimeout(resetTimer.current); + }; + }, []); + + const copy = useCallback(async () => { + try { + await navigator.clipboard.writeText(email); + } catch { + const field = document.createElement("textarea"); + field.value = email; + field.setAttribute("readonly", ""); + field.style.position = "fixed"; + field.style.left = "-9999px"; + document.body.appendChild(field); + field.select(); + document.execCommand("copy"); + document.body.removeChild(field); + } + + setCopied(true); + if (resetTimer.current) clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopied(false), 1600); + }, [email]); + + const showTip = hovered || copied; + const tipLabel = copied ? "Copied" : "Click to copy"; + + return ( + + + + + {tipLabel} + + + + ); +} diff --git a/src/components/osui/docks/spotlight-bar.tsx b/src/components/osui/docks/spotlight-bar.tsx new file mode 100644 index 000000000..9f439fc24 --- /dev/null +++ b/src/components/osui/docks/spotlight-bar.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { + forwardRef, + useId, + useState, + type ComponentPropsWithoutRef, + type FormEvent, + type KeyboardEvent, +} from "react"; + +import { Command, Search, Sparkles } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type SpotlightSuggestion = Readonly<{ + id: string; + label: string; + hint?: string; +}>; + +export type SpotlightBarProps = Readonly< + { + placeholder?: string; + suggestions?: readonly SpotlightSuggestion[]; + showShortcut?: boolean; + onSubmit?: (value: string) => void; + onSelectSuggestion?: (id: string) => void; + } & Omit, "onSubmit"> +>; + +const DEFAULT_SUGGESTIONS: readonly SpotlightSuggestion[] = [ + { id: "new-page", label: "Create page", hint: "P" }, + { id: "invite", label: "Invite teammate", hint: "I" }, + { id: "analytics", label: "Open analytics", hint: "A" }, +]; + +// Spotlight bar — frosted command search bar aligned with MacDock shell. +export const SpotlightBar = forwardRef( + ( + { + className, + placeholder = "Search actions, pages, people…", + suggestions = DEFAULT_SUGGESTIONS, + showShortcut = true, + onSubmit, + onSelectSuggestion, + ...props + }, + ref, + ) => { + const inputId = useId(); + const [value, setValue] = useState(""); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + + const filtered = suggestions.filter((item) => + item.label.toLowerCase().includes(value.trim().toLowerCase()), + ); + + const run = (event?: FormEvent) => { + event?.preventDefault(); + const target = filtered[activeIndex]; + if (target) { + onSelectSuggestion?.(target.id); + setValue(""); + setOpen(false); + return; + } + if (value.trim()) { + onSubmit?.(value.trim()); + setValue(""); + setOpen(false); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { + setOpen(true); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((index) => + filtered.length === 0 ? 0 : (index + 1) % filtered.length, + ); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((index) => + filtered.length === 0 + ? 0 + : (index - 1 + filtered.length) % filtered.length, + ); + } + if (event.key === "Escape") setOpen(false); + }; + + return ( +
+
+ + + { + setValue(event.target.value); + setOpen(true); + setActiveIndex(0); + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={placeholder} + className="min-w-0 flex-1 bg-transparent text-sm text-neutral-900 ring-0 outline-none placeholder:text-neutral-400 focus:border-transparent focus:ring-0" + /> + {showShortcut ? ( + + K + + ) : null} +
+ +
    0 + ? "pointer-events-auto scale-100 opacity-100" + : "pointer-events-none scale-95 opacity-0", + )} + > + {filtered.map((item, index) => ( +
  • + +
  • + ))} +
+
+ ); + }, +); + +SpotlightBar.displayName = "SpotlightBar"; diff --git a/src/components/osui/dropdowns/filter-sort-dropdown.tsx b/src/components/osui/dropdowns/filter-sort-dropdown.tsx new file mode 100644 index 000000000..04e6dd0c2 --- /dev/null +++ b/src/components/osui/dropdowns/filter-sort-dropdown.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, +} from "react"; + +import { cn } from "@/lib/utils"; + +import { ChevronDown } from "lucide-react"; + +export type FilterSortOption = Readonly<{ + id: string; + label: string; +}>; + +export type FilterSortDropdownProps = Readonly< + { + label?: string; + triggerAriaLabel?: string; + menuAriaLabel?: string; + value?: string; + options?: readonly FilterSortOption[]; + onValueChange?: (option: FilterSortOption) => void; + } & ComponentPropsWithoutRef<"div"> +>; + +const defaultOptions: readonly FilterSortOption[] = [ + { id: "newest", label: "Newest first" }, + { id: "oldest", label: "Oldest first" }, + { id: "popular", label: "Most popular" }, + { id: "az", label: "A → Z" }, + { id: "za", label: "Z → A" }, +]; + +type FilterSortOptionRowProps = Readonly<{ + option: FilterSortOption; + selected: boolean; + onSelect: (option: FilterSortOption) => void; +}>; + +function FilterSortOptionRow({ + option, + selected, + onSelect, +}: FilterSortOptionRowProps) { + return ( + + ); +} + +// Native select style — menu width matches trigger exactly. +export const FilterSortDropdown = forwardRef< + HTMLDivElement, + FilterSortDropdownProps +>( + ( + { + label = "Sort", + triggerAriaLabel = "Sort options", + menuAriaLabel = "Sort menu", + value = "newest", + options = defaultOptions, + onValueChange, + className, + ...props + }, + ref, + ) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const menuId = useId(); + + const selectedOption = + options.find((option) => option.id === value) ?? options[0]; + + useEffect(() => { + const closeOnOutside = (event: globalThis.MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, []); + + const handleOptionSelect = (option: FilterSortOption) => { + setOpen(false); + onValueChange?.(option); + }; + + const toggleOpen = () => setOpen((prev) => !prev); + + return ( +
+
+ + + {open ? ( + + ) : null} +
+
+ ); + }, +); + +FilterSortDropdown.displayName = "FilterSortDropdown"; diff --git a/src/components/osui/dropdowns/kebab-actions-dropdown.tsx b/src/components/osui/dropdowns/kebab-actions-dropdown.tsx new file mode 100644 index 000000000..879429c41 --- /dev/null +++ b/src/components/osui/dropdowns/kebab-actions-dropdown.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, + type ComponentType, +} from "react"; + +import { cn } from "@/lib/utils"; + +import { MoreVertical, Pencil, Link, Archive, Trash } from "lucide-react"; + +type IconComponent = ComponentType< + Readonly<{ size?: number; className?: string }> +>; + +export type KebabMenuItem = Readonly<{ + id: string; + label?: string; + icon?: IconComponent; + danger?: boolean; + separator?: boolean; +}>; + +export type KebabActionsDropdownProps = Readonly< + { + rowTitle?: string; + triggerAriaLabel?: string; + menuAriaLabel?: string; + items?: readonly KebabMenuItem[]; + onItemClick?: (item: KebabMenuItem) => void; + } & ComponentPropsWithoutRef<"div"> +>; + +const defaultItems: readonly KebabMenuItem[] = [ + { id: "edit", label: "Edit", icon: Pencil }, + { id: "copy-link", label: "Copy link", icon: Link }, + { id: "archive", label: "Archive", icon: Archive }, + { id: "separator-delete", separator: true }, + { id: "delete", label: "Delete", icon: Trash, danger: true }, +]; + +type KebabMenuItemRowProps = Readonly<{ + item: KebabMenuItem; + onSelect: (item: KebabMenuItem) => void; +}>; + +function KebabMenuItemRow({ item, onSelect }: KebabMenuItemRowProps) { + if (item.separator) { + return
; + } + + const Icon = item.icon; + + return ( + + ); +} + +// Inline expand row — menu opens inside the same card, widths always match. +export const KebabActionsDropdown = forwardRef< + HTMLDivElement, + KebabActionsDropdownProps +>( + ( + { + rowTitle = "Quarterly report", + triggerAriaLabel = "More actions", + menuAriaLabel = "Actions menu", + items = defaultItems, + onItemClick, + className, + ...props + }, + ref, + ) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const menuId = useId(); + + useEffect(() => { + const closeOnOutside = (event: globalThis.MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, []); + + const handleItemSelect = (item: KebabMenuItem) => { + setOpen(false); + onItemClick?.(item); + }; + + const toggleOpen = () => setOpen((prev) => !prev); + + return ( +
+
+
+ + {rowTitle} + + + +
+ + {open ? ( + + ) : null} +
+
+ ); + }, +); + +KebabActionsDropdown.displayName = "KebabActionsDropdown"; diff --git a/src/components/osui/dropdowns/notification-dropdown.tsx b/src/components/osui/dropdowns/notification-dropdown.tsx new file mode 100644 index 000000000..4a813a885 --- /dev/null +++ b/src/components/osui/dropdowns/notification-dropdown.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, +} from "react"; + +import { cn } from "@/lib/utils"; + +import { Bell } from "lucide-react"; + +export type NotificationItem = Readonly<{ + id: string; + title: string; + body: string; + time: string; + unread?: boolean; +}>; + +export type NotificationDropdownProps = Readonly< + { + triggerAriaLabel?: string; + menuAriaLabel?: string; + headerTitle?: string; + markAllLabel?: string; + emptyLabel?: string; + notifications?: readonly NotificationItem[]; + onNotificationClick?: (item: NotificationItem) => void; + onMarkAllRead?: () => void; + } & ComponentPropsWithoutRef<"div"> +>; + +const defaultNotifications: readonly NotificationItem[] = [ + { + id: "comment", + title: "New comment", + body: "Sarah replied on your post", + time: "2m", + unread: true, + }, + { + id: "deploy", + title: "Deploy succeeded", + body: "Production build finished", + time: "1h", + unread: true, + }, + { + id: "invite", + title: "Invite accepted", + body: "Alex joined your workspace", + time: "1d", + unread: false, + }, +]; + +type NotificationRowProps = Readonly<{ + item: NotificationItem; + onSelect: (item: NotificationItem) => void; +}>; + +function NotificationRow({ item, onSelect }: NotificationRowProps) { + return ( + + ); +} + +// Notification panel — square trigger, inset gray tray with card rows. +export const NotificationDropdown = forwardRef< + HTMLDivElement, + NotificationDropdownProps +>( + ( + { + triggerAriaLabel = "Notifications", + menuAriaLabel = "Notification list", + headerTitle = "Inbox", + markAllLabel = "Clear", + emptyLabel = "Nothing new", + notifications = defaultNotifications, + onNotificationClick, + onMarkAllRead, + className, + ...props + }, + ref, + ) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const menuId = useId(); + + const unreadCount = notifications.filter((n) => n.unread).length; + + useEffect(() => { + const closeOnOutside = (event: globalThis.MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, []); + + const handleNotificationSelect = (item: NotificationItem) => { + setOpen(false); + onNotificationClick?.(item); + }; + + const toggleOpen = () => setOpen((prev) => !prev); + + return ( +
+
+ + + {open ? ( + + ) : null} +
+
+ ); + }, +); + +NotificationDropdown.displayName = "NotificationDropdown"; diff --git a/src/components/osui/dropdowns/user-menu-dropdown.tsx b/src/components/osui/dropdowns/user-menu-dropdown.tsx new file mode 100644 index 000000000..83ee9553d --- /dev/null +++ b/src/components/osui/dropdowns/user-menu-dropdown.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + cloneElement, + type ComponentPropsWithoutRef, + type ReactElement, +} from "react"; +import Image from "next/image"; + +import { cn } from "@/lib/utils"; + +import { User, Settings, Mail, ChevronDown } from "lucide-react"; + +/** Single row in the user menu — action, shortcut, or separator. */ +export type UserMenuItem = Readonly<{ + id?: string; + label?: string; + icon?: ReactElement; + kbd?: string; + danger?: boolean; + separator?: boolean; + onClick?: () => void; +}>; + +export type UserMenuDropdownProps = Readonly< + { + userName?: string; + userEmail?: string; + avatarSrc?: string; + avatarAlt?: string; + items?: readonly UserMenuItem[]; + onItemClick?: (item: UserMenuItem) => void; + } & ComponentPropsWithoutRef<"div"> +>; + +const defaultItems: readonly UserMenuItem[] = [ + { id: "profile", label: "View Profile", icon: , onClick: () => {} }, + { + id: "settings", + label: "Account Settings", + icon: , + kbd: "ctrl + S", + onClick: () => {}, + }, + { + id: "messages", + label: "Messages", + icon: , + kbd: "ctrl + M", + onClick: () => {}, + }, + { id: "separator-sign-out", separator: true }, + { id: "sign-out", label: "Sign Out", danger: true, onClick: () => {} }, +]; + +type UserMenuItemRowProps = Readonly<{ + item: UserMenuItem; + onSelect: (item: UserMenuItem) => void; +}>; + +function UserMenuItemRow({ item, onSelect }: UserMenuItemRowProps) { + if (item.separator) { + return
; + } + + return ( + + ); +} + +// Self-contained user profile menu with a soft spring open animation. +export const UserMenuDropdown = forwardRef< + HTMLDivElement, + UserMenuDropdownProps +>( + ( + { + userName = "Bidyut Kundu", + userEmail = "bidyut.kundu.dev@gmail.com", + avatarSrc = "/profile-picture.png", + avatarAlt = "User", + items = defaultItems, + onItemClick, + className, + ...props + }, + ref, + ) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const menuId = useId(); + + useEffect(() => { + const closeOnOutside = (event: globalThis.MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, []); + + const handleItemSelect = (item: UserMenuItem) => { + setOpen(false); + item.onClick?.(); + onItemClick?.(item); + }; + + const toggleOpen = () => setOpen((prev) => !prev); + + return ( +
+
+ + + {open ? ( + + ) : null} +
+
+ ); + }, +); + +UserMenuDropdown.displayName = "UserMenuDropdown"; diff --git a/src/components/osui/dropdowns/workspace-switcher-dropdown.tsx b/src/components/osui/dropdowns/workspace-switcher-dropdown.tsx new file mode 100644 index 000000000..0a18172cf --- /dev/null +++ b/src/components/osui/dropdowns/workspace-switcher-dropdown.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + cloneElement, + type ComponentPropsWithoutRef, + type ReactElement, +} from "react"; + +import { cn } from "@/lib/utils"; + +import { ChevronsUpDown, Plus, GraduationCap, Briefcase } from "lucide-react"; + +export type WorkspaceItem = Readonly<{ + id: string; + name?: string; + icon?: ReactElement; + plan?: string; + active?: boolean; + separator?: boolean; +}>; + +export type WorkspaceSwitcherDropdownProps = Readonly< + { + triggerAriaLabel?: string; + menuAriaLabel?: string; + createLabel?: string; + workspaces?: readonly WorkspaceItem[]; + onWorkspaceChange?: (workspace: WorkspaceItem) => void; + onCreateWorkspace?: () => void; + } & ComponentPropsWithoutRef<"div"> +>; + +// MIT opensourceui.in — brand icons swapped for lucide (same component otherwise). +const defaultWorkspaces: readonly WorkspaceItem[] = [ + { + id: "school", + name: "School", + icon: , + plan: "PhD", + active: true, + }, + { + id: "job", + name: "Jobs", + icon: , + plan: "Search", + }, + { id: "separator-create", separator: true }, + { id: "create", name: "Create workspace" }, +]; + +type WorkspaceRowProps = Readonly<{ + item: WorkspaceItem; + onSelect: (item: WorkspaceItem) => void; + createLabel: string; +}>; + +function WorkspaceRow({ item, onSelect, createLabel }: WorkspaceRowProps) { + if (item.separator) { + return
; + } + + const isCreate = item.id === "create"; + + return ( + + ); +} + +// Org switcher — rectangular bar trigger, inline expand menu (same width). +export const WorkspaceSwitcherDropdown = forwardRef< + HTMLDivElement, + WorkspaceSwitcherDropdownProps +>( + ( + { + triggerAriaLabel = "Switch workspace", + menuAriaLabel = "Workspace list", + createLabel = "Create workspace", + workspaces = defaultWorkspaces, + onWorkspaceChange, + onCreateWorkspace, + className, + ...props + }, + ref, + ) => { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const menuId = useId(); + + const activeWorkspace = + workspaces.find((w) => w.active && !w.separator) ?? + workspaces.find((w) => !w.separator && w.id !== "create"); + + useEffect(() => { + const closeOnOutside = (event: globalThis.MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", closeOnOutside); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, []); + + const handleWorkspaceSelect = (item: WorkspaceItem) => { + setOpen(false); + if (item.id === "create") { + onCreateWorkspace?.(); + } else { + onWorkspaceChange?.(item); + } + }; + + const toggleOpen = () => setOpen((prev) => !prev); + + return ( +
+
+ + + {open ? ( + + ) : null} +
+
+ ); + }, +); + +WorkspaceSwitcherDropdown.displayName = "WorkspaceSwitcherDropdown"; diff --git a/src/components/osui/files/ink-stamp-document-card.tsx b/src/components/osui/files/ink-stamp-document-card.tsx new file mode 100644 index 000000000..5d424d3ac --- /dev/null +++ b/src/components/osui/files/ink-stamp-document-card.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { forwardRef, type ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils"; +import { FileText } from "lucide-react"; + +export type InkStampDocumentCardProps = Readonly< + { + title?: string; + reference?: string; + submittedBy?: string; + submittedOn?: string; + stampLabel?: string; + status?: string; + } & ComponentPropsWithoutRef<"div"> +>; + +export const InkStampDocumentCard = forwardRef< + HTMLDivElement, + InkStampDocumentCardProps +>( + ( + { + className, + title = "Vendor Agreement", + reference = "DOC-2026-118", + submittedBy = "Northline Studio", + submittedOn = "12 Jun 2026", + stampLabel = "Approved", + status = "Signed & filed", + ...props + }, + ref, + ) => { + return ( +
+
+
+

+ {reference} +

+

+ {title} +

+
+
+ +
+
+ +
+

+ Submitted by ·{" "} + {submittedBy} +

+

+ Date · {submittedOn} +

+
+ +
+ +

{status}

+ +
+

+ {stampLabel} +

+
+
+ ); + }, +); + +InkStampDocumentCard.displayName = "InkStampDocumentCard"; diff --git a/src/components/osui/files/opensource-folder-tab-card.tsx b/src/components/osui/files/opensource-folder-tab-card.tsx new file mode 100644 index 000000000..fe1ed6ea9 --- /dev/null +++ b/src/components/osui/files/opensource-folder-tab-card.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Image from "next/image"; +import { forwardRef, type ComponentPropsWithoutRef } from "react"; +import { cn } from "@/lib/utils"; + +export type OpensourceFolderTabCardProps = Readonly< + { + appName?: string; + cardLabel?: string; + title?: string; + subtitle?: string; + primaryValue?: string; + primaryLabel?: string; + secondaryValue?: string; + secondaryLabel?: string; + imageSrc?: string; + imageAlt?: string; + } & ComponentPropsWithoutRef<"div"> +>; + +export const OpensourceFolderTabCard = forwardRef< + HTMLDivElement, + OpensourceFolderTabCardProps +>(function OpensourceFolderTabCard( + { + className, + appName = "Opensource App", + cardLabel = "Card Design", + title = "UI blocks", + subtitle = "Copy & paste ready", + primaryValue = "116", + primaryLabel = "Components", + secondaryValue = "Free", + secondaryLabel = "Open source", + imageSrc = "/background1.webp", + imageAlt = "Card preview", + ...props + }, + ref, +) { + return ( +
+
+ {imageAlt} + +
+

{appName}

+

{cardLabel}

+
+
+ +
+
+
+
+ + + +
+ +
+
+

+ {title} +

+

{subtitle}

+
+ +
+

+ + {primaryValue} + + {primaryLabel} +

+

+ {secondaryValue}{" "} + {secondaryLabel} +

+
+
+
+
+ ); +}); + +OpensourceFolderTabCard.displayName = "OpensourceFolderTabCard"; diff --git a/src/components/osui/files/stacked-folder-card.tsx b/src/components/osui/files/stacked-folder-card.tsx new file mode 100644 index 000000000..75305f213 --- /dev/null +++ b/src/components/osui/files/stacked-folder-card.tsx @@ -0,0 +1,176 @@ +"use client"; + +import Image from "next/image"; +import { + forwardRef, + useCallback, + useState, + type ComponentPropsWithoutRef, +} from "react"; + +import { cn } from "@/lib/utils"; + +type LayerIndex = 0 | 1 | 2; +type StackPosition = "front" | "middle" | "back"; + +const TRANSITION = + "transition-[top,left,width,height,transform,box-shadow] duration-400 ease-[cubic-bezier(0.22,1,0.36,1)]"; + +const POSITION_LAYOUT: Record = { + front: + "top-0 left-9 z-30 h-[17rem] w-[15.75rem] rotate-0 shadow-[0_20px_44px_-14px_rgba(0,0,0,0.38)]", + middle: + "top-4 left-4 z-20 h-[16rem] w-[15rem] -rotate-[3.5deg] shadow-[0_12px_28px_-10px_rgba(0,0,0,0.3)]", + back: "top-9 left-0.5 z-10 h-[15.5rem] w-[14.5rem] -rotate-[7deg] shadow-[0_10px_24px_-10px_rgba(0,0,0,0.28)]", +}; + +// [front, middle, back] — hovered card goes front, previous front goes back, other stays middle. +const STACK_ORDER: Record< + LayerIndex, + readonly [LayerIndex, LayerIndex, LayerIndex] +> = { + 0: [0, 1, 2], + 1: [1, 0, 2], + 2: [2, 1, 0], +}; + +function positionForLayer( + layer: LayerIndex, + active: LayerIndex, +): StackPosition { + const [front, middle] = STACK_ORDER[active]; + if (layer === front) return "front"; + if (layer === middle) return "middle"; + return "back"; +} + +type LayerConfig = Readonly<{ + index: LayerIndex; + src?: string; + alt: string; + surfaceClassName: string; +}>; + +export type StackedFolderCardProps = Readonly< + { + imageSrc?: string; + imageAlt?: string; + backImageSrc?: string; + middleImageSrc?: string; + backClassName?: string; + middleClassName?: string; + } & ComponentPropsWithoutRef<"div"> +>; + +// Three-layer folder stack · fixed hit zones · hover rotates front → back. +export const StackedFolderCard = forwardRef< + HTMLDivElement, + StackedFolderCardProps +>(function StackedFolderCard( + { + className, + imageSrc = "/background1.webp", + imageAlt = "Folder cover", + backImageSrc, + middleImageSrc, + backClassName = "bg-[#e28a8a]", + middleClassName = "bg-[#5a675e]", + ...props + }, + ref, +) { + const [active, setActive] = useState(2); + + const [frontLayer, middleLayer, backLayer] = STACK_ORDER[active]; + + const bringToFront = useCallback((layer: LayerIndex) => { + setActive((current) => (current === layer ? current : layer)); + }, []); + + const layers: LayerConfig[] = [ + { + index: 0, + src: backImageSrc, + alt: "", + surfaceClassName: backClassName, + }, + { + index: 1, + src: middleImageSrc, + alt: "", + surfaceClassName: middleClassName, + }, + { + index: 2, + src: imageSrc, + alt: imageAlt, + surfaceClassName: "", + }, + ]; + + return ( +
+ {layers.map((layer) => { + const position = positionForLayer(layer.index, active); + + return ( +
+ {layer.src ? ( + {layer.alt} + ) : null} +
+ ); + })} + +
+ ); +}); + +StackedFolderCard.displayName = "StackedFolderCard"; diff --git a/src/components/osui/forms/forgot-password-form.tsx b/src/components/osui/forms/forgot-password-form.tsx new file mode 100644 index 000000000..1b622c085 --- /dev/null +++ b/src/components/osui/forms/forgot-password-form.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { + forwardRef, + useCallback, + useId, + useState, + type ComponentPropsWithoutRef, + type FormEvent, + type ReactNode, +} from "react"; + +import Link from "next/link"; + +import { AlertCircle, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type FormLinkProps = Readonly<{ + href: string; + className?: string; + children: ReactNode; +}>; + +function isPlaceholderHref(href: string): boolean { + return !href || href === "#"; +} + +function FormLink({ href, className, children }: FormLinkProps) { + if (isPlaceholderHref(href)) { + return ( + + ); + } + + return ( + + {children} + + ); +} + +export type ForgotPasswordFormProps = Readonly< + { + title?: string; + subtitle?: string; + submitLabel?: string; + successTitle?: string; + successMessage?: string; + backLabel?: string; + backHref?: string; + submitErrorMessage?: string; + loading?: boolean; + onSubmit?: (email: string) => void | Promise; + } & Omit, "onSubmit"> +>; + +export const ForgotPasswordForm = forwardRef< + HTMLDivElement, + ForgotPasswordFormProps +>(function ForgotPasswordForm( + { + className, + title = "Reset your password", + subtitle = "Enter the email linked to your account and we'll send a reset link.", + submitLabel = "Send reset link", + successTitle = "Check your inbox", + successMessage = "If an account exists for that email, you'll receive reset instructions shortly.", + backLabel = "Back to sign in", + backHref = "/components/login-form", + submitErrorMessage = "We couldn't send the reset email. Please try again.", + loading = false, + onSubmit, + onReset, + ...props + }, + ref, +) { + const formId = useId(); + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + const [submitError, setSubmitError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [sentTo, setSentTo] = useState(""); + + const busy = loading || submitting; + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (busy || success) return; + + const trimmed = email.trim(); + if (!trimmed) { + setError("Email is required."); + return; + } + if (!EMAIL_RE.test(trimmed)) { + setError("Enter a valid email address."); + return; + } + + setError(""); + setSubmitError(""); + setSubmitting(true); + try { + await onSubmit?.(trimmed); + setSentTo(trimmed); + setSuccess(true); + } catch { + setSubmitError(submitErrorMessage); + } finally { + setSubmitting(false); + } + }, + [busy, email, onSubmit, submitErrorMessage, success], + ); + + const handleReset = useCallback( + (event: FormEvent) => { + onReset?.(event); + if (event.defaultPrevented) return; + setEmail(""); + setError(""); + setSubmitError(""); + }, + [onReset], + ); + + if (success) { + return ( +
+

+ {successTitle} +

+

+ {successMessage} +

+ {sentTo ? ( +

+ {sentTo} +

+ ) : null} + + + {backLabel} + +
+ ); + } + + return ( +
+
+ + + {backLabel} + + +
+

+ {title} +

+

+ {subtitle} +

+
+ +
+ + { + setEmail(event.target.value); + setSubmitError(""); + if (error) setError(""); + }} + className={cn( + "h-11 w-full rounded-md border bg-white px-3.5 text-sm ring-0 transition-colors outline-none focus:ring-0 disabled:bg-neutral-50", + error + ? "border-rose-300" + : "border-neutral-200 focus:border-neutral-900", + )} + placeholder="you@company.com" + /> + {error ? ( + + ) : null} +
+ + {submitError ? ( +
+ + {submitError} +
+ ) : null} + + +
+
+ ); +}); + +ForgotPasswordForm.displayName = "ForgotPasswordForm"; diff --git a/src/components/osui/forms/login-form.tsx b/src/components/osui/forms/login-form.tsx new file mode 100644 index 000000000..09bad4979 --- /dev/null +++ b/src/components/osui/forms/login-form.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { + forwardRef, + useCallback, + useId, + useState, + type ComponentPropsWithoutRef, + type FormEvent, + type ReactNode, +} from "react"; + +import Link from "next/link"; + +import { AlertCircle, ChevronRight, Eye, EyeOff, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type FormLinkProps = Readonly<{ + href: string; + className?: string; + children: ReactNode; +}>; + +function isPlaceholderHref(href: string): boolean { + return !href || href === "#"; +} + +function FormLink({ href, className, children }: FormLinkProps) { + if (isPlaceholderHref(href)) { + return ( + + ); + } + + return ( + + {children} + + ); +} + +type LoginFormValues = Readonly<{ + email: string; + password: string; + remember: boolean; +}>; + +type LoginFormErrors = Partial>; + +export type LoginFormProps = Readonly< + { + title?: string; + subtitle?: string; + submitLabel?: string; + forgotPasswordLabel?: string; + forgotPasswordHref?: string; + signupPrompt?: string; + signupHref?: string; + signupLabel?: string; + submitErrorMessage?: string; + loading?: boolean; + onSubmit?: (values: LoginFormValues) => void | Promise; + } & Omit, "onSubmit"> +>; + +function validateLogin(values: LoginFormValues): LoginFormErrors { + const errors: LoginFormErrors = {}; + const email = values.email.trim(); + const password = values.password; + + if (!email) errors.email = "Email is required."; + else if (!EMAIL_RE.test(email)) errors.email = "Enter a valid email address."; + + if (!password) errors.password = "Password is required."; + + return errors; +} + +export const LoginForm = forwardRef( + function LoginForm( + { + className, + title = "Welcome back", + subtitle = "Sign in to continue to your account.", + submitLabel = "Sign in", + forgotPasswordLabel = "Forgot password?", + forgotPasswordHref = "/components/forgot-password-form", + signupPrompt = "Don't have an account?", + signupHref = "/components/signup-form", + signupLabel = "Create one", + submitErrorMessage = "We couldn't sign you in. Please try again.", + loading = false, + onSubmit, + onReset, + ...props + }, + ref, + ) { + const formId = useId(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const busy = loading || submitting; + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + + const values: LoginFormValues = { email, password, remember }; + const nextErrors = validateLogin(values); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + setSubmitting(true); + setSubmitError(""); + try { + await onSubmit?.({ + email: email.trim(), + password, + remember, + }); + } catch { + setSubmitError(submitErrorMessage); + } finally { + setSubmitting(false); + } + }, + [busy, email, onSubmit, password, remember, submitErrorMessage], + ); + + const handleReset = useCallback( + (event: FormEvent) => { + onReset?.(event); + if (event.defaultPrevented) return; + setEmail(""); + setPassword(""); + setRemember(false); + setShowPassword(false); + setErrors({}); + setSubmitError(""); + }, + [onReset], + ); + + return ( +
+
+

+ {title} +

+

+ {subtitle} +

+
+ +
+
+ + { + setEmail(event.target.value); + setSubmitError(""); + if (errors.email) + setErrors((prev) => ({ ...prev, email: undefined })); + }} + className={cn( + "h-11 w-full rounded-md border bg-white px-3.5 text-sm text-neutral-900 ring-0 transition-[border-color,background-color] duration-200 outline-none placeholder:text-neutral-400 focus:ring-0 disabled:cursor-not-allowed disabled:bg-neutral-50", + errors.email + ? "border-rose-300 focus:border-rose-400" + : "border-neutral-200 focus:border-neutral-900", + )} + placeholder="you@company.com" + /> + {errors.email ? ( + + ) : null} +
+ +
+
+ + + {forgotPasswordLabel} + +
+
+ { + setPassword(event.target.value); + setSubmitError(""); + if (errors.password) + setErrors((prev) => ({ ...prev, password: undefined })); + }} + className={cn( + "h-11 w-full rounded-md border bg-white py-2 pr-10 pl-3.5 text-sm text-neutral-900 ring-0 transition-[border-color,background-color] duration-200 outline-none placeholder:text-neutral-400 focus:ring-0 disabled:cursor-not-allowed disabled:bg-neutral-50", + errors.password + ? "border-rose-300 focus:border-rose-400" + : "border-neutral-200 focus:border-neutral-900", + )} + placeholder="Enter your password" + /> + +
+ {errors.password ? ( + + ) : null} +
+ + +
+ + {submitError ? ( +
+ + {submitError} +
+ ) : null} + + + +

+ {signupPrompt}{" "} + + {signupLabel} + +

+
+ ); + }, +); + +LoginForm.displayName = "LoginForm"; diff --git a/src/components/osui/forms/signup-form.tsx b/src/components/osui/forms/signup-form.tsx new file mode 100644 index 000000000..5443a5531 --- /dev/null +++ b/src/components/osui/forms/signup-form.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { + forwardRef, + useCallback, + useId, + useState, + type ComponentPropsWithoutRef, + type FormEvent, + type ReactNode, +} from "react"; + +import Link from "next/link"; + +import { AlertCircle, ChevronRight, Eye, EyeOff, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type FormLinkProps = Readonly<{ + href: string; + className?: string; + children: ReactNode; +}>; + +function isPlaceholderHref(href: string): boolean { + return !href || href === "#"; +} + +function FormLink({ href, className, children }: FormLinkProps) { + if (isPlaceholderHref(href)) { + return ( + + ); + } + + return ( + + {children} + + ); +} + +type SignupFormValues = Readonly<{ + name: string; + email: string; + password: string; + confirmPassword: string; + acceptedTerms: boolean; +}>; + +type SignupFormErrors = Partial>; + +export type SignupFormProps = Readonly< + { + title?: string; + subtitle?: string; + submitLabel?: string; + termsHref?: string; + privacyHref?: string; + loginPrompt?: string; + loginHref?: string; + loginLabel?: string; + submitErrorMessage?: string; + loading?: boolean; + onSubmit?: ( + values: Omit, + ) => void | Promise; + } & Omit, "onSubmit"> +>; + +function validateSignup(values: SignupFormValues): SignupFormErrors { + const errors: SignupFormErrors = {}; + const name = values.name.trim(); + const email = values.email.trim(); + + if (name.length < 2) errors.name = "Name must be at least 2 characters."; + if (!email) errors.email = "Email is required."; + else if (!EMAIL_RE.test(email)) errors.email = "Enter a valid email address."; + if (!values.password) errors.password = "Password is required."; + else if (values.password.length < 8) + errors.password = "Use at least 8 characters."; + else if ( + !/\d/.test(values.password) || + !/[^a-zA-Z0-9]/.test(values.password) + ) { + errors.password = "Include a number and a symbol."; + } + if (!values.confirmPassword) + errors.confirmPassword = "Confirm your password."; + else if (values.confirmPassword !== values.password) { + errors.confirmPassword = "Passwords do not match."; + } + if (!values.acceptedTerms) + errors.acceptedTerms = "You must accept the terms to continue."; + + return errors; +} + +export const SignupForm = forwardRef( + function SignupForm( + { + className, + title = "Create your account", + subtitle = "Start building with a free account today.", + submitLabel = "Create account", + termsHref = "#", + privacyHref = "/privacy", + loginPrompt = "Already have an account?", + loginHref = "/components/login-form", + loginLabel = "Sign in", + submitErrorMessage = "We couldn't create your account. Please try again.", + loading = false, + onSubmit, + onReset, + ...props + }, + ref, + ) { + const formId = useId(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [acceptedTerms, setAcceptedTerms] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [errors, setErrors] = useState({}); + const [submitError, setSubmitError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const busy = loading || submitting; + + const clearError = useCallback((key: keyof SignupFormValues) => { + setSubmitError(""); + setErrors((prev) => ({ ...prev, [key]: undefined })); + }, []); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + + const values: SignupFormValues = { + name, + email, + password, + confirmPassword, + acceptedTerms, + }; + const nextErrors = validateSignup(values); + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + setSubmitting(true); + setSubmitError(""); + try { + await onSubmit?.({ + name: name.trim(), + email: email.trim(), + password, + acceptedTerms, + }); + } catch { + setSubmitError(submitErrorMessage); + } finally { + setSubmitting(false); + } + }, + [ + acceptedTerms, + busy, + confirmPassword, + email, + name, + onSubmit, + password, + submitErrorMessage, + ], + ); + + const handleReset = useCallback( + (event: FormEvent) => { + onReset?.(event); + if (event.defaultPrevented) return; + setName(""); + setEmail(""); + setPassword(""); + setConfirmPassword(""); + setAcceptedTerms(false); + setShowPassword(false); + setErrors({}); + setSubmitError(""); + }, + [onReset], + ); + + return ( +
+
+

+ {title} +

+

+ {subtitle} +

+
+ +
+
+ + { + setName(event.target.value); + clearError("name"); + }} + className={cn( + "h-11 w-full rounded-md border bg-white px-3.5 text-sm ring-0 transition-colors outline-none focus:ring-0 disabled:bg-neutral-50", + errors.name + ? "border-rose-300" + : "border-neutral-200 focus:border-neutral-900", + )} + placeholder="Jane Cooper" + /> + {errors.name ? ( + + ) : null} +
+ +
+ + { + setEmail(event.target.value); + clearError("email"); + }} + className={cn( + "h-11 w-full rounded-md border bg-white px-3.5 text-sm ring-0 transition-colors outline-none focus:ring-0 disabled:bg-neutral-50", + errors.email + ? "border-rose-300" + : "border-neutral-200 focus:border-neutral-900", + )} + placeholder="you@company.com" + /> + {errors.email ? ( + + ) : null} +
+ +
+ +
+ { + setPassword(event.target.value); + clearError("password"); + }} + className={cn( + "h-11 w-full rounded-md border bg-white py-2 pr-10 pl-3.5 text-sm ring-0 transition-colors outline-none focus:ring-0 disabled:bg-neutral-50", + errors.password + ? "border-rose-300" + : "border-neutral-200 focus:border-neutral-900", + )} + placeholder="Min. 8 characters" + /> + +
+ {errors.password ? ( + + ) : null} +
+ +
+ + { + setConfirmPassword(event.target.value); + clearError("confirmPassword"); + }} + className={cn( + "h-11 w-full rounded-md border bg-white px-3.5 text-sm ring-0 transition-colors outline-none focus:ring-0 disabled:bg-neutral-50", + errors.confirmPassword + ? "border-rose-300" + : "border-neutral-200 focus:border-neutral-900", + )} + placeholder="Re-enter password" + /> + {errors.confirmPassword ? ( + + ) : null} +
+ +
+ { + setAcceptedTerms(event.target.checked); + clearError("acceptedTerms"); + }} + className="mt-0.5 size-4 cursor-pointer accent-neutral-900 disabled:cursor-not-allowed" + /> +
+ + + Terms + {" "} + and{" "} + + Privacy Policy + +
+
+ {errors.acceptedTerms ? ( + + ) : null} +
+ + {submitError ? ( +
+ + {submitError} +
+ ) : null} + + + +

+ {loginPrompt}{" "} + + {loginLabel} + +

+
+ ); + }, +); + +SignupForm.displayName = "SignupForm"; diff --git a/src/components/osui/inputs/checkbox-field-input.tsx b/src/components/osui/inputs/checkbox-field-input.tsx new file mode 100644 index 000000000..5e19d803d --- /dev/null +++ b/src/components/osui/inputs/checkbox-field-input.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { + forwardRef, + useCallback, + useId, + useState, + type ChangeEvent, + type ComponentPropsWithoutRef, +} from "react"; + +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type CheckboxFieldInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + containerClassName?: string; + onCheckedChange?: ( + checked: boolean, + event: ChangeEvent, + ) => void; + } & Omit, "size" | "type" | "onChange"> +>; + +export const CheckboxFieldInput = forwardRef< + HTMLInputElement, + CheckboxFieldInputProps +>(function CheckboxFieldInput( + { + className, + containerClassName, + id, + label = "I agree to the terms and privacy policy", + hint, + error = false, + errorMessage = "You must accept before continuing.", + disabled, + required, + checked, + defaultChecked = false, + onCheckedChange, + ...props + }, + ref, +) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + + const isControlled = checked !== undefined; + const [internal, setInternal] = useState(defaultChecked); + const isChecked = isControlled ? checked : internal; + + const handleChange = useCallback( + (event: ChangeEvent) => { + const next = event.target.checked; + if (!isControlled) setInternal(next); + onCheckedChange?.(next, event); + }, + [isControlled, onCheckedChange], + ); + + return ( +
+ + + {error ? ( + + ) : null} +
+ ); +}); + +CheckboxFieldInput.displayName = "CheckboxFieldInput"; diff --git a/src/components/osui/inputs/combobox-field-input.tsx b/src/components/osui/inputs/combobox-field-input.tsx new file mode 100644 index 000000000..c407887fa --- /dev/null +++ b/src/components/osui/inputs/combobox-field-input.tsx @@ -0,0 +1,466 @@ +"use client"; + +import { + forwardRef, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type ChangeEvent, + type ComponentPropsWithoutRef, + type KeyboardEvent, + type MutableRefObject, + type Ref, +} from "react"; + +import { Check, ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type ComboboxFieldOption = Readonly<{ + value: string; + label: string; + disabled?: boolean; +}>; + +export type ComboboxFieldInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + placeholder?: string; + emptyMessage?: string; + options?: readonly ComboboxFieldOption[]; + containerClassName?: string; + name?: string; + onValueChange?: (value: string) => void; + } & Omit< + ComponentPropsWithoutRef<"input">, + "size" | "type" | "value" | "defaultValue" | "onChange" | "role" + > +> & { + value?: string; + defaultValue?: string; + onChange?: (event: ChangeEvent) => void; +}; + +const DEFAULT_OPTIONS: readonly ComboboxFieldOption[] = [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + { value: "svelte", label: "Svelte" }, + { value: "angular", label: "Angular" }, + { value: "solid", label: "Solid" }, + { value: "next", label: "Next.js" }, +]; + +function mergeRefs(...refs: Array | undefined>) { + return (node: T | null) => { + for (const ref of refs) { + if (!ref) continue; + if (typeof ref === "function") ref(node); + else (ref as MutableRefObject).current = node; + } + }; +} + +function findNextEnabledIndex( + options: readonly ComboboxFieldOption[], + start: number, + direction: 1 | -1, +): number { + const count = options.length; + if (count === 0) return -1; + + for (let step = 1; step <= count; step += 1) { + const index = (start + direction * step + count) % count; + if (!options[index]?.disabled) return index; + } + + return -1; +} + +export const ComboboxFieldInput = forwardRef< + HTMLInputElement, + ComboboxFieldInputProps +>(function ComboboxFieldInput( + { + className, + containerClassName, + id, + label = "Framework", + hint, + error = false, + errorMessage = "Select a valid option.", + placeholder = "Search frameworks…", + emptyMessage = "No results found.", + options = DEFAULT_OPTIONS, + disabled, + required, + name, + value, + defaultValue = "", + onChange, + onValueChange, + onFocus, + onBlur, + onKeyDown, + ...props + }, + ref, +) { + const generatedId = useId(); + const fieldId = id ?? generatedId; + const listboxId = `${fieldId}-listbox`; + const hintId = `${fieldId}-hint`; + const errorId = `${fieldId}-error`; + const rootRef = useRef(null); + const inputRef = useRef(null); + const blurTimerRef = useRef(null); + + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(String(defaultValue)); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [highlighted, setHighlighted] = useState(-1); + + const currentValue = isControlled ? String(value ?? "") : internalValue; + const selectedOption = options.find( + (option) => option.value === currentValue, + ); + + const filtered = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return options; + return options.filter((option) => + option.label.toLowerCase().includes(normalized), + ); + }, [options, query]); + + const displayValue = open ? query : (selectedOption?.label ?? query); + + const emitChange = useCallback( + (next: string) => { + if (!isControlled) setInternalValue(next); + onValueChange?.(next); + onChange?.({ target: { value: next } } as ChangeEvent); + }, + [isControlled, onChange, onValueChange], + ); + + const close = useCallback(() => { + if (blurTimerRef.current !== null) { + window.clearTimeout(blurTimerRef.current); + blurTimerRef.current = null; + } + setOpen(false); + setHighlighted(-1); + setQuery(selectedOption?.label ?? ""); + }, [selectedOption?.label]); + + const openList = useCallback(() => { + if (disabled) return; + setOpen(true); + setQuery(""); + const index = filtered.findIndex((option) => option.value === currentValue); + if (index >= 0 && !filtered[index]?.disabled) { + setHighlighted(index); + return; + } + const firstEnabled = filtered.findIndex((option) => !option.disabled); + setHighlighted(firstEnabled); + }, [currentValue, disabled, filtered]); + + const selectOption = useCallback( + (option: ComboboxFieldOption) => { + if (option.disabled) return; + emitChange(option.value); + setQuery(option.label); + setOpen(false); + setHighlighted(-1); + }, + [emitChange], + ); + + useEffect(() => { + if (!open) return; + + function handlePointerDown(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) close(); + } + + function handleEscape(event: globalThis.KeyboardEvent) { + if (event.key === "Escape") { + close(); + inputRef.current?.focus(); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [close, open]); + + useEffect(() => { + if (disabled && open) close(); + }, [close, disabled, open]); + + useEffect(() => { + inputRef.current?.setCustomValidity( + required && !currentValue ? errorMessage : "", + ); + }, [currentValue, errorMessage, required]); + + useEffect(() => { + return () => { + if (blurTimerRef.current !== null) + window.clearTimeout(blurTimerRef.current); + }; + }, []); + + useEffect(() => { + if (!open) return; + if (highlighted >= filtered.length) { + const firstEnabled = filtered.findIndex((option) => !option.disabled); + setHighlighted(firstEnabled); + } + }, [filtered, highlighted, open]); + + const handleInputChange = useCallback( + (event: ChangeEvent) => { + const nextQuery = event.target.value; + setQuery(nextQuery); + setOpen(true); + const normalized = nextQuery.trim().toLowerCase(); + const nextOptions = normalized + ? options.filter((option) => + option.label.toLowerCase().includes(normalized), + ) + : options; + setHighlighted(nextOptions.findIndex((option) => !option.disabled)); + if (!nextQuery.trim()) emitChange(""); + }, + [emitChange, options], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented || disabled) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + if (!open) { + openList(); + return; + } + const next = findNextEnabledIndex( + filtered, + highlighted < 0 ? -1 : highlighted, + 1, + ); + if (next >= 0) setHighlighted(next); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (!open) { + openList(); + return; + } + const next = findNextEnabledIndex( + filtered, + highlighted < 0 ? filtered.length : highlighted, + -1, + ); + if (next >= 0) setHighlighted(next); + return; + } + + if (event.key === "Enter" && open && highlighted >= 0) { + event.preventDefault(); + const option = filtered[highlighted]; + if (option) selectOption(option); + return; + } + + if (event.key === "Escape" && open) { + event.preventDefault(); + close(); + } + }, + [ + close, + disabled, + filtered, + highlighted, + onKeyDown, + open, + openList, + selectOption, + ], + ); + + return ( +
+ {name ? ( + + ) : null} + + + +
+ = 0 + ? `${listboxId}-option-${highlighted}` + : undefined + } + aria-invalid={error || undefined} + aria-describedby={error ? errorId : hint ? hintId : undefined} + onChange={handleInputChange} + onFocus={(event) => { + onFocus?.(event); + if (!disabled) openList(); + }} + onBlur={(event) => { + onBlur?.(event); + if (event.defaultPrevented) return; + blurTimerRef.current = window.setTimeout(() => close(), 120); + }} + onInvalid={() => { + inputRef.current?.focus(); + }} + onKeyDown={handleKeyDown} + className={cn( + "h-10 w-full rounded-lg border bg-white py-2 pr-10 pl-3.5 font-sans text-sm text-neutral-900 ring-0 transition-[border-color] duration-200 outline-none placeholder:text-neutral-400 focus:ring-0 disabled:cursor-not-allowed disabled:bg-neutral-50 disabled:text-neutral-400", + error + ? "border-rose-300 focus:border-rose-400" + : open + ? "border-neutral-900" + : "border-neutral-200 focus:border-neutral-900", + className, + )} + {...props} + /> + + +
+ + {open ? ( +
    + {filtered.length === 0 ? ( +
  • + {emptyMessage} +
  • + ) : ( + filtered.map((option, index) => { + const isSelected = option.value === currentValue; + const isHighlighted = index === highlighted; + + return ( +
  • { + if (!option.disabled) setHighlighted(index); + }} + onMouseDown={(event) => event.preventDefault()} + onClick={() => selectOption(option)} + className={cn( + "flex cursor-pointer items-center justify-between gap-2 px-3.5 py-2 text-sm transition-colors duration-150", + option.disabled && "cursor-not-allowed text-neutral-300", + !option.disabled && + isHighlighted && + "bg-neutral-50 text-neutral-900", + !option.disabled && !isHighlighted && "text-neutral-700", + isSelected && + !option.disabled && + "font-medium text-neutral-900", + )} + > + {option.label} + {isSelected ? ( + + ) : null} +
  • + ); + }) + )} +
+ ) : null} + + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +}); + +ComboboxFieldInput.displayName = "ComboboxFieldInput"; diff --git a/src/components/osui/inputs/date-field-input.tsx b/src/components/osui/inputs/date-field-input.tsx new file mode 100644 index 000000000..8cb5add93 --- /dev/null +++ b/src/components/osui/inputs/date-field-input.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { + forwardRef, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type ChangeEvent, + type ComponentPropsWithoutRef, + type KeyboardEvent, + type MutableRefObject, + type Ref, +} from "react"; + +import { Calendar, ChevronLeft, ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type DateFieldInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + placeholder?: string; + containerClassName?: string; + openPickerLabel?: string; + name?: string; + required?: boolean; + onValueChange?: (value: string) => void; + } & Omit< + ComponentPropsWithoutRef<"button">, + "size" | "type" | "value" | "defaultValue" | "onChange" | "children" + > +> & { + value?: string; + defaultValue?: string; + min?: string; + max?: string; + onChange?: (event: ChangeEvent) => void; +}; + +const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; + +function mergeRefs(...refs: Array | undefined>) { + return (node: T | null) => { + for (const ref of refs) { + if (!ref) continue; + if (typeof ref === "function") ref(node); + else (ref as MutableRefObject).current = node; + } + }; +} + +function parseIsoDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + return date; +} + +function toIsoDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function formatDisplayDate(value: string): string { + const date = parseIsoDate(value); + if (!date) return value; + return date.toLocaleDateString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function addMonths(date: Date, count: number): Date { + return new Date(date.getFullYear(), date.getMonth() + count, 1); +} + +function buildMonthGrid(month: Date): Date[] { + const first = startOfMonth(month); + const start = new Date(first); + start.setDate(first.getDate() - first.getDay()); + + return Array.from({ length: 42 }, (_, index) => { + const day = new Date(start); + day.setDate(start.getDate() + index); + return day; + }); +} + +function isDisabledDay(day: Date, min?: string, max?: string): boolean { + const minDate = min ? parseIsoDate(min) : null; + const maxDate = max ? parseIsoDate(max) : null; + const time = new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + ).getTime(); + if (minDate && time < minDate.getTime()) return true; + if (maxDate && time > maxDate.getTime()) return true; + return false; +} + +export const DateFieldInput = forwardRef< + HTMLButtonElement, + DateFieldInputProps +>(function DateFieldInput( + { + className, + containerClassName, + id, + label = "Date of birth", + hint, + error = false, + errorMessage = "Enter a valid date.", + placeholder = "Select a date", + openPickerLabel = "Open calendar", + disabled, + required, + name, + value, + defaultValue = "", + min, + max, + onChange, + onValueChange, + onClick, + onBlur, + onKeyDown, + ...props + }, + ref, +) { + const generatedId = useId(); + const fieldId = id ?? generatedId; + const calendarId = `${fieldId}-calendar`; + const hintId = `${fieldId}-hint`; + const errorId = `${fieldId}-error`; + const rootRef = useRef(null); + const triggerRef = useRef(null); + const today = useMemo(() => new Date(), []); + + const isControlled = value !== undefined; + const [internal, setInternal] = useState(String(defaultValue)); + const [open, setOpen] = useState(false); + const [viewMonth, setViewMonth] = useState(() => { + const initial = parseIsoDate( + isControlled ? String(value ?? "") : String(defaultValue), + ); + return startOfMonth(initial ?? today); + }); + + const current = isControlled ? String(value ?? "") : internal; + const selectedDate = parseIsoDate(current); + const hasValue = current.length > 0; + const monthDays = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]); + + const monthLabel = viewMonth.toLocaleDateString(undefined, { + month: "long", + year: "numeric", + }); + + const emitChange = useCallback( + (next: string) => { + if (!isControlled) setInternal(next); + onValueChange?.(next); + onChange?.({ target: { value: next } } as ChangeEvent); + }, + [isControlled, onChange, onValueChange], + ); + + const close = useCallback(() => setOpen(false), []); + + const selectDay = useCallback( + (day: Date) => { + if (isDisabledDay(day, min, max)) return; + const next = toIsoDate(day); + emitChange(next); + close(); + triggerRef.current?.focus(); + }, + [close, emitChange, max, min], + ); + + useEffect(() => { + if (!open) return; + + function handlePointerDown(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) close(); + } + + function handleEscape(event: globalThis.KeyboardEvent) { + if (event.key === "Escape") { + close(); + triggerRef.current?.focus(); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [close, open]); + + const handleTriggerKeyDown = useCallback( + (event: KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented || disabled) return; + if ( + event.key === "Enter" || + event.key === " " || + event.key === "ArrowDown" + ) { + event.preventDefault(); + if (!open) { + if (selectedDate) setViewMonth(startOfMonth(selectedDate)); + setOpen(true); + } + } + }, + [disabled, onKeyDown, open, selectedDate], + ); + + return ( +
+ { + event.preventDefault(); + triggerRef.current?.focus(); + }} + className="sr-only" + readOnly + /> + + + + + + {open ? ( + + ) : null} + + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +}); + +DateFieldInput.displayName = "DateFieldInput"; diff --git a/src/components/osui/inputs/file-upload-field-input.tsx b/src/components/osui/inputs/file-upload-field-input.tsx new file mode 100644 index 000000000..285c9da7a --- /dev/null +++ b/src/components/osui/inputs/file-upload-field-input.tsx @@ -0,0 +1,578 @@ +"use client"; + +import { + forwardRef, + useCallback, + useEffect, + useId, + useRef, + useState, + type ChangeEvent, + type DragEvent, +} from "react"; + +import { FileText, ImagePlus, Plus, X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type FileUploadFieldInputProps = Readonly<{ + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + browseLabel?: string; + dropLabel?: string; + replaceLabel?: string; + accept?: string; + multiple?: boolean; + maxFiles?: number; + maxSizeBytes?: number; + disabled?: boolean; + required?: boolean; + name?: string; + containerClassName?: string; + onFilesChange?: (files: File[]) => void; +}>; + +type FileEntry = Readonly<{ + id: string; + file: File; + previewUrl?: string; +}>; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function isImageFile(file: File): boolean { + return ( + file.type.startsWith("image/") || + /\.(png|jpe?g|gif|webp|svg)$/i.test(file.name) + ); +} + +function isPdfFile(file: File): boolean { + return file.type === "application/pdf" || /\.pdf$/i.test(file.name); +} + +function parseAcceptTokens(accept: string): string[] { + return accept + .split(",") + .map((part) => part.trim().replace(/^\./, "").toUpperCase()) + .filter(Boolean); +} + +function fileMatchesAccept(file: File, accept: string): boolean { + const tokens = accept + .split(",") + .map((token) => token.trim().toLowerCase()) + .filter(Boolean); + if (tokens.length === 0) return true; + + const fileName = file.name.toLowerCase(); + const fileType = file.type.toLowerCase(); + return tokens.some((token) => { + if (token.startsWith(".")) return fileName.endsWith(token); + if (token.endsWith("/*")) return fileType.startsWith(token.slice(0, -1)); + return fileType === token; + }); +} + +function createEntry(file: File): FileEntry { + return { + id: `${file.name}-${file.size}-${file.lastModified}`, + file, + previewUrl: isImageFile(file) ? URL.createObjectURL(file) : undefined, + }; +} + +function revokeEntryPreview(entry: FileEntry) { + if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl); +} + +export const FileUploadFieldInput = forwardRef< + HTMLInputElement, + FileUploadFieldInputProps +>(function FileUploadFieldInput( + { + label = "Upload document", + hint = "PDF, PNG, or JPG up to 5 MB.", + error = false, + errorMessage = "Could not upload this file.", + browseLabel = "Choose file", + dropLabel = "Drop your file here", + replaceLabel = "Replace file", + accept = ".pdf,.png,.jpg,.jpeg", + multiple = false, + maxFiles = 3, + maxSizeBytes = 5 * 1024 * 1024, + disabled = false, + required = false, + name, + containerClassName, + onFilesChange, + }, + ref, +) { + const generatedId = useId(); + const inputId = `${generatedId}-file`; + const hintId = `${generatedId}-hint`; + const errorId = `${generatedId}-error`; + const localRef = useRef(null); + const entriesRef = useRef([]); + const [entries, setEntries] = useState([]); + const [dragging, setDragging] = useState(false); + const [localError, setLocalError] = useState(""); + const safeMaxFiles = Number.isFinite(maxFiles) + ? Math.max(1, Math.floor(maxFiles)) + : 3; + const safeMaxSizeBytes = Number.isFinite(maxSizeBytes) + ? Math.max(0, maxSizeBytes) + : 5 * 1024 * 1024; + + entriesRef.current = entries; + + const mergeRef = useCallback( + (node: HTMLInputElement | null) => { + localRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); + + useEffect(() => { + return () => { + for (const entry of entriesRef.current) revokeEntryPreview(entry); + }; + }, []); + + useEffect(() => { + const form = localRef.current?.form; + if (!form) return; + + const handleReset = () => { + for (const entry of entriesRef.current) revokeEntryPreview(entry); + entriesRef.current = []; + setEntries([]); + setLocalError(""); + setDragging(false); + onFilesChange?.([]); + }; + + form.addEventListener("reset", handleReset); + return () => form.removeEventListener("reset", handleReset); + }, [onFilesChange]); + + const validateAndAdd = useCallback( + (incoming: File[]) => { + setLocalError(""); + const validFiles: File[] = []; + + for (const file of incoming) { + if (!fileMatchesAccept(file, accept)) { + setLocalError(`"${file.name}" is not an accepted file type.`); + continue; + } + if (file.size > safeMaxSizeBytes) { + setLocalError(`"${file.name}" exceeds the size limit.`); + continue; + } + validFiles.push(file); + } + + if (validFiles.length === 0) return; + + const previous = entriesRef.current; + const existingIds = new Set(previous.map((entry) => entry.id)); + const uniqueFiles = validFiles.filter( + (file) => + !existingIds.has(`${file.name}-${file.size}-${file.lastModified}`), + ); + const availableSlots = multiple + ? Math.max(0, safeMaxFiles - previous.length) + : 1; + const filesToAdd = uniqueFiles.slice(0, availableSlots); + + if (uniqueFiles.length > availableSlots) { + setLocalError( + `You can upload up to ${safeMaxFiles} ${safeMaxFiles === 1 ? "file" : "files"}.`, + ); + } + if (filesToAdd.length === 0) return; + + const nextEntries = filesToAdd.map(createEntry); + if (!multiple) { + for (const entry of previous) revokeEntryPreview(entry); + } + + const next = multiple ? [...previous, ...nextEntries] : nextEntries; + entriesRef.current = next; + setEntries(next); + onFilesChange?.(next.map((entry) => entry.file)); + }, + [accept, multiple, onFilesChange, safeMaxFiles, safeMaxSizeBytes], + ); + + useEffect(() => { + if (localRef.current && typeof DataTransfer !== "undefined") { + const transfer = new DataTransfer(); + for (const entry of entries) transfer.items.add(entry.file); + localRef.current.files = transfer.files; + } + }, [entries]); + + const openPicker = useCallback(() => { + if (!disabled) localRef.current?.click(); + }, [disabled]); + + const handleInputChange = useCallback( + (event: ChangeEvent) => { + const list = event.target.files; + if (!list?.length) return; + validateAndAdd(Array.from(list)); + event.target.value = ""; + }, + [validateAndAdd], + ); + + const bindDrag = useCallback( + (active: boolean) => { + if (!disabled) setDragging(active); + }, + [disabled], + ); + + const handleDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + setDragging(false); + if (disabled) return; + const list = event.dataTransfer.files; + if (!list.length) return; + validateAndAdd(Array.from(list)); + }, + [disabled, validateAndAdd], + ); + + const removeFile = useCallback( + (id: string) => { + const previous = entriesRef.current; + const target = previous.find((entry) => entry.id === id); + if (target) revokeEntryPreview(target); + const next = previous.filter((entry) => entry.id !== id); + entriesRef.current = next; + setEntries(next); + onFilesChange?.(next.map((entry) => entry.file)); + setLocalError(""); + }, + [onFilesChange], + ); + + const showError = error || Boolean(localError); + const message = localError || errorMessage; + const hasFiles = entries.length > 0; + const acceptTokens = parseAcceptTokens(accept); + const maxSizeLabel = formatFileSize(safeMaxSizeBytes); + const primaryEntry = entries[0]; + const showSinglePreview = + !multiple && primaryEntry && isImageFile(primaryEntry.file); + + return ( +
+ + + + +
+ {!hasFiles ? ( +
+
{ + if (disabled) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openPicker(); + } + }} + onClick={openPicker} + onDragEnter={(event) => { + event.preventDefault(); + bindDrag(true); + }} + onDragOver={(event) => { + event.preventDefault(); + bindDrag(true); + }} + onDragLeave={(event) => { + event.preventDefault(); + if ( + !event.currentTarget.contains(event.relatedTarget as Node) + ) { + bindDrag(false); + } + }} + onDrop={handleDrop} + className={cn( + "group relative aspect-[4/3] cursor-pointer overflow-hidden rounded-xl border ring-0 transition-[border-color,background-color] duration-200 outline-none focus:border-neutral-900 focus:ring-0", + dragging && !showError && "border-neutral-900 bg-neutral-50", + showError && "border-rose-300 bg-rose-50/40", + !dragging && + !showError && + "border-neutral-100 bg-neutral-50 hover:border-neutral-300 hover:bg-neutral-50/80", + disabled && "cursor-not-allowed opacity-60", + )} + > +
+ {Array.from({ length: 9 }, (_, index) => ( +
+ ))} +
+ + + + + + +
+ + + + + + {dragging ? "Release to upload" : dropLabel} + + + Drag & drop or click anywhere + +
+
+ +
+
+

+ No file selected +

+

+ {acceptTokens.join(", ")} · max {maxSizeLabel} + {multiple ? ` · up to ${safeMaxFiles} files` : ""} +

+
+ + {!disabled ? ( + + ) : null} +
+ +
+ {acceptTokens.map((token) => ( + + {token} + + ))} +
+
+ ) : showSinglePreview && primaryEntry ? ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {primaryEntry.file.name} + {!disabled ? ( + + ) : null} +
+ +
+
+

+ {primaryEntry.file.name} +

+

+ {formatFileSize(primaryEntry.file.size)} · Ready to upload +

+
+ {!disabled ? ( + + ) : null} +
+
+ ) : ( +
+
    + {entries.map((entry) => ( +
  • + {entry.previewUrl ? ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + +
    + ) : ( + + {isPdfFile(entry.file) ? ( + + ) : ( + + )} + + )} + +
    +

    + {entry.file.name} +

    +

    + {formatFileSize(entry.file.size)} +

    +
    + + {!disabled ? ( + + ) : null} +
  • + ))} +
+ + {multiple && entries.length < safeMaxFiles && !disabled ? ( + + ) : null} + + {!multiple && !disabled ? ( + + ) : null} +
+ )} +
+ + {showError ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +}); + +FileUploadFieldInput.displayName = "FileUploadFieldInput"; diff --git a/src/components/osui/inputs/input-group-field.tsx b/src/components/osui/inputs/input-group-field.tsx new file mode 100644 index 000000000..afca941ed --- /dev/null +++ b/src/components/osui/inputs/input-group-field.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { + forwardRef, + useId, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; + +import { cn } from "@/lib/utils"; + +export type InputGroupFieldProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + prefix?: ReactNode; + suffix?: ReactNode; + containerClassName?: string; + } & Omit, "size"> +>; + +export const InputGroupField = forwardRef< + HTMLInputElement, + InputGroupFieldProps +>(function InputGroupField( + { + className, + containerClassName, + id, + label = "Website", + hint = "Include the path after your domain.", + error = false, + errorMessage = "Enter a valid URL.", + prefix = "https://", + suffix, + disabled, + required, + type = "text", + placeholder = "yoursite.com", + ...props + }, + ref, +) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + + return ( +
+ + +
+ {prefix ? ( + + {prefix} + + ) : null} + + + + {suffix ? ( + + {suffix} + + ) : null} +
+ + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +}); + +InputGroupField.displayName = "InputGroupField"; diff --git a/src/components/osui/inputs/search-input.tsx b/src/components/osui/inputs/search-input.tsx new file mode 100644 index 000000000..97922eaaf --- /dev/null +++ b/src/components/osui/inputs/search-input.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { + forwardRef, + useCallback, + useEffect, + useId, + useRef, + useState, + type ChangeEvent, + type ComponentPropsWithoutRef, +} from "react"; + +import { Search, X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type SearchInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + clearLabel?: string; + containerClassName?: string; + onClear?: () => void; + } & Omit, "size" | "type" | "onChange"> +> & { + onChange?: (value: string, event: ChangeEvent) => void; +}; + +export const SearchInput = forwardRef( + function SearchInput( + { + className, + containerClassName, + id, + label = "Search", + hint, + error = false, + errorMessage = "Enter a valid search term.", + clearLabel = "Clear search", + disabled, + value, + defaultValue = "", + placeholder = "Search…", + onChange, + onClear, + ...props + }, + ref, + ) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + const localRef = useRef(null); + + const isControlled = value !== undefined; + const [internal, setInternal] = useState(String(defaultValue)); + const current = isControlled ? String(value) : internal; + const hasValue = current.length > 0; + + const handleChange = useCallback( + (event: ChangeEvent) => { + const next = event.target.value; + if (!isControlled) setInternal(next); + onChange?.(next, event); + }, + [isControlled, onChange], + ); + + const handleClear = useCallback(() => { + if (!isControlled) setInternal(""); + onClear?.(); + localRef.current?.focus(); + }, [isControlled, onClear]); + + const setRefs = useCallback( + (node: HTMLInputElement | null) => { + localRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); + + useEffect(() => { + if (isControlled) return; + const form = localRef.current?.form; + if (!form) return; + const handleReset = () => setInternal(String(defaultValue)); + form.addEventListener("reset", handleReset); + return () => form.removeEventListener("reset", handleReset); + }, [defaultValue, isControlled]); + + return ( +
+ + +
+ + + + + {hasValue && !disabled ? ( + + ) : null} +
+ + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); + }, +); + +SearchInput.displayName = "SearchInput"; diff --git a/src/components/osui/inputs/select-field-input.tsx b/src/components/osui/inputs/select-field-input.tsx new file mode 100644 index 000000000..62429b631 --- /dev/null +++ b/src/components/osui/inputs/select-field-input.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { + forwardRef, + useCallback, + useEffect, + useId, + useRef, + useState, + type ChangeEvent, + type ComponentPropsWithoutRef, + type KeyboardEvent, + type MutableRefObject, + type Ref, +} from "react"; + +import { Check, ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +export type SelectFieldOption = Readonly<{ + value: string; + label: string; + disabled?: boolean; +}>; + +export type SelectFieldInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + placeholder?: string; + options?: readonly SelectFieldOption[]; + containerClassName?: string; + name?: string; + required?: boolean; + onValueChange?: (value: string) => void; + } & Omit< + ComponentPropsWithoutRef<"button">, + "size" | "type" | "value" | "defaultValue" | "onChange" | "children" + > +> & { + value?: string; + defaultValue?: string; + onChange?: (event: ChangeEvent) => void; +}; + +const DEFAULT_OPTIONS: readonly SelectFieldOption[] = [ + { value: "us", label: "United States" }, + { value: "uk", label: "United Kingdom" }, + { value: "ca", label: "Canada" }, + { value: "au", label: "Australia" }, + { value: "de", label: "Germany" }, +]; + +function mergeRefs(...refs: Array | undefined>) { + return (node: T | null) => { + for (const ref of refs) { + if (!ref) continue; + if (typeof ref === "function") ref(node); + else (ref as MutableRefObject).current = node; + } + }; +} + +function findNextEnabledIndex( + options: readonly SelectFieldOption[], + start: number, + direction: 1 | -1, +): number { + const count = options.length; + if (count === 0) return -1; + + for (let step = 1; step <= count; step += 1) { + const index = (start + direction * step + count) % count; + if (!options[index]?.disabled) return index; + } + + return -1; +} + +export const SelectFieldInput = forwardRef< + HTMLButtonElement, + SelectFieldInputProps +>(function SelectFieldInput( + { + className, + containerClassName, + id, + label = "Country", + hint, + error = false, + errorMessage = "Please select an option.", + placeholder = "Select an option", + options = DEFAULT_OPTIONS, + disabled, + required, + name, + value, + defaultValue = "", + onChange, + onValueChange, + onClick, + onBlur, + onKeyDown, + ...props + }, + ref, +) { + const generatedId = useId(); + const fieldId = id ?? generatedId; + const listboxId = `${fieldId}-listbox`; + const hintId = `${fieldId}-hint`; + const errorId = `${fieldId}-error`; + const rootRef = useRef(null); + const triggerRef = useRef(null); + + const isControlled = value !== undefined; + const [internal, setInternal] = useState(String(defaultValue)); + const [open, setOpen] = useState(false); + const [highlighted, setHighlighted] = useState(-1); + + const current = isControlled ? String(value ?? "") : internal; + const selectedOption = options.find((option) => option.value === current); + const hasValue = current.length > 0; + + const emitChange = useCallback( + (next: string) => { + if (!isControlled) setInternal(next); + onValueChange?.(next); + onChange?.({ target: { value: next } } as ChangeEvent); + }, + [isControlled, onChange, onValueChange], + ); + + const close = useCallback(() => { + setOpen(false); + setHighlighted(-1); + }, []); + + const openList = useCallback(() => { + if (disabled) return; + setOpen(true); + const selectedIndex = options.findIndex( + (option) => option.value === current, + ); + if (selectedIndex >= 0 && !options[selectedIndex]?.disabled) { + setHighlighted(selectedIndex); + return; + } + const firstEnabled = options.findIndex((option) => !option.disabled); + setHighlighted(firstEnabled); + }, [current, disabled, options]); + + const selectAt = useCallback( + (index: number) => { + const option = options[index]; + if (!option || option.disabled) return; + emitChange(option.value); + close(); + triggerRef.current?.focus(); + }, + [close, emitChange, options], + ); + + useEffect(() => { + if (!open) return; + + function handlePointerDown(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) close(); + } + + function handleEscape(event: globalThis.KeyboardEvent) { + if (event.key === "Escape") { + close(); + triggerRef.current?.focus(); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleEscape); + }; + }, [close, open]); + + const handleTriggerKeyDown = useCallback( + (event: KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented || disabled) return; + + if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Enter" || + event.key === " " + ) { + event.preventDefault(); + if (!open) { + openList(); + return; + } + } + + if (!open) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + const next = findNextEnabledIndex( + options, + highlighted < 0 ? -1 : highlighted, + 1, + ); + if (next >= 0) setHighlighted(next); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + const next = findNextEnabledIndex( + options, + highlighted < 0 ? options.length : highlighted, + -1, + ); + if (next >= 0) setHighlighted(next); + return; + } + + if (event.key === "Home") { + event.preventDefault(); + const next = findNextEnabledIndex(options, -1, 1); + if (next >= 0) setHighlighted(next); + return; + } + + if (event.key === "End") { + event.preventDefault(); + const next = findNextEnabledIndex(options, options.length, -1); + if (next >= 0) setHighlighted(next); + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (highlighted >= 0) selectAt(highlighted); + } + }, + [disabled, highlighted, onKeyDown, open, openList, options, selectAt], + ); + + return ( +
+ {name || required ? ( + + ) : null} + + + + + + {open ? ( +
    + {options.map((option, index) => { + const isSelected = option.value === current; + const isHighlighted = index === highlighted; + + return ( +
  • { + if (!option.disabled) setHighlighted(index); + }} + onMouseDown={(event) => event.preventDefault()} + onClick={() => selectAt(index)} + className={cn( + "flex cursor-pointer items-center justify-between gap-2 px-3.5 py-2 text-sm transition-colors duration-150", + option.disabled && "cursor-not-allowed text-neutral-300", + !option.disabled && + isHighlighted && + "bg-neutral-50 text-neutral-900", + !option.disabled && !isHighlighted && "text-neutral-700", + isSelected && + !option.disabled && + "font-medium text-neutral-900", + )} + > + {option.label} + {isSelected ? ( + + ) : null} +
  • + ); + })} +
+ ) : null} + + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +}); + +SelectFieldInput.displayName = "SelectFieldInput"; diff --git a/src/components/osui/inputs/switch-field-input.tsx b/src/components/osui/inputs/switch-field-input.tsx new file mode 100644 index 000000000..74bb3430c --- /dev/null +++ b/src/components/osui/inputs/switch-field-input.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { + forwardRef, + useCallback, + useId, + useState, + type ComponentPropsWithoutRef, + type KeyboardEvent, +} from "react"; + +import { cn } from "@/lib/utils"; + +export type SwitchFieldInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + containerClassName?: string; + onCheckedChange?: (checked: boolean) => void; + } & Omit< + ComponentPropsWithoutRef<"button">, + "size" | "type" | "role" | "onChange" | "defaultChecked" + > +> & { + checked?: boolean; + defaultChecked?: boolean; + disabled?: boolean; + required?: boolean; + name?: string; +}; + +export const SwitchFieldInput = forwardRef< + HTMLButtonElement, + SwitchFieldInputProps +>(function SwitchFieldInput( + { + className, + containerClassName, + id, + label = "Email notifications", + hint = "Receive product updates and release notes.", + error = false, + errorMessage = "This setting is required.", + disabled = false, + required, + checked, + defaultChecked = false, + name, + onCheckedChange, + onClick, + onKeyDown, + ...props + }, + ref, +) { + const generatedId = useId(); + const switchId = id ?? generatedId; + const hintId = `${switchId}-hint`; + const errorId = `${switchId}-error`; + + const isControlled = checked !== undefined; + const [internal, setInternal] = useState(defaultChecked); + const isOn = isControlled ? checked : internal; + + const toggle = useCallback(() => { + if (disabled) return; + const next = !isOn; + if (!isControlled) setInternal(next); + onCheckedChange?.(next); + }, [disabled, isControlled, isOn, onCheckedChange]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + onKeyDown?.(event); + if (event.defaultPrevented || disabled) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(); + } + }, + [disabled, onKeyDown, toggle], + ); + + return ( +
+ {name || required ? ( + undefined} + onInvalid={(event) => { + event.preventDefault(); + event.currentTarget.parentElement + ?.querySelector('[role="switch"]') + ?.focus(); + }} + className="sr-only" + /> + ) : null} + +
+
+

+ {label} + {required ? ( + + * + + ) : null} +

+ {hint ? ( +

+ {hint} +

+ ) : null} +
+ + +
+ + {error ? ( + + ) : null} +
+ ); +}); + +SwitchFieldInput.displayName = "SwitchFieldInput"; diff --git a/src/components/osui/inputs/text-field-input.tsx b/src/components/osui/inputs/text-field-input.tsx new file mode 100644 index 000000000..2ac6836f0 --- /dev/null +++ b/src/components/osui/inputs/text-field-input.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { forwardRef, useId, type ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils"; + +export type TextFieldInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + containerClassName?: string; + } & Omit, "size"> +>; + +export const TextFieldInput = forwardRef( + function TextFieldInput( + { + className, + containerClassName, + id, + label = "Label", + hint, + error = false, + errorMessage = "This field is required.", + disabled, + required, + type = "text", + ...props + }, + ref, + ) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + + return ( +
+ + + + + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); + }, +); + +TextFieldInput.displayName = "TextFieldInput"; diff --git a/src/components/osui/inputs/textarea-field-input.tsx b/src/components/osui/inputs/textarea-field-input.tsx new file mode 100644 index 000000000..1f476f9ee --- /dev/null +++ b/src/components/osui/inputs/textarea-field-input.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { + forwardRef, + useCallback, + useId, + useState, + type ChangeEvent, + type ComponentPropsWithoutRef, +} from "react"; + +import { cn } from "@/lib/utils"; + +export type TextareaFieldInputProps = Readonly< + { + label?: string; + hint?: string; + error?: boolean; + errorMessage?: string; + showCount?: boolean; + containerClassName?: string; + } & Omit, "onChange"> +> & { + onChange?: (value: string, event: ChangeEvent) => void; +}; + +export const TextareaFieldInput = forwardRef< + HTMLTextAreaElement, + TextareaFieldInputProps +>(function TextareaFieldInput( + { + className, + containerClassName, + id, + label = "Message", + hint, + error = false, + errorMessage = "Message is too long.", + showCount = true, + disabled, + required, + value, + defaultValue = "", + maxLength = 500, + rows = 4, + onChange, + ...props + }, + ref, +) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const hintId = `${inputId}-hint`; + const errorId = `${inputId}-error`; + const countId = `${inputId}-count`; + + const isControlled = value !== undefined; + const [internal, setInternal] = useState(String(defaultValue)); + const current = isControlled ? String(value) : internal; + const length = current.length; + const safeMaxLength = + maxLength === undefined + ? undefined + : Number.isFinite(maxLength) + ? Math.max(0, Math.floor(maxLength)) + : 500; + const atLimit = safeMaxLength !== undefined && length >= safeMaxLength; + + const handleChange = useCallback( + (event: ChangeEvent) => { + const next = event.target.value; + if (!isControlled) setInternal(next); + onChange?.(next, event); + }, + [isControlled, onChange], + ); + + return ( +
+
+ + + {showCount && safeMaxLength !== undefined ? ( + + {length}/{safeMaxLength} + + ) : null} +
+ +