Compare commits
28
Commits
@@ -2,7 +2,6 @@ node_modules
|
|||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
deploy
|
deploy
|
||||||
drizzle
|
|
||||||
docs
|
docs
|
||||||
*.md
|
*.md
|
||||||
.env*
|
.env*
|
||||||
|
|||||||
+2
-1
@@ -19,9 +19,10 @@ AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
|||||||
AUTH_OIDC_CLIENT_ID=replace-me
|
AUTH_OIDC_CLIENT_ID=replace-me
|
||||||
AUTH_OIDC_CLIENT_SECRET=replace-me
|
AUTH_OIDC_CLIENT_SECRET=replace-me
|
||||||
|
|
||||||
# Web Push (generate with: pnpm vapid:generate)
|
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
||||||
VAPID_SUBJECT=mailto:you@example.com
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|
||||||
# ntfy (optional fallback channel; leave blank to disable)
|
# ntfy (optional fallback channel; leave blank to disable)
|
||||||
|
|||||||
+19
-8
@@ -1,7 +1,17 @@
|
|||||||
# ── famapp ────────────────────────────────────────────────────────────────────
|
# ── famapp ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Public URL for the app (used in share links, OIDC redirect URIs, etc.)
|
# Image tag to deploy. Pin to a specific version after first deploy
|
||||||
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
|
# (e.g. ghcr.io/ginnoir/famapp:v0.1.0). `latest` is fine for staging/initial.
|
||||||
|
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:latest
|
||||||
|
# `always` pulls on every `up`; set `missing` if you want to skip pulls.
|
||||||
|
FAMAPP_PULL_POLICY=always
|
||||||
|
# Authentik image tag. Bump in lockstep with Authentik release notes.
|
||||||
|
AUTHENTIK_IMAGE_TAG=2024.12.3
|
||||||
|
# Run drizzle migrations on container start. Leave true.
|
||||||
|
RUN_MIGRATIONS=true
|
||||||
|
|
||||||
|
# Public URL for the app — used by Auth.js for OIDC redirect URIs.
|
||||||
|
AUTH_URL=https://fam.ginnoir.com
|
||||||
|
|
||||||
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
|
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
|
||||||
FAMAPP_DB_USER=famapp
|
FAMAPP_DB_USER=famapp
|
||||||
@@ -11,20 +21,21 @@ FAMAPP_DB_NAME=famapp
|
|||||||
# Auth.js session secret — generate with: openssl rand -base64 32
|
# Auth.js session secret — generate with: openssl rand -base64 32
|
||||||
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||||
|
|
||||||
# OIDC provider (Authentik) — task 06 will fill these in after bootstrapping
|
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
|
||||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||||
AUTH_OIDC_CLIENT_ID=replace-me
|
AUTH_OIDC_CLIENT_ID=replace-me
|
||||||
AUTH_OIDC_CLIENT_SECRET=replace-me
|
AUTH_OIDC_CLIENT_SECRET=replace-me
|
||||||
|
|
||||||
# Web Push VAPID keys — generate with: pnpm vapid:generate
|
# Web Push VAPID keys — generate with: pnpm vapid:generate (run from the repo)
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
# Must be "mailto:<address>" or a URL
|
# Must be "mailto:<address>" or a URL
|
||||||
VAPID_SUBJECT=mailto:you@example.com
|
VAPID_SUBJECT=mailto:3nigma.matt@gmail.com
|
||||||
|
|
||||||
# ntfy (optional push fallback — leave blank to disable)
|
# ntfy push fallback — uses your existing ntfy instance at ntfy.ginnoir.com
|
||||||
NTFY_URL=
|
# Leave NTFY_URL blank to disable ntfy fanout.
|
||||||
NTFY_TOPIC=
|
NTFY_URL=https://ntfy.ginnoir.com
|
||||||
|
NTFY_TOPIC=famapp
|
||||||
|
|
||||||
# Log level: error | warn | info | debug
|
# Log level: error | warn | info | debug
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.33.3
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: pnpm typecheck
|
||||||
|
|
||||||
|
- run: pnpm lint
|
||||||
|
|
||||||
|
- run: pnpm format:check
|
||||||
|
|
||||||
|
- run: pnpm build
|
||||||
|
env:
|
||||||
|
# Build only — no DB connection. Provide a placeholder so any
|
||||||
|
# module-level reads of DATABASE_URL don't crash the build.
|
||||||
|
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
|
||||||
|
AUTH_SECRET: ci-placeholder-secret
|
||||||
|
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+4
-2
@@ -9,6 +9,9 @@ dist/
|
|||||||
build/
|
build/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Generated at build time by next.config.ts — do not commit
|
||||||
|
public/sw.js
|
||||||
|
|
||||||
# Auto-generated by Next.js — never edit, never commit
|
# Auto-generated by Next.js — never edit, never commit
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
@@ -41,8 +44,7 @@ playwright-report/
|
|||||||
test-results/
|
test-results/
|
||||||
tests/.auth/
|
tests/.auth/
|
||||||
|
|
||||||
# Drizzle generated artifacts (migrations themselves are committed)
|
# drizzle/meta/ intentionally committed — migrator requires _journal.json at runtime
|
||||||
drizzle/meta/
|
|
||||||
|
|
||||||
# Backups
|
# Backups
|
||||||
deploy/backups/data/
|
deploy/backups/data/
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
One line per release. "What's in prod" = the highest tag listed under a date that's been deployed.
|
||||||
|
|
||||||
|
Format: `## vX.Y.Z — YYYY-MM-DD`
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
|
||||||
|
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
|
||||||
|
- Auth.js: replaced `NEXT_PUBLIC_APP_URL` with `AUTH_URL` in compose (correct next-auth v5 var).
|
||||||
|
- Push opt-in: VAPID public key now passed as prop from server component instead of `NEXT_PUBLIC_*` so pre-built images work without a build-time env var.
|
||||||
|
- ntfy integration wired to existing `ntfy.ginnoir.com` instance via `NTFY_URL`/`NTFY_TOPIC` env vars.
|
||||||
+13
-1
@@ -32,6 +32,18 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
# Migration assets — drizzle/ holds SQL + meta journal; migrate.mjs runs
|
||||||
|
# at startup via the entrypoint. Explicitly copy the full drizzle-orm and
|
||||||
|
# postgres packages because the standalone tracer only includes subpaths
|
||||||
|
# the app itself imports, not the postgres-js/migrator subpath.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts/migrate.mjs ./scripts/migrate.mjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/scripts/seed.mjs ./scripts/seed.mjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules/postgres
|
||||||
|
COPY --chown=nextjs:nodejs docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -19,10 +19,32 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
|||||||
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
||||||
- **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
- **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
||||||
- **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass.
|
- **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass.
|
||||||
|
- **20 — Dashboard composition (single-dashboard MVP)**. Added `default_dashboard_layout` jsonb column to `users` + migration `0007_uneven_living_lightning.sql`. Created `src/modules/_core/manifest.tsx` (`core.activity` placeholder widget) and registered it. Updated all three module manifests (calendar, lists, notes) with real async server component widget renders (data-fetching, empty states). Created `src/lib/dashboard.ts` (layout parsing + `computeDefaultLayout` greedy packer). Built `src/app/page.tsx` — 12-col CSS Grid, static `smColSpan` lookup for Tailwind class safety, per-widget `<Suspense>` for parallel loading, graceful skip for unknown widget IDs. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
|
- **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
|
- **22 — Activity log**. Added `activity_log` table to `_core/schema.ts` with index on `(household_id, created_at desc)`. Migration `0008_activity_log.sql` applied. `logActivity()` server function in `_core/activity.ts` reads current session and inserts a row. Added `ActivityLogEntry` type and optional `renderActivity?(entry): string` to `EntityTypeRegistration` in `_core/module.ts`. All three module manifests implement `renderActivity` for each entity type (human-readable, no hardcoded branches in the widget). Replaced `core.activity` widget stub with a real async server component that queries the last 20 rows via `getEntityType(entry.entityType)?.renderActivity(entry)`. Wired `logActivity()` into every create/update/delete in calendar, lists, and notes server actions. Also added `text` to `getAuthorizedItem` select so toggle/delete log the item text. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
|
- **30 — Share-link service**. Added `share_links` table to `_core/schema.ts` + migration `0009_share_links.sql`. Created `_core/share.ts` with `createShareLink`, `resolveShareToken`, `revokeShareLink`, and `getActiveShareLinks`. Token is 32 random bytes (URL-safe base64), stored as SHA-256 hash — raw token only returned at creation. `createShareLink` guards that the entity type is registered with `canShare === true`. `resolveShareToken` returns null for expired or revoked tokens. All three functions exported from `_core/index.ts`. `/settings` page gained a Share links card: lists active links (entity label, read/write capabilities, expiry) with a Revoke button per link (server action in `settings/actions.ts`). `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
|
- **25 — Multiple dashboards per user**. Added `dashboards` table (migration `0012_dashboards.sql`). Migrated each user's `default_dashboard_layout` into a "Home" dashboard row with `is_default = true`; dropped the interim column. Server actions: `listDashboards`, `createDashboard`, `renameDashboard`, `deleteDashboard`, `setDefaultDashboard`, `reorderDashboards`, `saveDashboardLayout`, `resetDashboardLayout`, `resolveWidgetConfigOptions`. `/` redirects to the user's default `/d/<slug>`. Dashboard switcher in AppNav renders tabs (active highlighted client-side) with a `+` button to create new dashboards and a kebab menu on the active tab for rename / set-default / delete. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||||
|
- **26 — Customizable layout + widget configuration**. Installed `react-grid-layout` v2. Dashboard pages check `?edit=1` to enter edit mode, rendering a client `DashboardEditor` instead of the static grid. Editor uses `react-grid-layout` with `gridConfig`/`dragConfig` v2 API; each widget shell shows a drag handle, configure button (⚙), and remove button (🗑). `WidgetPicker` is a two-step modal: step 1 lists all registry widgets grouped by category; step 2 is a `WidgetConfigurator` auto-generated from the widget's default config — handles `"all"|string[]` multi-selects, booleans, numbers, and enums. `resolveWidgetConfigOptions` server action fetches dynamic options (calendars, lists). Save validates each config against its registered Zod schema. Reset to defaults calls `computeDefaultLayout()`. `pnpm typecheck`, `pnpm build` pass.
|
||||||
|
- **31 — Public share viewer**. Made `actorId` nullable in `activity_log` (migration `0010_nullable_actor_id.sql`, `onDelete: "set null"`) for anonymous share-page mutations. Added `logShareActivity` to `_core/activity.ts` (no session, explicit `householdId`). Added `householdId` to `resolveShareToken` return. Added `renderSharedView` to `EntityTypeRegistration` type. Each module implements `loadForShare` (bare DB queries, no session) and `renderSharedView`: calendar shows upcoming 90-day events or single-event details, lists shows items with optional toggle, notes shows title + body. `toggleShareListItem` server action lives in `lists/server/share-actions.ts` — validates token write capability, verifies item→list→household chain, logs `share.toggle` with `actorId = null`. `/app/s/[token]/page.tsx` resolves token, dispatches to `loadForShare` + `renderSharedView`, returns friendly error for invalid/expired tokens, sets `noindex`. Middleware `/s/*` exemption confirmed present. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||||
|
|
||||||
|
- **50 — PWA shell**. `public/manifest.webmanifest` (name, short_name, icons, theme_color, display: standalone, start_url `/`). Placeholder PNG icons at 180, 192, 384, 512 (regular + maskable) generated by `scripts/generate-icons.mjs` (`pnpm gen:icons`); `public/icon.svg` committed as source. Hand-rolled service worker at `public/sw.js`: precaches `offline.html` on install, cache-first for `/_next/static/`, network-first for navigation with offline fallback, network-only for API routes. `src/components/pwa-register.tsx` registers the SW client-side. `src/components/install-prompt.tsx` shows a dismissible banner: `beforeinstallprompt` on Android/Chrome, a one-time "Add to Home Screen" hint on iOS (detected via UA + `navigator.maxTouchPoints`, suppressed in standalone mode). Root layout exports `viewport` (themeColor), updated `metadata` (manifest, appleWebApp, apple-touch-icon), and mounts both new components. `pnpm typecheck`, `pnpm lint`, `pnpm build` all clean.
|
||||||
|
- **51 — Offline shell + service worker caching**. `next.config.ts` generates `public/sw.js` as a side effect on every `next build` / `next dev` invocation, embedding a build timestamp as `CACHE_VERSION` (stable `"dev"` string in development to avoid hot-reload cache churn; epoch milliseconds in production). SW strategies: stale-while-revalidate for `/_next/static/` chunks and navigation HTML (cached page served instantly, network update fires in background); network-first with 2-second abort timeout for API GETs falling back to cache; network-only for mutations (POST/PATCH/DELETE/PUT) — if offline, all controlled clients receive `{ type: "OFFLINE_MUTATION" }` via `postMessage` and a synthetic 503 is returned. Activate handler evicts all `famapp-*` caches whose suffix doesn't match the current version, then claims clients. `pwa-register.tsx` extended with three inline toasts: amber "offline" banner (persistent, driven by `navigator.onLine` + `online`/`offline` events), red "changes can't be saved" toast (auto-dismisses in 4 s, driven by SW postMessage), and indigo "new version available — refresh" bottom toast (driven by `controllerchange` with `hadController` guard). `pnpm typecheck`, `pnpm build` pass.
|
||||||
|
|
||||||
|
- **40 — Web Push (VAPID)**. Installed `web-push` + `@types/web-push`. Added `pnpm vapid:generate` script (`scripts/vapid-generate.mjs`) that prints all three env vars to stdout. Added `push_subscriptions` table to `_core/schema.ts` + migration `0013_push_notify_reminders.sql`. Created `_core/push.ts` with `sendPush(userId, payload)` — iterates subscriptions, removes 404/410 stale entries. Added `push` and `notificationclick` event handlers to the generated `public/sw.js` template. Created `<PushOptIn />` client component on `/settings` (opt-in button → `subscribeToPush` server action, disable button → `unsubscribeFromPush`, test button → `sendTestNotification`). Documented `NEXT_PUBLIC_VAPID_PUBLIC_KEY` in `.env.example`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||||
|
|
||||||
|
- **42 — Notification bus + ntfy adapter**. Added `notifications` table and `notif_push`/`notif_inapp`/`notif_ntfy` columns on `users` (migration `0013_push_notify_reminders.sql`). Created `_core/notify.ts` with `notify(userId, { title, body, url, channels? })` — fans out to push (if VAPID configured), in-app DB insert, and ntfy POST (if `NTFY_URL`+`NTFY_TOPIC` set). Added `<NotificationBell />` async server component in `AppNav`: queries last 20 notifications, shows unread badge, dropdown inbox with mark-read and mark-all-read. Added `<NotifyChannelToggles />` client component with per-channel checkboxes in `/settings`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||||
|
|
||||||
|
- **41 — Reminders engine**. Added `fired_at` and `created_by` columns to `reminders` table (migration `0013_push_notify_reminders.sql`); default channel changed to `'auto'`. Created `_core/reminders.ts` with `scheduleReminder` (upsert by entity), `cancelReminder`, `listReminders`, and `tickReminders` (30 s tick, `pg_try_advisory_xact_lock` guard). `startReminderWorker()` started via `src/instrumentation.ts` on the Node.js runtime. Notes actions updated to use `scheduleReminder`/`cancelReminder` instead of raw SQL. Calendar `createEvent` accepts optional `remindMinutesBefore` and schedules a reminder; `deleteEvent` calls `cancelReminder`. Calendar-shell event dialog shows "Remind me 30 min before" checkbox (new events only, checked by default). Reminder worker confirmed starting on server boot (logged in dev server output). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||||
|
|
||||||
|
- **60 — Postgres backups**. `famapp-backup` Alpine service added to `deploy/compose.yaml`; scripts in `deploy/backups/`: `backup.sh` (`pg_dump -Fc` for famapp-db + authentik-db nightly at 02:00), `retain.sh` (14 daily / 8 weekly / 6 monthly), `restore.sh` (restore from any dump file), `entrypoint.sh` (installs postgresql-client, sets up crontab, starts crond). Backup files stored in `backups` named Docker volume. Restore procedure in `deploy/backups/README.md`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||||
|
- **61 — Rate limiting on share links**. `src/lib/rate-limit.ts`: pure-JS Edge-compatible sliding-window counter (50 req/min, 1-min window, 10k-key LRU eviction) with `consume()`, `isRateLimited()`, and `recordFailure()` exports. `src/middleware.ts` calls `consume(ip:prefix)` for every `/s/[token]` request and returns 429 with `Retry-After: 60` when the bucket is exceeded. `src/app/s/[token]/page.tsx` additionally tracks only failed `resolveShareToken` lookups via `recordFailure()` in the Node.js runtime (separate module instance from middleware; Redis would unify them for multi-replica deployments). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||||
|
- **62 — Structured logging**. Installed `pino` + `pino-pretty` (dev). `src/lib/logger.ts`: pino instance — JSON in production (`stdout`), pretty-printed in dev; level from `LOG_LEVEL` env (default `info`); `pid`/`hostname` stripped, ISO timestamps. `src/middleware.ts` logs every request as structured JSON via `console.log` (Edge-compatible; pino not available in Edge runtime) with `method`, `path`, `status`, `ms`, `authenticated`. All `console.error`/`console.log` calls in `_core/push.ts`, `_core/notify.ts`, `_core/reminders.ts` replaced with `logger.error`/`logger.info`; sensitive fields (endpoint URLs, keys) are never logged as named fields. `next.config.ts` adds `serverExternalPackages: ["pino","pino-pretty"]` so webpack does not bundle them. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||||
|
|
||||||
## Next up
|
## Next up
|
||||||
|
|
||||||
- Next task in `docs/tasks/`.
|
- **Ready to tag v0.1.0.** All phase 1–7 tasks complete and production wiring verified.
|
||||||
|
- Run `docs/tasks/09-pre-deploy-checklist.md` before pushing the tag.
|
||||||
|
- On the server: clone repo to `/srv/famapp`, copy `.env.production.example` → `/srv/famapp/deploy/.env`, fill in secrets (AUTH_SECRET, DB passwords, AUTHENTIK_SECRET_KEY, VAPID keys, AUTH_OIDC_CLIENT_ID/SECRET), add the Caddyfile snippet, bootstrap Authentik per `deploy/authentik/README.md`, then `docker compose -f deploy/compose.yaml up -d`.
|
||||||
|
|
||||||
## Development login/testing notes
|
## Development login/testing notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Include from main Caddyfile or paste into the existing one.
|
# famapp — paste into your existing Caddyfile.
|
||||||
|
# famapp runs on 3010, authentik-server on 9200 (both bound to the host by deploy/compose.yaml).
|
||||||
|
|
||||||
fam.ginnoir.com {
|
fam.ginnoir.com {
|
||||||
reverse_proxy famapp:3000
|
reverse_proxy 192.168.1.69:3010
|
||||||
}
|
}
|
||||||
|
|
||||||
auth.ginnoir.com {
|
auth.ginnoir.com {
|
||||||
reverse_proxy authentik-server:9000
|
reverse_proxy 192.168.1.69:9200
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Deploying famapp
|
||||||
|
|
||||||
|
Trunk-based: `main` is always green. Production deploys only from version tags (`vX.Y.Z`). The dev-login flow is retained for local development behind a double gate (`NODE_ENV !== "production"` **and** `ENABLE_DEV_LOGIN=true`); a startup assertion in `src/lib/dev-login-config.ts` makes a misconfigured prod fail loud instead of silently exposing it.
|
||||||
|
|
||||||
|
## One-time host setup
|
||||||
|
|
||||||
|
1. Install Docker + Compose plugin on the host.
|
||||||
|
2. `git clone` this repo to e.g. `/srv/famapp`.
|
||||||
|
3. Copy `.env.production.example` → `/srv/famapp/deploy/.env` and fill in real values.
|
||||||
|
- `openssl rand -base64 32` for `AUTH_SECRET`.
|
||||||
|
- `openssl rand -base64 60` for `AUTHENTIK_SECRET_KEY`.
|
||||||
|
- `pnpm vapid:generate` (locally) for the three VAPID lines.
|
||||||
|
4. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
|
||||||
|
5. Wire Caddy with `deploy/Caddyfile.snippet`.
|
||||||
|
|
||||||
|
## Cutting a release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from your dev machine, on main, with a clean working tree
|
||||||
|
git tag v0.1.0
|
||||||
|
git push origin v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`.github/workflows/release.yml` builds + pushes `ghcr.io/ginnoir/famapp:v0.1.0`, `:0.1`, and `:latest` to GHCR.
|
||||||
|
|
||||||
|
## Deploying a release on the host
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /srv/famapp/deploy
|
||||||
|
# pin to the tag you just cut
|
||||||
|
echo 'FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0' >> .env # or edit in place
|
||||||
|
docker compose pull famapp
|
||||||
|
docker compose up -d famapp
|
||||||
|
docker compose logs -f famapp # watch migrations + boot
|
||||||
|
```
|
||||||
|
|
||||||
|
The container's entrypoint runs `node scripts/migrate.mjs` before starting the server. To skip migrations on a given start (rare — e.g. emergency rollback to an older schema-compatible image), set `RUN_MIGRATIONS=false`.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Edit `.env` to point `FAMAPP_IMAGE` at the previous tag, then `docker compose up -d famapp`. If the rollback target predates a migration that's already applied, restore from backup (`deploy/backups/README.md`) before bringing the older image up.
|
||||||
|
|
||||||
|
## Pre-deploy checklist
|
||||||
|
|
||||||
|
Run [docs/tasks/09-pre-deploy-checklist.md](../docs/tasks/09-pre-deploy-checklist.md) before every deploy.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Backups
|
||||||
|
|
||||||
|
The `famapp-backup` service performs nightly compressed `pg_dump` of both
|
||||||
|
`famapp-db` and `authentik-db` at **02:00 server time**.
|
||||||
|
|
||||||
|
## Storage layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/backups/ (named Docker volume: famapp_backups)
|
||||||
|
famapp/
|
||||||
|
daily/ ← last 14 days
|
||||||
|
weekly/ ← last 8 Sundays
|
||||||
|
monthly/ ← last 6 first-of-month dumps
|
||||||
|
authentik/
|
||||||
|
daily/
|
||||||
|
weekly/
|
||||||
|
monthly/
|
||||||
|
```
|
||||||
|
|
||||||
|
Dump files are named `YYYY-MM-DD.dump` in custom (`-Fc`) format (internal
|
||||||
|
compression, ~3–5× smaller than plain SQL).
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
| Tier | Kept | Trigger |
|
||||||
|
| ------- | ---- | ---------------- |
|
||||||
|
| daily | 14 | every night |
|
||||||
|
| weekly | 8 | Sunday night |
|
||||||
|
| monthly | 6 | 1st of the month |
|
||||||
|
|
||||||
|
Retention is enforced by `retain.sh` at the end of each `backup.sh` run.
|
||||||
|
|
||||||
|
## Restore procedure
|
||||||
|
|
||||||
|
### 1. Identify the dump
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# List available dumps
|
||||||
|
docker exec famapp-backup-1 ls /backups/famapp/daily/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2a. Restore inside the backup container (recommended)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||||
|
/backups/famapp/daily/2024-06-01.dump \
|
||||||
|
postgres://famapp:SECRET@famapp-db:5432/famapp
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `SECRET` with the value of `FAMAPP_DB_PASSWORD` in your `.env` file.
|
||||||
|
For authentik:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||||
|
/backups/authentik/daily/2024-06-01.dump \
|
||||||
|
postgres://authentik:SECRET@authentik-db:5432/authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2b. Restore to a separate database (safe — non-destructive)
|
||||||
|
|
||||||
|
Create a fresh target database first, then restore into it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Create the target DB
|
||||||
|
docker exec famapp-db-1 createdb \
|
||||||
|
-U "$FAMAPP_DB_USER" famapp_restore
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||||
|
/backups/famapp/daily/2024-06-01.dump \
|
||||||
|
postgres://famapp:SECRET@famapp-db:5432/famapp_restore
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2c. Restore on a fresh host (disaster recovery)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Copy the dump file out of the volume
|
||||||
|
docker cp famapp-backup-1:/backups/famapp/daily/2024-06-01.dump ./
|
||||||
|
|
||||||
|
# Spin up a temporary Postgres container and restore
|
||||||
|
docker run --rm \
|
||||||
|
-e PGPASSWORD=SECRET \
|
||||||
|
-v "$(pwd)/2024-06-01.dump:/dump.dump:ro" \
|
||||||
|
postgres:16-alpine \
|
||||||
|
pg_restore -h <new-db-host> -U famapp -d famapp \
|
||||||
|
--no-owner --no-acl /dump.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
## Off-site replication
|
||||||
|
|
||||||
|
The backups live in the `famapp_backups` Docker named volume. To copy them
|
||||||
|
to another host, rsync the volume's data directory periodically (e.g. from a
|
||||||
|
host cron job):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# On the Docker host, add to /etc/cron.d/famapp-rsync:
|
||||||
|
30 3 * * * root rsync -a --delete \
|
||||||
|
/var/lib/docker/volumes/famapp_backups/_data/ \
|
||||||
|
user@offsite-server:/opt/famapp-backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
Encryption at rest is handled at the disk/filesystem layer (e.g. LUKS).
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Nightly pg_dump for famapp-db and authentik-db.
|
||||||
|
# Called by crond. Outputs to /backups/<db>/daily/<YYYY-MM-DD>.dump
|
||||||
|
# and copies into weekly/ (Sundays) and monthly/ (1st of month).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
DOW=$(date +%u) # 1=Mon … 7=Sun
|
||||||
|
DOM=$(date +%d | sed 's/^0*//') # day-of-month without leading zero
|
||||||
|
|
||||||
|
dump_db() {
|
||||||
|
local name="$1"
|
||||||
|
local host="$2"
|
||||||
|
local port="$3"
|
||||||
|
local user="$4"
|
||||||
|
local pass="$5"
|
||||||
|
local dbname="$6"
|
||||||
|
|
||||||
|
local daily_dir="/backups/$name/daily"
|
||||||
|
local dest="$daily_dir/$TODAY.dump"
|
||||||
|
|
||||||
|
mkdir -p "$daily_dir" \
|
||||||
|
"/backups/$name/weekly" \
|
||||||
|
"/backups/$name/monthly"
|
||||||
|
|
||||||
|
echo "[backup] dumping $name ..."
|
||||||
|
PGPASSWORD="$pass" pg_dump \
|
||||||
|
-h "$host" -p "$port" -U "$user" -d "$dbname" \
|
||||||
|
-Fc -f "$dest"
|
||||||
|
echo "[backup] $name → $dest"
|
||||||
|
|
||||||
|
if [ "$DOW" = "7" ]; then
|
||||||
|
cp "$dest" "/backups/$name/weekly/$TODAY.dump"
|
||||||
|
echo "[backup] weekly copy saved for $name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DOM" = "1" ]; then
|
||||||
|
cp "$dest" "/backups/$name/monthly/$TODAY.dump"
|
||||||
|
echo "[backup] monthly copy saved for $name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_db famapp \
|
||||||
|
"${FAMAPP_DB_HOST:-famapp-db}" \
|
||||||
|
"${FAMAPP_DB_PORT:-5432}" \
|
||||||
|
"$FAMAPP_DB_USER" \
|
||||||
|
"$FAMAPP_DB_PASSWORD" \
|
||||||
|
"$FAMAPP_DB_NAME"
|
||||||
|
|
||||||
|
dump_db authentik \
|
||||||
|
"${AUTHENTIK_DB_HOST:-authentik-db}" \
|
||||||
|
"${AUTHENTIK_DB_PORT:-5432}" \
|
||||||
|
"$AUTHENTIK_DB_USER" \
|
||||||
|
"$AUTHENTIK_DB_PASSWORD" \
|
||||||
|
"$AUTHENTIK_DB_NAME"
|
||||||
|
|
||||||
|
/scripts/retain.sh
|
||||||
|
echo "[backup] complete"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0 2 * * * /scripts/backup.sh >> /var/log/backup.log 2>&1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container entrypoint: install postgresql-client, create backup directories,
|
||||||
|
# install crontab, and start crond.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
apk add --no-cache postgresql-client >/dev/null
|
||||||
|
|
||||||
|
mkdir -p \
|
||||||
|
/backups/famapp/daily /backups/famapp/weekly /backups/famapp/monthly \
|
||||||
|
/backups/authentik/daily /backups/authentik/weekly /backups/authentik/monthly
|
||||||
|
|
||||||
|
# Make scripts executable (volume mount may strip +x).
|
||||||
|
chmod +x /scripts/backup.sh /scripts/retain.sh /scripts/restore.sh
|
||||||
|
|
||||||
|
# Install root crontab from the mounted file.
|
||||||
|
mkdir -p /var/spool/cron/crontabs
|
||||||
|
cp /scripts/crontab /var/spool/cron/crontabs/root
|
||||||
|
chmod 600 /var/spool/cron/crontabs/root
|
||||||
|
|
||||||
|
echo "[entrypoint] backup service started — first run at 02:00"
|
||||||
|
exec crond -f -l 2
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Restore a pg_dump (-Fc format) to a target Postgres database.
|
||||||
|
# The target DB must already exist and be empty (or you accept overwriting data).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# restore.sh <dump-file> <target-db-url>
|
||||||
|
#
|
||||||
|
# Example (inside the backup container):
|
||||||
|
# /scripts/restore.sh \
|
||||||
|
# /backups/famapp/daily/2024-06-01.dump \
|
||||||
|
# postgres://famapp:secret@famapp-db:5432/famapp
|
||||||
|
#
|
||||||
|
# Example (from the host via docker exec):
|
||||||
|
# docker exec famapp-backup-1 /scripts/restore.sh \
|
||||||
|
# /backups/famapp/daily/2024-06-01.dump \
|
||||||
|
# postgres://famapp:secret@famapp-db:5432/famapp_restore
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DUMP_FILE="${1:?Usage: restore.sh <dump-file> <target-db-url>}"
|
||||||
|
TARGET_URL="${2:?Usage: restore.sh <dump-file> <target-db-url>}"
|
||||||
|
|
||||||
|
[ -f "$DUMP_FILE" ] || { echo "[restore] ERROR: $DUMP_FILE not found"; exit 1; }
|
||||||
|
|
||||||
|
echo "[restore] $DUMP_FILE → $TARGET_URL"
|
||||||
|
pg_restore -d "$TARGET_URL" --no-owner --no-acl --exit-on-error "$DUMP_FILE"
|
||||||
|
echo "[restore] done"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prune old backup files according to retention policy.
|
||||||
|
# daily: keep 14 weekly: keep 8 monthly: keep 6
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
prune() {
|
||||||
|
local dir="$1"
|
||||||
|
local keep="$2"
|
||||||
|
[ -d "$dir" ] || return 0
|
||||||
|
# Files are YYYY-MM-DD.dump; lexicographic sort = chronological.
|
||||||
|
# tail skips the N newest; xargs deletes the rest.
|
||||||
|
ls -1 "$dir"/*.dump 2>/dev/null \
|
||||||
|
| sort \
|
||||||
|
| head -n "-$keep" \
|
||||||
|
| xargs -r rm -f --
|
||||||
|
}
|
||||||
|
|
||||||
|
for db in famapp authentik; do
|
||||||
|
prune "/backups/$db/daily" 14
|
||||||
|
prune "/backups/$db/weekly" 8
|
||||||
|
prune "/backups/$db/monthly" 6
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[retain] done"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||||
|
echo "running migrations..."
|
||||||
|
node /app/scripts/migrate.mjs
|
||||||
|
else
|
||||||
|
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec node /app/server.js
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||||
|
echo "running migrations..."
|
||||||
|
node /app/scripts/migrate.mjs
|
||||||
|
else
|
||||||
|
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
node /app/scripts/seed.mjs
|
||||||
|
|
||||||
|
exec node /app/server.js
|
||||||
@@ -25,11 +25,19 @@ Cheap to set up before modules exist, painful to retrofit afterwards. Locking in
|
|||||||
Use shadcn's CSS-variable conventions, scoped by `data-theme` on `<html>`:
|
Use shadcn's CSS-variable conventions, scoped by `data-theme` on `<html>`:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
:root { /* default light tokens */ }
|
:root {
|
||||||
.dark { /* default dark tokens */ }
|
/* default light tokens */
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
/* default dark tokens */
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="warm"] { /* warm light */ }
|
[data-theme="warm"] {
|
||||||
[data-theme="warm"].dark { /* warm dark */ }
|
/* warm light */
|
||||||
|
}
|
||||||
|
[data-theme="warm"].dark {
|
||||||
|
/* warm dark */
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Ship at least **two** themes (`default` + one more) so the architecture is actually exercised. Token values can be placeholder — refining the palettes is a separate later concern.
|
Ship at least **two** themes (`default` + one more) so the architecture is actually exercised. Token values can be placeholder — refining the palettes is a separate later concern.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 09 — Pre-deploy checklist (recurring)
|
||||||
|
|
||||||
|
Run this before every production deploy (first deploy and each tagged release). Dev-login is intentionally retained behind a double gate; this checklist is what keeps that gate honest.
|
||||||
|
|
||||||
|
## Env hygiene
|
||||||
|
|
||||||
|
- [ ] Server `.env` (next to `deploy/compose.yaml`) does **not** set:
|
||||||
|
- `ENABLE_DEV_LOGIN`
|
||||||
|
- `DEV_LOGIN_EMAIL`, `DEV_LOGIN_NAME`, `DEV_HOUSEHOLD_NAME`
|
||||||
|
- [ ] Server `.env` sets real values for:
|
||||||
|
- `NEXT_PUBLIC_APP_URL` (https)
|
||||||
|
- `AUTH_SECRET` (`openssl rand -base64 32`)
|
||||||
|
- `AUTH_OIDC_ISSUER`, `AUTH_OIDC_CLIENT_ID`, `AUTH_OIDC_CLIENT_SECRET`
|
||||||
|
- `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`
|
||||||
|
- `FAMAPP_DB_*`, `AUTHENTIK_DB_*`, `AUTHENTIK_SECRET_KEY`
|
||||||
|
- [ ] `FAMAPP_IMAGE` pins a specific version tag (`ghcr.io/ginnoir/famapp:vX.Y.Z`), not `latest`, after first deploy.
|
||||||
|
|
||||||
|
## Code-side guard
|
||||||
|
|
||||||
|
`src/lib/dev-login-config.ts` throws on import if `NODE_ENV=production` and `ENABLE_DEV_LOGIN=true`. Container will refuse to start.
|
||||||
|
|
||||||
|
- [ ] Confirm guard is still present (do not remove without updating this checklist).
|
||||||
|
|
||||||
|
## Smoke after deploy
|
||||||
|
|
||||||
|
- [ ] `https://fam.ginnoir.com/login` shows only **Sign in with SSO** (no Dev login button).
|
||||||
|
- [ ] Real Authentik sign-in succeeds, lands on dashboard.
|
||||||
|
- [ ] First user appears in DB with household membership, default calendars, default lists.
|
||||||
|
- [ ] No `dev@famapp.local` row in production `users` table.
|
||||||
|
- [ ] Push opt-in works (settings → enable → test notification arrives).
|
||||||
|
- [ ] A share link created from `/settings` resolves at `/s/<token>` while signed out.
|
||||||
|
|
||||||
|
## Backup / rollback
|
||||||
|
|
||||||
|
- [ ] Last `famapp-backup` cron run succeeded (check container logs).
|
||||||
|
- [ ] Previous image tag is known and recorded in `CHANGELOG.md`, so rollback = bump `FAMAPP_IMAGE` and `docker compose up -d famapp`.
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 09 — Production dev-login removal gate
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Before the first production deployment, verify that development-only login/test shortcuts cannot be enabled accidentally in production.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
- 06
|
|
||||||
- 07
|
|
||||||
- local dev-login setup in `docs/dev-login.md`
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Review all production env sources:
|
|
||||||
- `.env.production.example`
|
|
||||||
- `deploy/compose.yaml`
|
|
||||||
- any host-level Docker Compose override files
|
|
||||||
- deployment secrets on the server
|
|
||||||
- Confirm production does not set:
|
|
||||||
- `ENABLE_DEV_LOGIN=true`
|
|
||||||
- `DEV_LOGIN_EMAIL`
|
|
||||||
- `DEV_LOGIN_NAME`
|
|
||||||
- `DEV_HOUSEHOLD_NAME`
|
|
||||||
- Confirm production Authentik variables are real:
|
|
||||||
- `AUTH_OIDC_ISSUER`
|
|
||||||
- `AUTH_OIDC_CLIENT_ID`
|
|
||||||
- `AUTH_OIDC_CLIENT_SECRET`
|
|
||||||
- Build with production-like env and verify `/login` renders only the SSO login path.
|
|
||||||
- Verify the app still protects private routes without a valid Auth.js session cookie.
|
|
||||||
- Verify real Authentik login creates the expected user, household membership, default calendars, and default lists.
|
|
||||||
- Remove any accidental dev users from the production database.
|
|
||||||
|
|
||||||
## Optional hardening
|
|
||||||
|
|
||||||
- Remove `src/lib/dev-login.ts` and the Dev login form from `src/app/login/page.tsx` entirely before first production deployment.
|
|
||||||
- If retaining the code for future local development, keep the current double gate:
|
|
||||||
- `NODE_ENV !== "production"`
|
|
||||||
- `ENABLE_DEV_LOGIN=true`
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] Production env cannot enable Dev login accidentally.
|
|
||||||
- [ ] `/login` in production does not show **Dev login**.
|
|
||||||
- [ ] Direct dev-login action execution is unavailable in production.
|
|
||||||
- [ ] Real Authentik login works on the production domain.
|
|
||||||
- [ ] No `dev@famapp.local` or configured dev-login user exists in production data.
|
|
||||||
@@ -6,7 +6,7 @@ Implement the `calendar` module: first-class **calendars** (multiple per househo
|
|||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Calendars are entities, not a singleton concept. A user might run a *Personal* (private) calendar, a *Family* (household-shared) calendar, and a *Wedding planning* calendar that gets share-linked publicly. The calendar module ships that abstraction; widgets and the share-link service then reuse it generically.
|
Calendars are entities, not a singleton concept. A user might run a _Personal_ (private) calendar, a _Family_ (household-shared) calendar, and a _Wedding planning_ calendar that gets share-linked publicly. The calendar module ships that abstraction; widgets and the share-link service then reuse it generically.
|
||||||
|
|
||||||
## Depends on
|
## Depends on
|
||||||
|
|
||||||
|
|||||||
@@ -22,5 +22,14 @@ A single global `+` button on the dashboard (and a `cmd+k` palette anywhere) tha
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
|
- [x] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
|
||||||
- [ ] Keyboard navigation works in the palette (arrows + enter + escape).
|
- [x] Keyboard navigation works in the palette (arrows + enter + escape).
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- `QuickAddAction` gained a `url: string` field; `action` made optional (future inline modals).
|
||||||
|
- `SerializedQuickAddItem` strips the non-serializable `action` fn so it can cross the server→client RSC boundary as props to `QuickAddProvider`.
|
||||||
|
- `getQuickAdds()` in the registry returns one `SerializedQuickAddItem` per registered quick-add, enriched with `moduleId` / `moduleName` for grouping.
|
||||||
|
- Root layout calls `getQuickAdds()` and passes the result to `<QuickAddProvider>` which wraps the whole app. `<QuickAddSheet>` and `<CommandPalette>` live inside the provider and read from context.
|
||||||
|
- `cmdk` installed for the command palette; keyboard shortcut (cmd/ctrl+k) wired via `useEffect` in the provider.
|
||||||
|
- `.claude/**` added to ESLint ignores to prevent stale worktree `.next` build artifacts from failing lint.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Each user can create any number of named dashboards, switch between them, and ch
|
|||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Decouples "the dashboard" from "user's home page". A user might have a *Daily*, *Garden*, and *Wedding planning* dashboard, each composing different widgets. This task introduces the entity; task 26 adds the editor.
|
Decouples "the dashboard" from "user's home page". A user might have a _Daily_, _Garden_, and _Wedding planning_ dashboard, each composing different widgets. This task introduces the entity; task 26 adds the editor.
|
||||||
|
|
||||||
## Depends on
|
## Depends on
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,15 @@ Same component is reused for **Configure** on an already-placed widget. Re-runs
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"widgets": [
|
"widgets": [
|
||||||
{ "widgetId": "lists.list", "config": { "listIds": ["..."], "showCompleted": false }, "x": 0, "y": 0, "w": 6, "h": 4 }
|
{
|
||||||
]
|
"widgetId": "lists.list",
|
||||||
|
"config": { "listIds": ["..."], "showCompleted": false },
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 6,
|
||||||
|
"h": 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "default_dashboard_layout" jsonb;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "activity_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"actor_id" uuid NOT NULL,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"payload" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_log_household_created_idx" ON "activity_log" USING btree ("household_id","created_at");
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE "share_links" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"capabilities" jsonb NOT NULL,
|
||||||
|
"created_by" uuid NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "share_links_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "share_links_household_idx" ON "share_links" USING btree ("household_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "share_links_entity_idx" ON "share_links" USING btree ("entity_type","entity_id");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "activity_log" ALTER COLUMN "actor_id" DROP NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "activity_log" DROP CONSTRAINT "activity_log_actor_id_users_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "completion_visibility_hours" integer NOT NULL DEFAULT 24;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "dashboards" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"is_default" boolean NOT NULL DEFAULT false,
|
||||||
|
"position" integer NOT NULL DEFAULT 0,
|
||||||
|
"layout" jsonb NOT NULL DEFAULT '{"version":1,"widgets":[]}',
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "dashboards_user_slug_uq" ON "dashboards" ("user_id", "slug");
|
||||||
|
|
||||||
|
-- Migrate each existing user's default_dashboard_layout into a "Home" dashboard.
|
||||||
|
-- Idempotent: ON CONFLICT DO NOTHING.
|
||||||
|
INSERT INTO "dashboards" ("user_id", "name", "slug", "is_default", "position", "layout")
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
'Home',
|
||||||
|
'home',
|
||||||
|
true,
|
||||||
|
0,
|
||||||
|
COALESCE("default_dashboard_layout", '{"version":1,"widgets":[]}')
|
||||||
|
FROM "users"
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE "users" DROP COLUMN IF EXISTS "default_dashboard_layout";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Task 40: push_subscriptions table
|
||||||
|
CREATE TABLE "push_subscriptions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"endpoint" text NOT NULL UNIQUE,
|
||||||
|
"p256dh" text NOT NULL,
|
||||||
|
"auth" text NOT NULL,
|
||||||
|
"user_agent" text,
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "push_subscriptions_user_idx" ON "push_subscriptions" ("user_id");
|
||||||
|
|
||||||
|
-- Task 42: notifications table
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"url" text,
|
||||||
|
"read_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "notifications_user_read_idx" ON "notifications" ("user_id", "read_at");
|
||||||
|
|
||||||
|
-- Task 42: per-user notification channel preferences
|
||||||
|
ALTER TABLE "users"
|
||||||
|
ADD COLUMN "notif_push" boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "notif_inapp" boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN "notif_ntfy" boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Task 41: add fired_at and created_by to reminders
|
||||||
|
ALTER TABLE "reminders"
|
||||||
|
ADD COLUMN "fired_at" timestamp with time zone,
|
||||||
|
ADD COLUMN "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Task 41: update default channel value to 'auto'
|
||||||
|
UPDATE "reminders" SET "channel" = 'auto' WHERE "channel" = 'in_app';
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
{
|
||||||
|
"id": "afe12cff-df99-417d-977c-3e6a1c77981a",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.household_members": {
|
||||||
|
"name": "household_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "household_member_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"household_members_household_id_households_id_fk": {
|
||||||
|
"name": "household_members_household_id_households_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": [
|
||||||
|
"household_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"household_members_user_id_users_id_fk": {
|
||||||
|
"name": "household_members_user_id_users_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"household_members_household_id_user_id_pk": {
|
||||||
|
"name": "household_members_household_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"household_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.households": {
|
||||||
|
"name": "households",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.household_member_role": {
|
||||||
|
"name": "household_member_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"owner",
|
||||||
|
"member"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
{
|
||||||
|
"id": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89012",
|
||||||
|
"prevId": "afe12cff-df99-417d-977c-3e6a1c77981a",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.accounts": {
|
||||||
|
"name": "accounts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_account_id": {
|
||||||
|
"name": "provider_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"accounts_user_id_users_id_fk": {
|
||||||
|
"name": "accounts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "accounts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"accounts_provider_provider_account_id_pk": {
|
||||||
|
"name": "accounts_provider_provider_account_id_pk",
|
||||||
|
"columns": ["provider", "provider_account_id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"session_token": {
|
||||||
|
"name": "session_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification_tokens": {
|
||||||
|
"name": "verification_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verification_tokens_identifier_token_pk": {
|
||||||
|
"name": "verification_tokens_identifier_token_pk",
|
||||||
|
"columns": ["identifier", "token"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.household_members": {
|
||||||
|
"name": "household_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "household_member_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"household_members_household_id_households_id_fk": {
|
||||||
|
"name": "household_members_household_id_households_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": ["household_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"household_members_user_id_users_id_fk": {
|
||||||
|
"name": "household_members_user_id_users_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"household_members_household_id_user_id_pk": {
|
||||||
|
"name": "household_members_household_id_user_id_pk",
|
||||||
|
"columns": ["household_id", "user_id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.households": {
|
||||||
|
"name": "households",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["email"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.household_member_role": {
|
||||||
|
"name": "household_member_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": ["owner", "member"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
{
|
||||||
|
"id": "eac5a1bb-3598-4f33-999f-58c4e562feb8",
|
||||||
|
"prevId": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89012",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.accounts": {
|
||||||
|
"name": "accounts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_account_id": {
|
||||||
|
"name": "provider_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"accounts_user_id_users_id_fk": {
|
||||||
|
"name": "accounts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "accounts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"accounts_provider_provider_account_id_pk": {
|
||||||
|
"name": "accounts_provider_provider_account_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"provider",
|
||||||
|
"provider_account_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.household_members": {
|
||||||
|
"name": "household_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "household_member_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"household_members_household_id_households_id_fk": {
|
||||||
|
"name": "household_members_household_id_households_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": [
|
||||||
|
"household_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"household_members_user_id_users_id_fk": {
|
||||||
|
"name": "household_members_user_id_users_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"household_members_household_id_user_id_pk": {
|
||||||
|
"name": "household_members_household_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"household_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.households": {
|
||||||
|
"name": "households",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"session_token": {
|
||||||
|
"name": "session_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'default'"
|
||||||
|
},
|
||||||
|
"theme_mode": {
|
||||||
|
"name": "theme_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'system'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification_tokens": {
|
||||||
|
"name": "verification_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verification_tokens_identifier_token_pk": {
|
||||||
|
"name": "verification_tokens_identifier_token_pk",
|
||||||
|
"columns": [
|
||||||
|
"identifier",
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.household_member_role": {
|
||||||
|
"name": "household_member_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"owner",
|
||||||
|
"member"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
{
|
||||||
|
"id": "ba74770c-55d7-4238-97c6-49431a4fcb4c",
|
||||||
|
"prevId": "eac5a1bb-3598-4f33-999f-58c4e562feb8",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.accounts": {
|
||||||
|
"name": "accounts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_account_id": {
|
||||||
|
"name": "provider_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"accounts_user_id_users_id_fk": {
|
||||||
|
"name": "accounts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "accounts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"accounts_provider_provider_account_id_pk": {
|
||||||
|
"name": "accounts_provider_provider_account_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"provider",
|
||||||
|
"provider_account_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.household_members": {
|
||||||
|
"name": "household_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "household_member_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"household_members_household_id_households_id_fk": {
|
||||||
|
"name": "household_members_household_id_households_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": [
|
||||||
|
"household_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"household_members_user_id_users_id_fk": {
|
||||||
|
"name": "household_members_user_id_users_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"household_members_household_id_user_id_pk": {
|
||||||
|
"name": "household_members_household_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"household_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.households": {
|
||||||
|
"name": "households",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"session_token": {
|
||||||
|
"name": "session_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'default'"
|
||||||
|
},
|
||||||
|
"theme_mode": {
|
||||||
|
"name": "theme_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'system'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification_tokens": {
|
||||||
|
"name": "verification_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verification_tokens_identifier_token_pk": {
|
||||||
|
"name": "verification_tokens_identifier_token_pk",
|
||||||
|
"columns": [
|
||||||
|
"identifier",
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.calendar_events": {
|
||||||
|
"name": "calendar_events",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"calendar_id": {
|
||||||
|
"name": "calendar_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"name": "start_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"end_at": {
|
||||||
|
"name": "end_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"all_day": {
|
||||||
|
"name": "all_day",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"name": "location",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"rrule": {
|
||||||
|
"name": "rrule",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"external_source": {
|
||||||
|
"name": "external_source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"calendar_events_calendar_start_idx": {
|
||||||
|
"name": "calendar_events_calendar_start_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "calendar_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "start_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"calendar_events_calendar_id_calendars_id_fk": {
|
||||||
|
"name": "calendar_events_calendar_id_calendars_id_fk",
|
||||||
|
"tableFrom": "calendar_events",
|
||||||
|
"tableTo": "calendars",
|
||||||
|
"columnsFrom": [
|
||||||
|
"calendar_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"calendar_events_owner_id_users_id_fk": {
|
||||||
|
"name": "calendar_events_owner_id_users_id_fk",
|
||||||
|
"tableFrom": "calendar_events",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"calendar_events_range_check": {
|
||||||
|
"name": "calendar_events_range_check",
|
||||||
|
"value": "\"calendar_events\".\"end_at\" >= \"calendar_events\".\"start_at\""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.calendars": {
|
||||||
|
"name": "calendars",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"name": "visibility",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'household'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"calendars_household_idx": {
|
||||||
|
"name": "calendars_household_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "household_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"calendars_owner_idx": {
|
||||||
|
"name": "calendars_owner_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "owner_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"calendars_household_id_households_id_fk": {
|
||||||
|
"name": "calendars_household_id_households_id_fk",
|
||||||
|
"tableFrom": "calendars",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": [
|
||||||
|
"household_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"calendars_owner_id_users_id_fk": {
|
||||||
|
"name": "calendars_owner_id_users_id_fk",
|
||||||
|
"tableFrom": "calendars",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"calendars_visibility_check": {
|
||||||
|
"name": "calendars_visibility_check",
|
||||||
|
"value": "\"calendars\".\"visibility\" in ('private', 'household')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.household_member_role": {
|
||||||
|
"name": "household_member_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"owner",
|
||||||
|
"member"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,927 @@
|
|||||||
|
{
|
||||||
|
"id": "5ca276cc-01ef-429a-aa67-822ef6ed7cbd",
|
||||||
|
"prevId": "ba74770c-55d7-4238-97c6-49431a4fcb4c",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.accounts": {
|
||||||
|
"name": "accounts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_account_id": {
|
||||||
|
"name": "provider_account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"accounts_user_id_users_id_fk": {
|
||||||
|
"name": "accounts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "accounts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"accounts_provider_provider_account_id_pk": {
|
||||||
|
"name": "accounts_provider_provider_account_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"provider",
|
||||||
|
"provider_account_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.household_members": {
|
||||||
|
"name": "household_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "household_member_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'member'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"household_members_household_id_households_id_fk": {
|
||||||
|
"name": "household_members_household_id_households_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": [
|
||||||
|
"household_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"household_members_user_id_users_id_fk": {
|
||||||
|
"name": "household_members_user_id_users_id_fk",
|
||||||
|
"tableFrom": "household_members",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"household_members_household_id_user_id_pk": {
|
||||||
|
"name": "household_members_household_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"household_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.households": {
|
||||||
|
"name": "households",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"session_token": {
|
||||||
|
"name": "session_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'default'"
|
||||||
|
},
|
||||||
|
"theme_mode": {
|
||||||
|
"name": "theme_mode",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'system'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification_tokens": {
|
||||||
|
"name": "verification_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verification_tokens_identifier_token_pk": {
|
||||||
|
"name": "verification_tokens_identifier_token_pk",
|
||||||
|
"columns": [
|
||||||
|
"identifier",
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.calendar_events": {
|
||||||
|
"name": "calendar_events",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"calendar_id": {
|
||||||
|
"name": "calendar_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"start_at": {
|
||||||
|
"name": "start_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"end_at": {
|
||||||
|
"name": "end_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"all_day": {
|
||||||
|
"name": "all_day",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"name": "location",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"rrule": {
|
||||||
|
"name": "rrule",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"external_source": {
|
||||||
|
"name": "external_source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"calendar_events_calendar_start_idx": {
|
||||||
|
"name": "calendar_events_calendar_start_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "calendar_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "start_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"calendar_events_calendar_id_calendars_id_fk": {
|
||||||
|
"name": "calendar_events_calendar_id_calendars_id_fk",
|
||||||
|
"tableFrom": "calendar_events",
|
||||||
|
"tableTo": "calendars",
|
||||||
|
"columnsFrom": [
|
||||||
|
"calendar_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"calendar_events_owner_id_users_id_fk": {
|
||||||
|
"name": "calendar_events_owner_id_users_id_fk",
|
||||||
|
"tableFrom": "calendar_events",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"calendar_events_range_check": {
|
||||||
|
"name": "calendar_events_range_check",
|
||||||
|
"value": "\"calendar_events\".\"end_at\" >= \"calendar_events\".\"start_at\""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.calendars": {
|
||||||
|
"name": "calendars",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"name": "visibility",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'household'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"calendars_household_idx": {
|
||||||
|
"name": "calendars_household_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "household_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"calendars_owner_idx": {
|
||||||
|
"name": "calendars_owner_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "owner_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"calendars_household_id_households_id_fk": {
|
||||||
|
"name": "calendars_household_id_households_id_fk",
|
||||||
|
"tableFrom": "calendars",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": [
|
||||||
|
"household_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"calendars_owner_id_users_id_fk": {
|
||||||
|
"name": "calendars_owner_id_users_id_fk",
|
||||||
|
"tableFrom": "calendars",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"calendars_visibility_check": {
|
||||||
|
"name": "calendars_visibility_check",
|
||||||
|
"value": "\"calendars\".\"visibility\" in ('private', 'household')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.list_items": {
|
||||||
|
"name": "list_items",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"list_id": {
|
||||||
|
"name": "list_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"name": "text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"qty": {
|
||||||
|
"name": "qty",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"due_at": {
|
||||||
|
"name": "due_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"assignee_id": {
|
||||||
|
"name": "assignee_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"name": "position",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"list_items_list_position_idx": {
|
||||||
|
"name": "list_items_list_position_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "list_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "position",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"list_items_assignee_idx": {
|
||||||
|
"name": "list_items_assignee_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "assignee_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"list_items_list_id_lists_id_fk": {
|
||||||
|
"name": "list_items_list_id_lists_id_fk",
|
||||||
|
"tableFrom": "list_items",
|
||||||
|
"tableTo": "lists",
|
||||||
|
"columnsFrom": [
|
||||||
|
"list_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"list_items_assignee_id_users_id_fk": {
|
||||||
|
"name": "list_items_assignee_id_users_id_fk",
|
||||||
|
"tableFrom": "list_items",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"assignee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.lists": {
|
||||||
|
"name": "lists",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"household_id": {
|
||||||
|
"name": "household_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"archived": {
|
||||||
|
"name": "archived",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"lists_household_type_idx": {
|
||||||
|
"name": "lists_household_type_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "household_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "type",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"lists_household_archived_idx": {
|
||||||
|
"name": "lists_household_archived_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "household_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "archived",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"lists_household_id_households_id_fk": {
|
||||||
|
"name": "lists_household_id_households_id_fk",
|
||||||
|
"tableFrom": "lists",
|
||||||
|
"tableTo": "households",
|
||||||
|
"columnsFrom": [
|
||||||
|
"household_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.household_member_role": {
|
||||||
|
"name": "household_member_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"owner",
|
||||||
|
"member"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778048377946,
|
||||||
|
"tag": "0000_silent_magma",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1746533400000,
|
||||||
|
"tag": "0001_auth_tables",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778053473887,
|
||||||
|
"tag": "0002_naive_starbolt",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778054818403,
|
||||||
|
"tag": "0003_rainy_ravenous",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778055440944,
|
||||||
|
"tag": "0004_opposite_wraith",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778056000000,
|
||||||
|
"tag": "0005_auth_schema_repair",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778057808712,
|
||||||
|
"tag": "0006_new_hannibal_king",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778093344354,
|
||||||
|
"tag": "0007_uneven_living_lightning",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778120000000,
|
||||||
|
"tag": "0008_activity_log",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778200000000,
|
||||||
|
"tag": "0009_share_links",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778260000000,
|
||||||
|
"tag": "0010_nullable_actor_id",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778300000000,
|
||||||
|
"tag": "0011_completion_visibility",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778350000000,
|
||||||
|
"tag": "0012_dashboards",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778400000000,
|
||||||
|
"tag": "0013_push_notify_reminders",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-6
@@ -5,12 +5,7 @@ import nextConfig from "eslint-config-next/core-web-vitals";
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: ["node_modules/**", ".next/**", ".claude/**", "dist/**", "drizzle/**", "public/sw.js"],
|
||||||
"node_modules/**",
|
|
||||||
".next/**",
|
|
||||||
"dist/**",
|
|
||||||
"drizzle/**",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
|
|||||||
+176
@@ -1,8 +1,184 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
// Stable in dev to avoid cache churn on hot-reload; unique per production build.
|
||||||
|
const BUILD_TIME = process.env.NODE_ENV === "development" ? "dev" : String(Date.now());
|
||||||
|
|
||||||
|
writeFileSync(resolve(process.cwd(), "public/sw.js"), buildSwContent(BUILD_TIME));
|
||||||
|
|
||||||
|
function buildSwContent(version: string): string {
|
||||||
|
return `// famapp service worker — v${version}
|
||||||
|
// Generated at build time. Do not edit directly.
|
||||||
|
const CACHE_VERSION = "${version}";
|
||||||
|
const SHELL = "famapp-shell-" + CACHE_VERSION;
|
||||||
|
const API = "famapp-api-" + CACHE_VERSION;
|
||||||
|
const OFFLINE = "/offline.html";
|
||||||
|
|
||||||
|
// --- install: precache the offline fallback ---
|
||||||
|
|
||||||
|
self.addEventListener("install", (e) => {
|
||||||
|
e.waitUntil(caches.open(SHELL).then((c) => c.add(OFFLINE)));
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- activate: evict old-version caches, claim clients ---
|
||||||
|
|
||||||
|
self.addEventListener("activate", (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter(
|
||||||
|
(k) =>
|
||||||
|
k.startsWith("famapp-") && !k.endsWith("-" + CACHE_VERSION)
|
||||||
|
)
|
||||||
|
.map((k) => caches.delete(k))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- fetch: strategy dispatch ---
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (e) => {
|
||||||
|
const { request } = e;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Mutations — network-only; notify clients if the network is unreachable.
|
||||||
|
if (request.method !== "GET") {
|
||||||
|
e.respondWith(
|
||||||
|
fetch(request).catch(() => {
|
||||||
|
self.clients
|
||||||
|
.matchAll({ includeUncontrolled: true })
|
||||||
|
.then((cs) =>
|
||||||
|
cs.forEach((c) => c.postMessage({ type: "OFFLINE_MUTATION" }))
|
||||||
|
);
|
||||||
|
return new Response(JSON.stringify({ error: "offline" }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth endpoints — always network-only.
|
||||||
|
if (url.pathname.startsWith("/api/auth/")) return;
|
||||||
|
|
||||||
|
// Next.js immutable static chunks — stale-while-revalidate.
|
||||||
|
if (url.pathname.startsWith("/_next/static/")) {
|
||||||
|
e.respondWith(staleWhileRevalidate(request, SHELL));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API GETs — network-first with 2-second timeout, fall back to cache.
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
e.respondWith(networkFirstWithTimeout(request, API, 2000));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation — stale-while-revalidate; fall back to offline page.
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
e.respondWith(navigateWithFallback(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- push notifications ---
|
||||||
|
|
||||||
|
self.addEventListener("push", (e) => {
|
||||||
|
if (!e.data) return;
|
||||||
|
const data = e.data.json();
|
||||||
|
e.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || "famapp", {
|
||||||
|
body: data.body || "",
|
||||||
|
data: { url: data.url || "/" },
|
||||||
|
icon: "/icon-192.png",
|
||||||
|
badge: "/icon-192.png",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (e) => {
|
||||||
|
e.notification.close();
|
||||||
|
const url = e.notification.data?.url || "/";
|
||||||
|
e.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: "window", includeUncontrolled: true })
|
||||||
|
.then((cs) => {
|
||||||
|
const match = cs.find((c) => c.url.includes(self.location.origin));
|
||||||
|
if (match) {
|
||||||
|
match.focus();
|
||||||
|
return match.navigate(url);
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- strategy helpers ---
|
||||||
|
|
||||||
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
const update = fetch(request)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
// Serve cached immediately; let update happen in the background.
|
||||||
|
return cached ?? (await update);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirstWithTimeout(request, cacheName, ms) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const ac = new AbortController();
|
||||||
|
const timer = setTimeout(() => ac.abort(), ms);
|
||||||
|
try {
|
||||||
|
const res = await fetch(request, { signal: ac.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timer);
|
||||||
|
return (await cache.match(request)) ?? new Response(null, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateWithFallback(request) {
|
||||||
|
const cache = await caches.open(SHELL);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
const networkFetch = fetch(request)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) cache.put(request, res.clone());
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
if (cached) {
|
||||||
|
// Return cached immediately; revalidate in the background.
|
||||||
|
networkFetch;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const net = await networkFetch;
|
||||||
|
if (net) return net;
|
||||||
|
return (
|
||||||
|
(await cache.match(OFFLINE)) ?? new Response("Offline", { status: 503 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
// Keep pino and pino-pretty as native Node.js requires so their worker-thread
|
||||||
|
// transport and stream internals work correctly inside the standalone bundle.
|
||||||
|
serverExternalPackages: ["pino", "pino-pretty", "drizzle-orm", "postgres"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
+10
-1
@@ -20,7 +20,9 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"gen:icons": "node scripts/generate-icons.mjs",
|
||||||
|
"vapid:generate": "node scripts/vapid-generate.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
@@ -30,10 +32,13 @@
|
|||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-grid-layout": "^2.1.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-config-next": "^16.2.4",
|
"eslint-config-next": "^16.2.4",
|
||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
@@ -50,16 +55,20 @@
|
|||||||
"@fullcalendar/timegrid": "^6.1.20",
|
"@fullcalendar/timegrid": "^6.1.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "5.0.0-beta.31",
|
"next-auth": "5.0.0-beta.31",
|
||||||
|
"pino": "^10.3.1",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-grid-layout": "^2.2.3",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zod-to-json-schema": "^3.25.2"
|
"zod-to-json-schema": "^3.25.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+815
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 496 B |
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="512" height="512" rx="80" fill="#4F46E5"/>
|
||||||
|
<!-- House body -->
|
||||||
|
<polygon points="256,108 396,234 364,234 364,392 148,392 148,234 116,234" fill="white"/>
|
||||||
|
<!-- Door -->
|
||||||
|
<rect x="212" y="296" width="88" height="96" rx="6" fill="#4F46E5"/>
|
||||||
|
<!-- Left window -->
|
||||||
|
<rect x="164" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
||||||
|
<!-- Right window -->
|
||||||
|
<rect x="282" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "famapp",
|
||||||
|
"short_name": "famapp",
|
||||||
|
"description": "Family coordination app",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#4F46E5",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#4F46E5" />
|
||||||
|
<title>famapp — offline</title>
|
||||||
|
<style>
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--indigo: #4f46e5;
|
||||||
|
--indigo-light: #e0e7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #111827;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--indigo);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: #6b7280;
|
||||||
|
max-width: 28ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
background: var(--indigo);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="icon">
|
||||||
|
<!-- House silhouette -->
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<polygon points="20,5 34,17 31,17 31,35 9,35 9,17 6,17" fill="white" />
|
||||||
|
<rect x="15" y="22" width="10" height="13" rx="1" fill="#4F46E5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>You're offline</h1>
|
||||||
|
<p>famapp needs a connection — check your network and try again.</p>
|
||||||
|
<button onclick="location.reload()">Retry</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Generates placeholder PNG icons for the famapp PWA.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/generate-icons.mjs
|
||||||
|
*
|
||||||
|
* Produces:
|
||||||
|
* public/icon-192.png
|
||||||
|
* public/icon-384.png
|
||||||
|
* public/icon-512.png
|
||||||
|
* public/icon-512-maskable.png (same image; OS applies its own mask)
|
||||||
|
* public/icon-180.png (apple-touch-icon)
|
||||||
|
*
|
||||||
|
* All images are solid indigo (#4F46E5) squares — replace with a real
|
||||||
|
* branded export from icon.svg when assets are finalised.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deflateSync } from "zlib";
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publicDir = resolve(__dir, "../public");
|
||||||
|
|
||||||
|
// Build CRC-32 lookup table once.
|
||||||
|
const CRC_TABLE = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
CRC_TABLE[i] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function u32(n) {
|
||||||
|
const b = Buffer.alloc(4);
|
||||||
|
b.writeUInt32BE(n, 0);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngChunk(type, data) {
|
||||||
|
const typeBytes = Buffer.from(type, "ascii");
|
||||||
|
const crc = u32(crc32(Buffer.concat([typeBytes, data])));
|
||||||
|
return Buffer.concat([u32(data.length), typeBytes, data, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Buffer containing a valid PNG of size×size filled with (r, g, b).
|
||||||
|
*/
|
||||||
|
function solidPNG(size, r, g, b) {
|
||||||
|
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
|
||||||
|
const ihdr = pngChunk(
|
||||||
|
"IHDR",
|
||||||
|
Buffer.concat([
|
||||||
|
u32(size),
|
||||||
|
u32(size),
|
||||||
|
Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One filter byte (0 = None) followed by size×3 RGB bytes per row.
|
||||||
|
const rowLen = 1 + size * 3;
|
||||||
|
const raw = Buffer.alloc(size * rowLen);
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const base = y * rowLen;
|
||||||
|
raw[base] = 0;
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
raw[base + 1 + x * 3] = r;
|
||||||
|
raw[base + 2 + x * 3] = g;
|
||||||
|
raw[base + 3 + x * 3] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idat = pngChunk("IDAT", deflateSync(raw));
|
||||||
|
const iend = pngChunk("IEND", Buffer.alloc(0));
|
||||||
|
|
||||||
|
return Buffer.concat([PNG_SIG, ihdr, idat, iend]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indigo-600 (#4F46E5)
|
||||||
|
const [R, G, B] = [0x4f, 0x46, 0xe5];
|
||||||
|
|
||||||
|
const icons = [
|
||||||
|
{ name: "icon-192.png", size: 192 },
|
||||||
|
{ name: "icon-384.png", size: 384 },
|
||||||
|
{ name: "icon-512.png", size: 512 },
|
||||||
|
{ name: "icon-512-maskable.png", size: 512 },
|
||||||
|
{ name: "icon-180.png", size: 180 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, size } of icons) {
|
||||||
|
const out = resolve(publicDir, name);
|
||||||
|
writeFileSync(out, solidPNG(size, R, G, B));
|
||||||
|
console.log(` ✓ public/${name} (${size}×${size})`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("DATABASE_URL is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsFolder = process.env.MIGRATIONS_FOLDER ?? "./drizzle";
|
||||||
|
const sql = postgres(url, { max: 1 });
|
||||||
|
const db = drizzle(sql);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({ level: "info", msg: "migrations applied", folder: migrationsFolder }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(JSON.stringify({ level: "error", msg: "migrations failed", err: String(err) }));
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error(JSON.stringify({ level: "error", msg: "DATABASE_URL is required" }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = postgres(url, { max: 1 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [existing] = await sql`SELECT id, name FROM households LIMIT 1`;
|
||||||
|
if (existing) {
|
||||||
|
console.log(JSON.stringify({ level: "info", msg: "household exists", name: existing.name }));
|
||||||
|
} else {
|
||||||
|
await sql`INSERT INTO households (name) VALUES ('Home')`;
|
||||||
|
console.log(JSON.stringify({ level: "info", msg: "seeded household Home" }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(JSON.stringify({ level: "error", msg: "seed failed", err: String(err) }));
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import webPush from "web-push";
|
||||||
|
|
||||||
|
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
||||||
|
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
|
||||||
|
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
|
||||||
|
console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { parseDashboardLayout, computeDefaultLayout } from "@/lib/dashboard";
|
||||||
|
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||||
|
import { getDashboardBySlug } from "@/app/d/actions";
|
||||||
|
import { DashboardEditor } from "@/components/dashboard-editor";
|
||||||
|
import { QuickAddFab } from "@/components/quick-add-fab";
|
||||||
|
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const smColSpan: Record<number, string> = {
|
||||||
|
1: "sm:col-span-1",
|
||||||
|
2: "sm:col-span-2",
|
||||||
|
3: "sm:col-span-3",
|
||||||
|
4: "sm:col-span-4",
|
||||||
|
5: "sm:col-span-5",
|
||||||
|
6: "sm:col-span-6",
|
||||||
|
7: "sm:col-span-7",
|
||||||
|
8: "sm:col-span-8",
|
||||||
|
9: "sm:col-span-9",
|
||||||
|
10: "sm:col-span-10",
|
||||||
|
11: "sm:col-span-11",
|
||||||
|
12: "sm:col-span-12",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function DashboardPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ edit?: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const { edit } = await searchParams;
|
||||||
|
const isEditing = edit === "1";
|
||||||
|
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
const dashboard = await getDashboardBySlug(slug);
|
||||||
|
if (!dashboard) notFound();
|
||||||
|
|
||||||
|
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
|
||||||
|
const widgetMetas = getWidgetMetas();
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<DashboardEditor
|
||||||
|
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
|
||||||
|
layout={layout}
|
||||||
|
widgetMetas={widgetMetas}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = { userId: user.id, householdId: household.id };
|
||||||
|
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<QuickAddFab />
|
||||||
|
<EditDashboardButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
|
||||||
|
{placements.map((placement, i) => {
|
||||||
|
const widget = getWidget(placement.widgetId);
|
||||||
|
if (!widget) return null;
|
||||||
|
const colClass = smColSpan[placement.w] ?? "sm:col-span-12";
|
||||||
|
return (
|
||||||
|
<div key={i} className={`col-span-1 ${colClass}`}>
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{widget.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="animate-pulse space-y-2">
|
||||||
|
<div className="h-3 w-3/4 rounded bg-muted" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||||
|
<div className="h-3 w-2/3 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{widget.render({ config: placement.config, ctx })}
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { getWidget } from "@/modules/_core";
|
||||||
|
import { dashboards } from "@/modules/_core/schema";
|
||||||
|
import { computeDefaultLayout, type DashboardLayout } from "@/lib/dashboard";
|
||||||
|
|
||||||
|
export type DashboardMeta = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listDashboards(): Promise<DashboardMeta[]> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: dashboards.id,
|
||||||
|
name: dashboards.name,
|
||||||
|
slug: dashboards.slug,
|
||||||
|
isDefault: dashboards.isDefault,
|
||||||
|
position: dashboards.position,
|
||||||
|
})
|
||||||
|
.from(dashboards)
|
||||||
|
.where(eq(dashboards.userId, user.id))
|
||||||
|
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDefaultDashboardSlug(): Promise<string> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const rows = await db
|
||||||
|
.select({ slug: dashboards.slug })
|
||||||
|
.from(dashboards)
|
||||||
|
.where(and(eq(dashboards.userId, user.id), eq(dashboards.isDefault, true)))
|
||||||
|
.limit(1);
|
||||||
|
if (rows[0]) return rows[0].slug;
|
||||||
|
// Fallback: first dashboard by position
|
||||||
|
const [first] = await db
|
||||||
|
.select({ slug: dashboards.slug })
|
||||||
|
.from(dashboards)
|
||||||
|
.where(eq(dashboards.userId, user.id))
|
||||||
|
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
if (first) return first.slug;
|
||||||
|
// No dashboards yet — insert one directly without revalidatePath (we're in a
|
||||||
|
// render path; the redirect that follows is a fresh request anyway).
|
||||||
|
const slug = await uniqueSlug(user.id, "home");
|
||||||
|
await db
|
||||||
|
.insert(dashboards)
|
||||||
|
.values({ userId: user.id, name: "Home", slug, isDefault: false, position: 0 });
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardBySlug(slug: string) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(dashboards)
|
||||||
|
.where(and(eq(dashboards.userId, user.id), eq(dashboards.slug, slug)))
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(name: string): string {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "") || "dashboard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueSlug(userId: string, base: string): Promise<string> {
|
||||||
|
let slug = base;
|
||||||
|
let attempt = 1;
|
||||||
|
for (;;) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: dashboards.id })
|
||||||
|
.from(dashboards)
|
||||||
|
.where(and(eq(dashboards.userId, userId), eq(dashboards.slug, slug)))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) return slug;
|
||||||
|
attempt++;
|
||||||
|
slug = `${base}-${attempt}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDashboard(name: string): Promise<DashboardMeta> {
|
||||||
|
const parsed = z.string().trim().min(1).max(80).parse(name);
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const slug = await uniqueSlug(user.id, toSlug(parsed));
|
||||||
|
const rows = await db
|
||||||
|
.insert(dashboards)
|
||||||
|
.values({ userId: user.id, name: parsed, slug, isDefault: false, position: 9999 })
|
||||||
|
.returning();
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) throw new Error("Insert failed");
|
||||||
|
revalidatePath("/");
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
slug: row.slug,
|
||||||
|
isDefault: row.isDefault,
|
||||||
|
position: row.position,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameDashboard(id: string, name: string): Promise<void> {
|
||||||
|
const parsedName = z.string().trim().min(1).max(80).parse(name);
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.update(dashboards)
|
||||||
|
.set({ name: parsedName, updatedAt: new Date() })
|
||||||
|
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDashboard(id: string): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const all = await db
|
||||||
|
.select({ id: dashboards.id, isDefault: dashboards.isDefault, slug: dashboards.slug })
|
||||||
|
.from(dashboards)
|
||||||
|
.where(eq(dashboards.userId, user.id));
|
||||||
|
if (all.length <= 1) throw new Error("Cannot delete your only dashboard");
|
||||||
|
const target = all.find((d) => d.id === id);
|
||||||
|
if (!target) throw new Error("Dashboard not found");
|
||||||
|
|
||||||
|
await db.delete(dashboards).where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||||
|
|
||||||
|
// If we deleted the default, promote another
|
||||||
|
if (target.isDefault) {
|
||||||
|
const next = all.find((d) => d.id !== id);
|
||||||
|
if (next) {
|
||||||
|
await db
|
||||||
|
.update(dashboards)
|
||||||
|
.set({ isDefault: true })
|
||||||
|
.where(and(eq(dashboards.id, next.id), eq(dashboards.userId, user.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revalidatePath("/");
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDefaultDashboard(id: string): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db.update(dashboards).set({ isDefault: false }).where(eq(dashboards.userId, user.id));
|
||||||
|
await db
|
||||||
|
.update(dashboards)
|
||||||
|
.set({ isDefault: true })
|
||||||
|
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderDashboards(orderedIds: string[]): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await Promise.all(
|
||||||
|
orderedIds.map((id, i) =>
|
||||||
|
db
|
||||||
|
.update(dashboards)
|
||||||
|
.set({ position: i })
|
||||||
|
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
// Validate each widget's config against its registered schema
|
||||||
|
for (const placement of layout.widgets) {
|
||||||
|
const widget = getWidget(placement.widgetId);
|
||||||
|
if (!widget) continue;
|
||||||
|
widget.configSchema.parse(placement.config);
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(dashboards)
|
||||||
|
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||||
|
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetDashboardLayout(id: string): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const layout = computeDefaultLayout();
|
||||||
|
await db
|
||||||
|
.update(dashboards)
|
||||||
|
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||||
|
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveWidgetConfigOptions(widgetId: string): Promise<unknown> {
|
||||||
|
const { user, household } = await getCurrentSession();
|
||||||
|
const widget = getWidget(widgetId);
|
||||||
|
if (!widget?.resolveConfigOptions) return null;
|
||||||
|
return widget.resolveConfigOptions({ userId: user.id, householdId: household.id });
|
||||||
|
}
|
||||||
+190
-190
@@ -5,233 +5,233 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warm theme — light */
|
/* Warm theme — light */
|
||||||
[data-theme="warm"] {
|
[data-theme="warm"] {
|
||||||
--background: oklch(0.98 0.012 60);
|
--background: oklch(0.98 0.012 60);
|
||||||
--foreground: oklch(0.18 0.03 50);
|
--foreground: oklch(0.18 0.03 50);
|
||||||
--card: oklch(0.97 0.014 58);
|
--card: oklch(0.97 0.014 58);
|
||||||
--card-foreground: oklch(0.18 0.03 50);
|
--card-foreground: oklch(0.18 0.03 50);
|
||||||
--popover: oklch(0.97 0.014 58);
|
--popover: oklch(0.97 0.014 58);
|
||||||
--popover-foreground: oklch(0.18 0.03 50);
|
--popover-foreground: oklch(0.18 0.03 50);
|
||||||
--primary: oklch(0.52 0.18 40);
|
--primary: oklch(0.52 0.18 40);
|
||||||
--primary-foreground: oklch(0.98 0.01 60);
|
--primary-foreground: oklch(0.98 0.01 60);
|
||||||
--secondary: oklch(0.92 0.02 60);
|
--secondary: oklch(0.92 0.02 60);
|
||||||
--secondary-foreground: oklch(0.25 0.04 45);
|
--secondary-foreground: oklch(0.25 0.04 45);
|
||||||
--muted: oklch(0.93 0.018 58);
|
--muted: oklch(0.93 0.018 58);
|
||||||
--muted-foreground: oklch(0.52 0.04 50);
|
--muted-foreground: oklch(0.52 0.04 50);
|
||||||
--accent: oklch(0.90 0.03 55);
|
--accent: oklch(0.9 0.03 55);
|
||||||
--accent-foreground: oklch(0.22 0.04 45);
|
--accent-foreground: oklch(0.22 0.04 45);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.88 0.025 60);
|
--border: oklch(0.88 0.025 60);
|
||||||
--input: oklch(0.88 0.025 60);
|
--input: oklch(0.88 0.025 60);
|
||||||
--ring: oklch(0.65 0.12 45);
|
--ring: oklch(0.65 0.12 45);
|
||||||
--chart-1: oklch(0.72 0.16 40);
|
--chart-1: oklch(0.72 0.16 40);
|
||||||
--chart-2: oklch(0.62 0.14 55);
|
--chart-2: oklch(0.62 0.14 55);
|
||||||
--chart-3: oklch(0.52 0.12 45);
|
--chart-3: oklch(0.52 0.12 45);
|
||||||
--chart-4: oklch(0.45 0.10 40);
|
--chart-4: oklch(0.45 0.1 40);
|
||||||
--chart-5: oklch(0.35 0.08 35);
|
--chart-5: oklch(0.35 0.08 35);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.94 0.018 58);
|
--sidebar: oklch(0.94 0.018 58);
|
||||||
--sidebar-foreground: oklch(0.18 0.03 50);
|
--sidebar-foreground: oklch(0.18 0.03 50);
|
||||||
--sidebar-primary: oklch(0.52 0.18 40);
|
--sidebar-primary: oklch(0.52 0.18 40);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0.01 60);
|
--sidebar-primary-foreground: oklch(0.98 0.01 60);
|
||||||
--sidebar-accent: oklch(0.90 0.03 55);
|
--sidebar-accent: oklch(0.9 0.03 55);
|
||||||
--sidebar-accent-foreground: oklch(0.22 0.04 45);
|
--sidebar-accent-foreground: oklch(0.22 0.04 45);
|
||||||
--sidebar-border: oklch(0.88 0.025 60);
|
--sidebar-border: oklch(0.88 0.025 60);
|
||||||
--sidebar-ring: oklch(0.65 0.12 45);
|
--sidebar-ring: oklch(0.65 0.12 45);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Warm theme — dark */
|
/* Warm theme — dark */
|
||||||
[data-theme="warm"].dark {
|
[data-theme="warm"].dark {
|
||||||
--background: oklch(0.16 0.025 45);
|
--background: oklch(0.16 0.025 45);
|
||||||
--foreground: oklch(0.95 0.015 60);
|
--foreground: oklch(0.95 0.015 60);
|
||||||
--card: oklch(0.22 0.03 48);
|
--card: oklch(0.22 0.03 48);
|
||||||
--card-foreground: oklch(0.95 0.015 60);
|
--card-foreground: oklch(0.95 0.015 60);
|
||||||
--popover: oklch(0.22 0.03 48);
|
--popover: oklch(0.22 0.03 48);
|
||||||
--popover-foreground: oklch(0.95 0.015 60);
|
--popover-foreground: oklch(0.95 0.015 60);
|
||||||
--primary: oklch(0.78 0.14 50);
|
--primary: oklch(0.78 0.14 50);
|
||||||
--primary-foreground: oklch(0.16 0.025 45);
|
--primary-foreground: oklch(0.16 0.025 45);
|
||||||
--secondary: oklch(0.28 0.04 50);
|
--secondary: oklch(0.28 0.04 50);
|
||||||
--secondary-foreground: oklch(0.95 0.015 60);
|
--secondary-foreground: oklch(0.95 0.015 60);
|
||||||
--muted: oklch(0.28 0.035 48);
|
--muted: oklch(0.28 0.035 48);
|
||||||
--muted-foreground: oklch(0.68 0.04 55);
|
--muted-foreground: oklch(0.68 0.04 55);
|
||||||
--accent: oklch(0.30 0.045 50);
|
--accent: oklch(0.3 0.045 50);
|
||||||
--accent-foreground: oklch(0.95 0.015 60);
|
--accent-foreground: oklch(0.95 0.015 60);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.55 0.08 50);
|
--ring: oklch(0.55 0.08 50);
|
||||||
--chart-1: oklch(0.78 0.14 50);
|
--chart-1: oklch(0.78 0.14 50);
|
||||||
--chart-2: oklch(0.65 0.12 55);
|
--chart-2: oklch(0.65 0.12 55);
|
||||||
--chart-3: oklch(0.52 0.10 48);
|
--chart-3: oklch(0.52 0.1 48);
|
||||||
--chart-4: oklch(0.42 0.08 42);
|
--chart-4: oklch(0.42 0.08 42);
|
||||||
--chart-5: oklch(0.32 0.06 38);
|
--chart-5: oklch(0.32 0.06 38);
|
||||||
--sidebar: oklch(0.20 0.03 47);
|
--sidebar: oklch(0.2 0.03 47);
|
||||||
--sidebar-foreground: oklch(0.95 0.015 60);
|
--sidebar-foreground: oklch(0.95 0.015 60);
|
||||||
--sidebar-primary: oklch(0.78 0.14 50);
|
--sidebar-primary: oklch(0.78 0.14 50);
|
||||||
--sidebar-primary-foreground: oklch(0.16 0.025 45);
|
--sidebar-primary-foreground: oklch(0.16 0.025 45);
|
||||||
--sidebar-accent: oklch(0.30 0.045 50);
|
--sidebar-accent: oklch(0.3 0.045 50);
|
||||||
--sidebar-accent-foreground: oklch(0.95 0.015 60);
|
--sidebar-accent-foreground: oklch(0.95 0.015 60);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.55 0.08 50);
|
--sidebar-ring: oklch(0.55 0.08 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc {
|
.fc {
|
||||||
--fc-border-color: var(--border);
|
--fc-border-color: var(--border);
|
||||||
--fc-page-bg-color: var(--background);
|
--fc-page-bg-color: var(--background);
|
||||||
--fc-neutral-bg-color: var(--muted);
|
--fc-neutral-bg-color: var(--muted);
|
||||||
--fc-neutral-text-color: var(--muted-foreground);
|
--fc-neutral-text-color: var(--muted-foreground);
|
||||||
--fc-button-bg-color: var(--primary);
|
--fc-button-bg-color: var(--primary);
|
||||||
--fc-button-border-color: var(--primary);
|
--fc-button-border-color: var(--primary);
|
||||||
--fc-button-text-color: var(--primary-foreground);
|
--fc-button-text-color: var(--primary-foreground);
|
||||||
--fc-button-hover-bg-color: var(--foreground);
|
--fc-button-hover-bg-color: var(--foreground);
|
||||||
--fc-button-hover-border-color: var(--foreground);
|
--fc-button-hover-border-color: var(--foreground);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-button {
|
.fc .fc-button {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-toolbar-title {
|
.fc .fc-toolbar-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-daygrid-day-number,
|
.fc .fc-daygrid-day-number,
|
||||||
.fc .fc-col-header-cell-cushion {
|
.fc .fc-col-header-cell-cushion {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-event {
|
.fc .fc-event {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 1px 3px;
|
padding: 1px 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-10
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -6,14 +6,34 @@ import { AppNav } from "@/components/app-nav";
|
|||||||
import "@/modules"; // registers all module manifests
|
import "@/modules"; // registers all module manifests
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { dashboards, users } from "@/modules/_core/schema";
|
||||||
|
import type { DashboardMeta } from "@/app/d/actions";
|
||||||
|
import { getQuickAdds } from "@/modules/_core";
|
||||||
|
import { QuickAddProvider } from "@/components/quick-add-provider";
|
||||||
|
import { QuickAddSheet } from "@/components/quick-add-sheet";
|
||||||
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
|
import { InstallPrompt } from "@/components/install-prompt";
|
||||||
|
|
||||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#4F46E5",
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "famapp",
|
title: "famapp",
|
||||||
description: "Family coordination app",
|
description: "Family coordination app",
|
||||||
|
manifest: "/manifest.webmanifest",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "famapp",
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
apple: "/icon-180.png",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Runs before paint — reads localStorage / prefers-color-scheme and applies
|
// Runs before paint — reads localStorage / prefers-color-scheme and applies
|
||||||
@@ -29,13 +49,10 @@ const prePaintScript = `(function(){
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})();`;
|
})();`;
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
let theme = "default";
|
let theme = "default";
|
||||||
let themeMode = "system";
|
let themeMode = "system";
|
||||||
|
let userDashboards: DashboardMeta[] = [];
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
@@ -48,12 +65,25 @@ export default async function RootLayout({
|
|||||||
theme = row.theme;
|
theme = row.theme;
|
||||||
themeMode = row.themeMode;
|
themeMode = row.themeMode;
|
||||||
}
|
}
|
||||||
|
userDashboards = await db
|
||||||
|
.select({
|
||||||
|
id: dashboards.id,
|
||||||
|
name: dashboards.name,
|
||||||
|
slug: dashboards.slug,
|
||||||
|
isDefault: dashboards.isDefault,
|
||||||
|
position: dashboards.position,
|
||||||
|
})
|
||||||
|
.from(dashboards)
|
||||||
|
.where(eq(dashboards.userId, session.user.id))
|
||||||
|
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
// For system mode we can't know the preference on the server — the inline
|
// For system mode we can't know the preference on the server — the inline
|
||||||
// script will correct it before paint. We optimistically render light here.
|
// script will correct it before paint. We optimistically render light here.
|
||||||
const isDark = themeMode === "dark";
|
const isDark = themeMode === "dark";
|
||||||
|
|
||||||
|
const quickAdds = getQuickAdds();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
@@ -64,8 +94,14 @@ export default async function RootLayout({
|
|||||||
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
||||||
</head>
|
</head>
|
||||||
<body className="min-h-screen">
|
<body className="min-h-screen">
|
||||||
<AppNav />
|
<QuickAddProvider actions={quickAdds}>
|
||||||
<main>{children}</main>
|
<AppNav dashboards={userDashboards} />
|
||||||
|
<main>{children}</main>
|
||||||
|
<QuickAddSheet />
|
||||||
|
<CommandPalette />
|
||||||
|
<InstallPrompt />
|
||||||
|
<PwaRegister />
|
||||||
|
</QuickAddProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ListsIndex } from "@/modules/lists/components/lists-index";
|
import { ListsIndex } from "@/modules/lists/components/lists-index";
|
||||||
import { listLists } from "@/modules/lists/server/queries";
|
import { listListsWithItems } from "@/modules/lists/server/queries";
|
||||||
|
|
||||||
export default async function ListsPage() {
|
export default async function ListsPage() {
|
||||||
const lists = await listLists();
|
const lists = await listListsWithItems();
|
||||||
return <ListsIndex lists={lists} />;
|
return <ListsIndex lists={lists} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-7
@@ -1,8 +1,7 @@
|
|||||||
export default function Page() {
|
import { redirect } from "next/navigation";
|
||||||
return (
|
import { getDefaultDashboardSlug } from "@/app/d/actions";
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
|
||||||
<h1 className="text-4xl font-bold">famapp</h1>
|
export default async function RootPage() {
|
||||||
<p className="mt-4 text-muted-foreground">Dashboard coming soon</p>
|
const slug = await getDefaultDashboardSlug();
|
||||||
</main>
|
redirect(`/d/${slug}`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { resolveShareToken } from "@/modules/_core/share";
|
||||||
|
import { getEntityType } from "@/modules/_core/registry";
|
||||||
|
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate-limit prefix length — must match the value used in middleware.
|
||||||
|
const RL_PREFIX_LEN = 8;
|
||||||
|
|
||||||
|
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await params;
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip =
|
||||||
|
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
|
headersList.get("x-real-ip") ??
|
||||||
|
"0.0.0.0";
|
||||||
|
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
||||||
|
|
||||||
|
// Secondary rate-limit check in the Node.js runtime (failure-only bucket).
|
||||||
|
// The primary 429 enforcement lives in src/middleware.ts which counts all
|
||||||
|
// requests in the Edge runtime. This page tracks only failed token lookups,
|
||||||
|
// providing accurate per-failure accounting. The two buckets are independent
|
||||||
|
// (separate module instances across runtimes); a shared Redis store would
|
||||||
|
// unify them for multi-replica deployments.
|
||||||
|
if (isRateLimited(rlKey)) {
|
||||||
|
return <ShareRateLimitError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveShareToken(token);
|
||||||
|
if (!resolved) {
|
||||||
|
// Only failed lookups increment the failure bucket.
|
||||||
|
recordFailure(rlKey);
|
||||||
|
return <ShareError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityReg = getEntityType(resolved.entityType);
|
||||||
|
if (!entityReg?.loadForShare || !entityReg.renderSharedView) {
|
||||||
|
return <ShareError message="This content type cannot be shared." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await entityReg.loadForShare(resolved.entityId);
|
||||||
|
if (!data) {
|
||||||
|
recordFailure(rlKey);
|
||||||
|
return <ShareError />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="border-b bg-background px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Shared via famapp
|
||||||
|
{resolved.capabilities.write ? " · You can edit this" : " · View only"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareRateLimitError() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
|
<h1 className="text-xl font-semibold">Too many requests</h1>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
You have made too many requests in a short period. Please wait a minute and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareError({ message }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
|
<h1 className="text-xl font-semibold">Link not found</h1>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
|
{message ??
|
||||||
|
"This share link may have expired or been revoked. Ask the sender for a new link."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
-11
@@ -1,25 +1,34 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { users } from "@/modules/_core/schema";
|
import { users } from "@/modules/_core/schema";
|
||||||
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
|
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
|
||||||
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { revokeShareLink } from "@/modules/_core/share";
|
||||||
|
|
||||||
export async function setUserTheme({
|
export async function setUserTheme({ theme, mode }: { theme: ThemeId; mode: ThemeMode }) {
|
||||||
theme,
|
|
||||||
mode,
|
|
||||||
}: {
|
|
||||||
theme: ThemeId;
|
|
||||||
mode: ThemeMode;
|
|
||||||
}) {
|
|
||||||
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
|
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
|
||||||
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
|
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
|
||||||
|
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
await db
|
await db.update(users).set({ theme, themeMode: mode }).where(eq(users.id, user.id));
|
||||||
.update(users)
|
}
|
||||||
.set({ theme, themeMode: mode })
|
|
||||||
.where(eq(users.id, user.id));
|
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
|
||||||
|
const parsed = Number(hours);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 8760)
|
||||||
|
throw new Error("Invalid hours value");
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db.update(users).set({ completionVisibilityHours: parsed }).where(eq(users.id, user.id));
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeShareLinkAction(formData: FormData): Promise<void> {
|
||||||
|
const id = formData.get("id");
|
||||||
|
if (typeof id !== "string") throw new Error("Missing id");
|
||||||
|
await revokeShareLink(id);
|
||||||
|
revalidatePath("/settings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ export async function renameHousehold(formData: FormData) {
|
|||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
if (typeof name !== "string" || !name.trim()) throw new Error("Invalid name");
|
if (typeof name !== "string" || !name.trim()) throw new Error("Invalid name");
|
||||||
|
|
||||||
await db
|
await db.update(households).set({ name: name.trim() }).where(eq(households.id, household.id));
|
||||||
.update(households)
|
|
||||||
.set({ name: name.trim() })
|
|
||||||
.where(eq(households.id, household.id));
|
|
||||||
|
|
||||||
revalidatePath("/settings/household");
|
revalidatePath("/settings/household");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { getCurrentSession } from "@/lib/session";
|
|||||||
import { householdMembers, users } from "@/modules/_core/schema";
|
import { householdMembers, users } from "@/modules/_core/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { renameHousehold } from "./actions";
|
import { renameHousehold } from "./actions";
|
||||||
|
|
||||||
export default async function HouseholdSettingsPage() {
|
export default async function HouseholdSettingsPage() {
|
||||||
@@ -34,9 +29,7 @@ export default async function HouseholdSettingsPage() {
|
|||||||
{members.map(({ user, role: memberRole }) => (
|
{members.map(({ user, role: memberRole }) => (
|
||||||
<li key={user.id} className="flex items-center justify-between">
|
<li key={user.id} className="flex items-center justify-between">
|
||||||
<span>{user.name ?? user.email}</span>
|
<span>{user.name ?? user.email}</span>
|
||||||
<span className="text-sm text-muted-foreground capitalize">
|
<span className="text-sm text-muted-foreground capitalize">{memberRole}</span>
|
||||||
{memberRole}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { notifications, users } from "@/modules/_core/schema";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function markNotificationRead(id: string): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.update(notifications)
|
||||||
|
.set({ readAt: new Date() })
|
||||||
|
.where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.update(notifications)
|
||||||
|
.set({ readAt: new Date() })
|
||||||
|
.where(and(eq(notifications.userId, user.id), isNull(notifications.readAt)));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setNotifChannel(
|
||||||
|
channel: "push" | "inapp" | "ntfy",
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const col =
|
||||||
|
channel === "push"
|
||||||
|
? { notifPush: enabled }
|
||||||
|
: channel === "inapp"
|
||||||
|
? { notifInApp: enabled }
|
||||||
|
: { notifNtfy: enabled };
|
||||||
|
await db.update(users).set(col).where(eq(users.id, user.id));
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { getActiveShareLinks } from "@/modules/_core/share";
|
||||||
|
import { getEntityType } from "@/modules/_core/registry";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { ThemePicker } from "@/components/theme-picker";
|
import { ThemePicker } from "@/components/theme-picker";
|
||||||
|
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
|
||||||
|
import { PushOptIn } from "@/components/push-opt-in";
|
||||||
|
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
|
||||||
|
import { revokeShareLinkAction } from "./actions";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
|
const shareLinks = await getActiveShareLinks();
|
||||||
|
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
||||||
|
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-2xl py-8 space-y-6">
|
<div className="container max-w-2xl py-8 space-y-6">
|
||||||
@@ -23,6 +33,77 @@ export default async function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lists</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CompletionDelaySetting initialHours={user.completionVisibilityHours} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Push Notifications</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PushOptIn vapidKey={vapidKey} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notification Channels</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<NotifyChannelToggles
|
||||||
|
push={user.notifPush}
|
||||||
|
inapp={user.notifInApp}
|
||||||
|
ntfy={user.notifNtfy}
|
||||||
|
ntfyConfigured={ntfyConfigured}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Share Links</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{shareLinks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No active share links.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{shareLinks.map((link) => {
|
||||||
|
const registration = getEntityType(link.entityType);
|
||||||
|
const label = registration?.label.singular ?? link.entityType;
|
||||||
|
return (
|
||||||
|
<li key={link.id} className="flex items-center justify-between gap-4 text-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
{link.capabilities.write ? "read + write" : "read-only"}
|
||||||
|
</span>
|
||||||
|
{link.expiresAt && (
|
||||||
|
<span className="text-muted-foreground ml-2">
|
||||||
|
· expires {link.expiresAt.toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form action={revokeShareLinkAction}>
|
||||||
|
<input type="hidden" name="id" value={link.id} />
|
||||||
|
<Button variant="destructive" size="sm" type="submit">
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings/household"
|
href="/settings/household"
|
||||||
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { pushSubscriptions } from "@/modules/_core/schema";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { sendPush } from "@/modules/_core/push";
|
||||||
|
|
||||||
|
type PushSubscriptionJSON = {
|
||||||
|
endpoint: string;
|
||||||
|
keys: { p256dh: string; auth: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function subscribeToPush(sub: PushSubscriptionJSON, userAgent: string): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.insert(pushSubscriptions)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
p256dh: sub.keys.p256dh,
|
||||||
|
auth: sub.keys.auth,
|
||||||
|
userAgent: userAgent.slice(0, 512),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: pushSubscriptions.endpoint,
|
||||||
|
set: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeFromPush(endpoint: string): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await db
|
||||||
|
.delete(pushSubscriptions)
|
||||||
|
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestNotification(): Promise<void> {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
await sendPush(user.id, {
|
||||||
|
title: "famapp test",
|
||||||
|
body: "Push notifications are working!",
|
||||||
|
url: "/settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
+72
-14
@@ -1,24 +1,82 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { getRegistry } from "@/modules/_core/registry";
|
import { getRegistry } from "@/modules/_core/registry";
|
||||||
|
import type { DashboardMeta } from "@/app/d/actions";
|
||||||
|
import { notifications } from "@/modules/_core/schema";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { DashboardSwitcher } from "./dashboard-switcher";
|
||||||
|
import { DashboardTab } from "./dashboard-tab";
|
||||||
|
import { NotificationBell } from "./notification-bell";
|
||||||
|
|
||||||
export function AppNav() {
|
async function getNotifications(userId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(notifications)
|
||||||
|
.where(eq(notifications.userId, userId))
|
||||||
|
.orderBy(desc(notifications.createdAt))
|
||||||
|
.limit(20);
|
||||||
|
const unread = rows.filter((n) => !n.readAt).length;
|
||||||
|
return { rows, unread };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function AppNav({ dashboards = [] }: { dashboards?: DashboardMeta[] }) {
|
||||||
const { modules } = getRegistry();
|
const { modules } = getRegistry();
|
||||||
const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
const { rows: notifRows, unread } = userId
|
||||||
|
? await getNotifications(userId)
|
||||||
|
: { rows: [], unread: 0 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-b px-4 py-3 flex items-center gap-6">
|
<header className="border-b">
|
||||||
<Link href="/" className="font-semibold text-sm">
|
<nav className="px-4 py-3 flex items-center gap-6">
|
||||||
famapp
|
<Link href="/" className="font-semibold text-sm shrink-0">
|
||||||
</Link>
|
famapp
|
||||||
{navItems.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
{navItems.map((item) => (
|
||||||
</nav>
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{userId && (
|
||||||
|
<NotificationBell
|
||||||
|
initialUnread={unread}
|
||||||
|
initialItems={notifRows.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
url: n.url ?? null,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{dashboards.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 border-t px-4 overflow-x-auto">
|
||||||
|
{dashboards.map((d) => (
|
||||||
|
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
|
||||||
|
))}
|
||||||
|
<DashboardSwitcher dashboards={dashboards} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useQuickAdd } from "./quick-add-provider";
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const { paletteOpen, closePalette, actions } = useQuickAdd();
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paletteOpen) {
|
||||||
|
// Let the DOM mount, then focus
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
}, [paletteOpen]);
|
||||||
|
|
||||||
|
if (!paletteOpen) return null;
|
||||||
|
|
||||||
|
function handleSelect(url: string) {
|
||||||
|
closePalette();
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/50" onClick={closePalette} aria-hidden="true" />
|
||||||
|
|
||||||
|
{/* Palette modal */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Command palette"
|
||||||
|
className="fixed left-1/2 top-1/4 z-50 w-full max-w-md -translate-x-1/2 rounded-xl bg-background shadow-2xl ring-1 ring-border"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
className="flex flex-col overflow-hidden rounded-xl"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
closePalette();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<Command.Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Quick add…"
|
||||||
|
className="w-full bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Command.List className="max-h-80 overflow-y-auto p-2">
|
||||||
|
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No actions found.
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
{groupByModule(actions).map(([moduleId, group]) => (
|
||||||
|
<Command.Group key={moduleId} heading={group.name}>
|
||||||
|
{group.items.map((action) => (
|
||||||
|
<Command.Item
|
||||||
|
key={action.id}
|
||||||
|
value={`${action.moduleName} ${action.label}`}
|
||||||
|
onSelect={() => handleSelect(action.url)}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{iconEmoji(action.icon)}</span>
|
||||||
|
{action.label}
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
))}
|
||||||
|
</Command.List>
|
||||||
|
|
||||||
|
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<kbd className="rounded border px-1 py-0.5 font-mono">↑↓</kbd> navigate ·{" "}
|
||||||
|
<kbd className="rounded border px-1 py-0.5 font-mono">↵</kbd> select ·{" "}
|
||||||
|
<kbd className="rounded border px-1 py-0.5 font-mono">esc</kbd> close
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupEntry = [
|
||||||
|
string,
|
||||||
|
{ name: string; items: import("@/modules/_core").SerializedQuickAddItem[] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function groupByModule(actions: import("@/modules/_core").SerializedQuickAddItem[]): GroupEntry[] {
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; items: import("@/modules/_core").SerializedQuickAddItem[] }
|
||||||
|
>();
|
||||||
|
for (const action of actions) {
|
||||||
|
if (!map.has(action.moduleId)) {
|
||||||
|
map.set(action.moduleId, { name: action.moduleName, items: [] });
|
||||||
|
}
|
||||||
|
map.get(action.moduleId)!.items.push(action);
|
||||||
|
}
|
||||||
|
return [...map.entries()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconEmoji(icon?: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"calendar-plus": "📅",
|
||||||
|
"calendar-days": "🗓️",
|
||||||
|
"shopping-cart": "🛒",
|
||||||
|
"list-checks": "✅",
|
||||||
|
"list-plus": "📋",
|
||||||
|
"file-plus": "📝",
|
||||||
|
};
|
||||||
|
return icon ? (map[icon] ?? "➕") : "➕";
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { setCompletionVisibilityHours } from "@/app/settings/actions";
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ label: "1 hour", value: 1 },
|
||||||
|
{ label: "4 hours", value: 4 },
|
||||||
|
{ label: "8 hours", value: 8 },
|
||||||
|
{ label: "24 hours (default)", value: 24 },
|
||||||
|
{ label: "48 hours", value: 48 },
|
||||||
|
{ label: "7 days", value: 168 },
|
||||||
|
{ label: "Never hide", value: 8760 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CompletionDelaySetting({ initialHours }: { initialHours: number }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const hours = Number(e.target.value);
|
||||||
|
startTransition(() => setCompletionVisibilityHours(hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Show completed items for</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
How long checked-off items remain visible on list cards and the dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
defaultValue={initialHours}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import "react-grid-layout/css/styles.css";
|
||||||
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { GridLayout } from "react-grid-layout";
|
||||||
|
import type { Layout } from "react-grid-layout";
|
||||||
|
import { GripVertical, Settings2, Trash2, RotateCcw, Plus } from "lucide-react";
|
||||||
|
import type { DashboardLayout, WidgetPlacement } from "@/lib/dashboard";
|
||||||
|
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
||||||
|
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { WidgetPicker } from "./widget-picker";
|
||||||
|
|
||||||
|
function placementKey(placement: WidgetPlacement, index: number) {
|
||||||
|
return `${placement.widgetId}::${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardEditor({
|
||||||
|
dashboard,
|
||||||
|
layout: initialLayout,
|
||||||
|
widgetMetas,
|
||||||
|
}: {
|
||||||
|
dashboard: { id: string; name: string; slug: string };
|
||||||
|
layout: DashboardLayout;
|
||||||
|
widgetMetas: SerializedWidgetMeta[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [placements, setPlacements] = useState<WidgetPlacement[]>(initialLayout.widgets);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [configuringIndex, setConfiguringIndex] = useState<number | null>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function measure() {
|
||||||
|
const el = document.getElementById("dashboard-editor-grid");
|
||||||
|
if (el) setContainerWidth(el.offsetWidth);
|
||||||
|
}
|
||||||
|
measure();
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => window.removeEventListener("resize", measure);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") handleCancel();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dashboard.slug]);
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.push(`/d/${dashboard.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await saveDashboardLayout(dashboard.id, { version: 1, widgets: placements });
|
||||||
|
router.push(`/d/${dashboard.slug}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await resetDashboardLayout(dashboard.id);
|
||||||
|
router.push(`/d/${dashboard.slug}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLayoutChange(items: Layout) {
|
||||||
|
setPlacements((current) =>
|
||||||
|
current.map((p, i) => {
|
||||||
|
const key = placementKey(p, i);
|
||||||
|
const item = items.find((it) => it.i === key);
|
||||||
|
if (!item) return p;
|
||||||
|
return { ...p, x: item.x, y: item.y, w: item.w, h: item.h };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setIsDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWidget(index: number) {
|
||||||
|
setPlacements((current) => current.filter((_, i) => i !== index));
|
||||||
|
setIsDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWidget(widgetId: string, config: unknown) {
|
||||||
|
const meta = widgetMetas.find((m) => m.id === widgetId);
|
||||||
|
if (!meta) return;
|
||||||
|
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
|
||||||
|
setPlacements((current) => [
|
||||||
|
...current,
|
||||||
|
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h },
|
||||||
|
]);
|
||||||
|
setIsDirty(true);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfig(index: number, config: unknown) {
|
||||||
|
setPlacements((current) => current.map((p, i) => (i === index ? { ...p, config } : p)));
|
||||||
|
setIsDirty(true);
|
||||||
|
setConfiguringIndex(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridItems: Layout = placements.map((p, i) => ({
|
||||||
|
i: placementKey(p, i),
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
w: p.w,
|
||||||
|
h: p.h,
|
||||||
|
minW: widgetMetas.find((m) => m.id === p.widgetId)?.minSize?.w ?? 2,
|
||||||
|
minH: widgetMetas.find((m) => m.id === p.widgetId)?.minSize?.h ?? 1,
|
||||||
|
maxW: widgetMetas.find((m) => m.id === p.widgetId)?.maxSize?.w ?? 12,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
|
||||||
|
<RotateCcw className="size-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Plus className="size-4 mr-1" />
|
||||||
|
Add widget
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={!isDirty || isPending}>
|
||||||
|
{isPending ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Drag to reorder · resize from the bottom-right corner · ESC to cancel
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="dashboard-editor-grid">
|
||||||
|
<GridLayout
|
||||||
|
layout={gridItems}
|
||||||
|
width={containerWidth}
|
||||||
|
gridConfig={{
|
||||||
|
cols: 12,
|
||||||
|
rowHeight: 60,
|
||||||
|
margin: [16, 16] as [number, number],
|
||||||
|
containerPadding: [0, 0] as [number, number],
|
||||||
|
}}
|
||||||
|
dragConfig={{ handle: ".drag-handle" }}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
>
|
||||||
|
{placements.map((placement, i) => {
|
||||||
|
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={placementKey(placement, i)}
|
||||||
|
className="rounded-lg border bg-card text-card-foreground flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="drag-handle flex items-center gap-2 px-3 py-2 bg-muted/40 cursor-grab active:cursor-grabbing select-none border-b">
|
||||||
|
<GripVertical className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm font-medium truncate flex-1">
|
||||||
|
{meta?.title ?? placement.widgetId}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfiguringIndex(i)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
|
||||||
|
aria-label="Configure widget"
|
||||||
|
>
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeWidget(i)}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors p-0.5"
|
||||||
|
aria-label="Remove widget"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{meta?.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{placements.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 gap-4 text-center">
|
||||||
|
<p className="text-muted-foreground">No widgets yet.</p>
|
||||||
|
<Button onClick={() => setPickerOpen(true)}>
|
||||||
|
<Plus className="size-4 mr-1" />
|
||||||
|
Add widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pickerOpen && (
|
||||||
|
<WidgetPicker
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
widgetMetas={widgetMetas}
|
||||||
|
onAdd={addWidget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{configuringIndex !== null && (
|
||||||
|
<WidgetPicker
|
||||||
|
onClose={() => setConfiguringIndex(null)}
|
||||||
|
widgetMetas={widgetMetas}
|
||||||
|
onAdd={(_, config) => updateConfig(configuringIndex, config)}
|
||||||
|
initialWidgetId={placements[configuringIndex]?.widgetId}
|
||||||
|
initialConfig={placements[configuringIndex]?.config}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { MoreHorizontal, Plus, Star, Trash2, PenLine } from "lucide-react";
|
||||||
|
import {
|
||||||
|
createDashboard,
|
||||||
|
deleteDashboard,
|
||||||
|
renameDashboard,
|
||||||
|
setDefaultDashboard,
|
||||||
|
} from "@/app/d/actions";
|
||||||
|
import type { DashboardMeta } from "@/app/d/actions";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export function DashboardSwitcher({ dashboards }: { dashboards: DashboardMeta[] }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [creatingNew, setCreatingNew] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
|
||||||
|
function activeSlug() {
|
||||||
|
const m = pathname.match(/^\/d\/([^/]+)/);
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const created = await createDashboard(newName.trim());
|
||||||
|
setCreatingNew(false);
|
||||||
|
setNewName("");
|
||||||
|
router.push(`/d/${created.slug}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Active tab highlights — overlay on top of the server-rendered links */}
|
||||||
|
{dashboards.map((d) => {
|
||||||
|
const isActive = activeSlug() === d.slug;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={d.id}
|
||||||
|
aria-hidden
|
||||||
|
className={`absolute pointer-events-none border-b-2 transition-colors ${
|
||||||
|
isActive ? "border-primary" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{creatingNew ? (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreate();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 ml-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Dashboard name"
|
||||||
|
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && setCreatingNew(false)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground px-1"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreatingNew(false)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground px-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreatingNew(true)}
|
||||||
|
className="shrink-0 flex items-center gap-1 px-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="New dashboard"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dashboards.map((d) => {
|
||||||
|
const isActive = activeSlug() === d.slug;
|
||||||
|
if (!isActive) return null;
|
||||||
|
return <DashboardKebab key={d.id} dashboard={d} canDelete={dashboards.length > 1} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardKebab({
|
||||||
|
dashboard,
|
||||||
|
canDelete,
|
||||||
|
}: {
|
||||||
|
dashboard: DashboardMeta;
|
||||||
|
canDelete: boolean;
|
||||||
|
}) {
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [newName, setNewName] = useState(dashboard.name);
|
||||||
|
|
||||||
|
function handleRename() {
|
||||||
|
if (!newName.trim() || newName === dashboard.name) {
|
||||||
|
setRenaming(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
await renameDashboard(dashboard.id, newName.trim());
|
||||||
|
setRenaming(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renaming) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRename();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 ml-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && setRenaming(false)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="text-xs text-muted-foreground hover:text-foreground px-1">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRenaming(false)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground px-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="shrink-0 flex items-center px-1 py-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Dashboard options"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
||||||
|
<PenLine className="size-4 mr-2" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!dashboard.isDefault && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
|
||||||
|
>
|
||||||
|
<Star className="size-4 mr-2" />
|
||||||
|
Set as default
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export function DashboardTab({ slug, name }: { slug: string; name: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isActive = pathname === `/d/${slug}` || pathname.startsWith(`/d/${slug}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/d/${slug}`}
|
||||||
|
className={`shrink-0 px-3 py-2 text-sm transition-colors border-b-2 ${
|
||||||
|
isActive
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function EditDashboardButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`${pathname}?edit=1`)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Edit dashboard
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { X, Share, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
const DISMISSED_KEY = "pwa-install-dismissed";
|
||||||
|
|
||||||
|
type Prompt = "android" | "ios";
|
||||||
|
|
||||||
|
function isIos() {
|
||||||
|
return (
|
||||||
|
/iphone|ipad|ipod/i.test(navigator.userAgent) ||
|
||||||
|
// iPad on iOS 13+ reports as Mac
|
||||||
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInStandaloneMode() {
|
||||||
|
return (
|
||||||
|
"standalone" in window.navigator &&
|
||||||
|
(window.navigator as { standalone?: boolean }).standalone === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallPrompt() {
|
||||||
|
const [prompt, setPrompt] = useState<Prompt | null>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [deferredEvent, setDeferredEvent] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem(DISMISSED_KEY)) return;
|
||||||
|
|
||||||
|
// Android / Chrome desktop — beforeinstallprompt fires
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeferredEvent(e);
|
||||||
|
setPrompt("android");
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeinstallprompt", handler);
|
||||||
|
|
||||||
|
// iOS — no beforeinstallprompt; defer setState out of the synchronous
|
||||||
|
// effect body to satisfy react-hooks/set-state-in-effect.
|
||||||
|
let iosTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
if (isIos() && !isInStandaloneMode()) {
|
||||||
|
iosTimer = setTimeout(() => setPrompt("ios"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeinstallprompt", handler);
|
||||||
|
clearTimeout(iosTimer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, "1");
|
||||||
|
setPrompt(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install() {
|
||||||
|
if (!deferredEvent) return;
|
||||||
|
deferredEvent.prompt();
|
||||||
|
const { outcome } = await deferredEvent.userChoice;
|
||||||
|
if (outcome === "accepted" || outcome === "dismissed") {
|
||||||
|
dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="banner"
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 flex items-start gap-3 border-t bg-background px-4 py-3 shadow-lg sm:bottom-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 sm:rounded-xl sm:border sm:px-5 sm:py-4 sm:shadow-xl"
|
||||||
|
>
|
||||||
|
{/* App icon */}
|
||||||
|
<div className="mt-0.5 h-10 w-10 shrink-0 overflow-hidden rounded-xl bg-[#4F46E5]">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/icon-192.png" alt="" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
<p className="font-semibold leading-snug">Add famapp to your home screen</p>
|
||||||
|
|
||||||
|
{prompt === "android" && (
|
||||||
|
<>
|
||||||
|
<p className="mt-0.5 text-muted-foreground">
|
||||||
|
Install for a faster, app-like experience.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={install}
|
||||||
|
className="mt-2 rounded-md bg-[#4F46E5] px-3 py-1.5 text-xs font-semibold text-white"
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prompt === "ios" && (
|
||||||
|
<p className="mt-0.5 text-muted-foreground">
|
||||||
|
Tap <Share className="inline-block h-4 w-4 align-text-bottom" aria-label="Share" /> then{" "}
|
||||||
|
<strong className="font-medium">
|
||||||
|
<Plus className="inline-block h-3.5 w-3.5 align-text-bottom" />
|
||||||
|
Add to Home Screen
|
||||||
|
</strong>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="mt-0.5 shrink-0 rounded p-1 text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { markNotificationRead, markAllNotificationsRead } from "@/app/settings/notify-actions";
|
||||||
|
|
||||||
|
type NotifItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
url: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationBell({
|
||||||
|
initialUnread,
|
||||||
|
initialItems,
|
||||||
|
}: {
|
||||||
|
initialUnread: number;
|
||||||
|
initialItems: NotifItem[];
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [unread, setUnread] = useState(initialUnread);
|
||||||
|
const [items, setItems] = useState(initialItems);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function markRead(id: string) {
|
||||||
|
setItems((prev) => prev.map((n) => (n.id === id ? { ...n, readAt: new Date() } : n)));
|
||||||
|
setUnread((u) => Math.max(0, u - 1));
|
||||||
|
startTransition(() => markNotificationRead(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAll() {
|
||||||
|
setItems((prev) => prev.map((n) => ({ ...n, readAt: new Date() })));
|
||||||
|
setUnread(0);
|
||||||
|
startTransition(() => markAllNotificationsRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={panelRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Notifications${unread > 0 ? ` (${unread} unread)` : ""}`}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="relative text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Bell className="size-4" />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-8 z-50 w-80 rounded-lg border bg-background shadow-lg">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<span className="text-sm font-semibold">Notifications</span>
|
||||||
|
{unread > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={markAll}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-80 overflow-y-auto divide-y">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No notifications
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{items.map((n) => {
|
||||||
|
const isUnread = !("readAt" in n && (n as { readAt?: Date }).readAt);
|
||||||
|
return (
|
||||||
|
<li key={n.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-full text-left px-4 py-3 hover:bg-accent transition-colors ${isUnread ? "font-medium" : "opacity-60"}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isUnread) markRead(n.id);
|
||||||
|
if (n.url) window.location.href = n.url;
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm">{n.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{n.body}</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { setNotifChannel } from "@/app/settings/notify-actions";
|
||||||
|
|
||||||
|
type Channel = "push" | "inapp" | "ntfy";
|
||||||
|
|
||||||
|
const LABEL: Record<Channel, string> = {
|
||||||
|
push: "Web push",
|
||||||
|
inapp: "In-app inbox",
|
||||||
|
ntfy: "ntfy",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotifyChannelToggles({
|
||||||
|
push,
|
||||||
|
inapp,
|
||||||
|
ntfy,
|
||||||
|
ntfyConfigured,
|
||||||
|
}: {
|
||||||
|
push: boolean;
|
||||||
|
inapp: boolean;
|
||||||
|
ntfy: boolean;
|
||||||
|
ntfyConfigured: boolean;
|
||||||
|
}) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function toggle(channel: Channel, enabled: boolean) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await setNotifChannel(channel, enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels: { key: Channel; value: boolean; disabled?: boolean }[] = [
|
||||||
|
{ key: "push", value: push },
|
||||||
|
{ key: "inapp", value: inapp },
|
||||||
|
{ key: "ntfy", value: ntfy, disabled: !ntfyConfigured },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{channels.map(({ key, value, disabled }) => (
|
||||||
|
<label key={key} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm">
|
||||||
|
{LABEL[key]}
|
||||||
|
{key === "ntfy" && !ntfyConfigured && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
disabled={isPending || disabled}
|
||||||
|
onChange={(e) => toggle(key, e.target.checked)}
|
||||||
|
className="size-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
subscribeToPush,
|
||||||
|
unsubscribeFromPush,
|
||||||
|
sendTestNotification,
|
||||||
|
} from "@/app/settings/push-actions";
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = atob(base64);
|
||||||
|
const buf = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
||||||
|
const [status, setStatus] = useState<"idle" | "subscribed" | "denied" | "unsupported">("idle");
|
||||||
|
const [endpoint, setEndpoint] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [testSent, setTestSent] = useState(false);
|
||||||
|
|
||||||
|
if (!vapidKey) return null;
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Push notifications not supported in this browser.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribe() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||||
|
});
|
||||||
|
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||||
|
startTransition(async () => {
|
||||||
|
await subscribeToPush(json, navigator.userAgent);
|
||||||
|
setEndpoint(json.endpoint);
|
||||||
|
setStatus("subscribed");
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setStatus("denied");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribe() {
|
||||||
|
if (!endpoint) return;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const sub = await registration.pushManager.getSubscription();
|
||||||
|
if (sub) await sub.unsubscribe();
|
||||||
|
startTransition(async () => {
|
||||||
|
await unsubscribeFromPush(endpoint);
|
||||||
|
setEndpoint(null);
|
||||||
|
setStatus("idle");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTest() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await sendTestNotification();
|
||||||
|
setTestSent(true);
|
||||||
|
setTimeout(() => setTestSent(false), 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "denied") {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Notification permission denied. Enable it in browser settings.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "subscribed") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
Push notifications enabled
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}>
|
||||||
|
{testSent ? "Sent!" : "Send test"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={unsubscribe} disabled={isPending}>
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button size="sm" onClick={subscribe} disabled={isPending}>
|
||||||
|
Enable push notifications
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function PwaRegister() {
|
||||||
|
const [showUpdate, setShowUpdate] = useState(false);
|
||||||
|
const [isOffline, setIsOffline] = useState(false);
|
||||||
|
const [mutationFailed, setMutationFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOffline = () => setIsOffline(true);
|
||||||
|
const handleOnline = () => setIsOffline(false);
|
||||||
|
window.addEventListener("offline", handleOffline);
|
||||||
|
window.addEventListener("online", handleOnline);
|
||||||
|
// Re-sync after listeners are registered in case the online/offline event
|
||||||
|
// fired between page load and this effect running (e.g. DevTools toggle).
|
||||||
|
const syncId = setTimeout(() => setIsOffline(!navigator.onLine), 0);
|
||||||
|
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture whether a SW already controls this page before registering.
|
||||||
|
// If true and the controller later changes, a new build has deployed.
|
||||||
|
const hadController = !!navigator.serviceWorker.controller;
|
||||||
|
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/sw.js", { scope: "/" })
|
||||||
|
.catch((err) => console.warn("[famapp] SW registration failed:", err));
|
||||||
|
|
||||||
|
const handleControllerChange = () => {
|
||||||
|
if (hadController) setShowUpdate(true);
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange);
|
||||||
|
|
||||||
|
const handleMessage = (e: MessageEvent) => {
|
||||||
|
if (e.data?.type === "OFFLINE_MUTATION") {
|
||||||
|
setMutationFailed(true);
|
||||||
|
setTimeout(() => setMutationFailed(false), 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(syncId);
|
||||||
|
window.removeEventListener("offline", handleOffline);
|
||||||
|
window.removeEventListener("online", handleOnline);
|
||||||
|
navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange);
|
||||||
|
navigator.serviceWorker.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOffline && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg bg-amber-50 px-4 py-3 text-amber-900 shadow-lg ring-1 ring-amber-200 dark:bg-amber-950 dark:text-amber-100 dark:ring-amber-800"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">You’re offline — showing cached content</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOffline(false)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="text-sm opacity-60 hover:opacity-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutationFailed && !isOffline && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="fixed top-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg bg-destructive px-4 py-3 text-destructive-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">Changes can’t be saved while offline</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showUpdate && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg bg-primary px-4 py-3 text-primary-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">New version available</span>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="text-sm font-semibold underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUpdate(false)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="text-sm opacity-60 hover:opacity-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuickAdd } from "./quick-add-provider";
|
||||||
|
|
||||||
|
export function QuickAddFab() {
|
||||||
|
const { openSheet } = useQuickAdd();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label="Quick add"
|
||||||
|
onClick={openSheet}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
<span className="text-xl leading-none">+</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import type { SerializedQuickAddItem } from "@/modules/_core";
|
||||||
|
|
||||||
|
type QuickAddState = {
|
||||||
|
sheetOpen: boolean;
|
||||||
|
paletteOpen: boolean;
|
||||||
|
openSheet: () => void;
|
||||||
|
closeSheet: () => void;
|
||||||
|
openPalette: () => void;
|
||||||
|
closePalette: () => void;
|
||||||
|
actions: SerializedQuickAddItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuickAddContext = createContext<QuickAddState | null>(null);
|
||||||
|
|
||||||
|
export function useQuickAdd() {
|
||||||
|
const ctx = useContext(QuickAddContext);
|
||||||
|
if (!ctx) throw new Error("useQuickAdd must be used inside QuickAddProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickAddProvider({
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
actions: SerializedQuickAddItem[];
|
||||||
|
}) {
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
const openSheet = useCallback(() => setSheetOpen(true), []);
|
||||||
|
const closeSheet = useCallback(() => setSheetOpen(false), []);
|
||||||
|
const openPalette = useCallback(() => setPaletteOpen(true), []);
|
||||||
|
const closePalette = useCallback(() => setPaletteOpen(false), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setPaletteOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuickAddContext.Provider
|
||||||
|
value={{ sheetOpen, paletteOpen, openSheet, closeSheet, openPalette, closePalette, actions }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</QuickAddContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useQuickAdd } from "./quick-add-provider";
|
||||||
|
import type { SerializedQuickAddItem } from "@/modules/_core";
|
||||||
|
|
||||||
|
function groupByModule(
|
||||||
|
actions: SerializedQuickAddItem[],
|
||||||
|
): Map<string, { name: string; items: SerializedQuickAddItem[] }> {
|
||||||
|
const map = new Map<string, { name: string; items: SerializedQuickAddItem[] }>();
|
||||||
|
for (const action of actions) {
|
||||||
|
if (!map.has(action.moduleId)) {
|
||||||
|
map.set(action.moduleId, { name: action.moduleName, items: [] });
|
||||||
|
}
|
||||||
|
map.get(action.moduleId)!.items.push(action);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickAddSheet() {
|
||||||
|
const { sheetOpen, closeSheet, actions } = useQuickAdd();
|
||||||
|
const router = useRouter();
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sheetOpen) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") closeSheet();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [sheetOpen, closeSheet]);
|
||||||
|
|
||||||
|
if (!sheetOpen) return null;
|
||||||
|
|
||||||
|
const groups = groupByModule(actions);
|
||||||
|
|
||||||
|
function handleAction(url: string) {
|
||||||
|
closeSheet();
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
ref={backdropRef}
|
||||||
|
className="fixed inset-0 z-40 bg-black/40"
|
||||||
|
onClick={closeSheet}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet panel — bottom on mobile, right-anchored popover on sm+ */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Quick add"
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-background shadow-xl sm:bottom-auto sm:left-auto sm:right-6 sm:top-16 sm:w-72 sm:rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<span className="text-sm font-semibold">Quick add</span>
|
||||||
|
<button
|
||||||
|
onClick={closeSheet}
|
||||||
|
aria-label="Close quick add"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto p-2 sm:max-h-96">
|
||||||
|
{[...groups.entries()].map(([moduleId, group]) => (
|
||||||
|
<div key={moduleId} className="mb-2">
|
||||||
|
<p className="px-2 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.name}
|
||||||
|
</p>
|
||||||
|
{group.items.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => handleAction(action.url)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{iconEmoji(action.icon)}</span>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconEmoji(icon?: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"calendar-plus": "📅",
|
||||||
|
"calendar-days": "🗓️",
|
||||||
|
"shopping-cart": "🛒",
|
||||||
|
"list-checks": "✅",
|
||||||
|
"list-plus": "📋",
|
||||||
|
"file-plus": "📝",
|
||||||
|
};
|
||||||
|
return icon ? (map[icon] ?? "➕") : "➕";
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Link, Check, Copy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { createShareLink } from "@/modules/_core/share";
|
||||||
|
|
||||||
|
export function ShareButton({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
canWrite = false,
|
||||||
|
}: {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
canWrite?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [shareUrl, setShareUrl] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function share() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createShareLink(entityType, entityId, {
|
||||||
|
capabilities: { read: true, write: canWrite },
|
||||||
|
});
|
||||||
|
setShareUrl(result.url);
|
||||||
|
setOpen(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUrl() {
|
||||||
|
if (!shareUrl) return;
|
||||||
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={share} disabled={isPending}>
|
||||||
|
<Link />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share link created</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Anyone with this link can {canWrite ? "view and edit" : "view"} this{" "}
|
||||||
|
{entityType.split(".")[1]}.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input readOnly value={shareUrl ?? ""} className="font-mono text-xs" />
|
||||||
|
<Button variant="outline" size="icon" onClick={copyUrl} aria-label="Copy link">
|
||||||
|
{copied ? <Check className="text-green-600" /> : <Copy />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,11 +25,7 @@ export function ThemePicker({
|
|||||||
initialMode = "system",
|
initialMode = "system",
|
||||||
signedIn = false,
|
signedIn = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { theme, mode, setTheme, setMode } = useTheme(
|
const { theme, mode, setTheme, setMode } = useTheme(initialTheme, initialMode, signedIn);
|
||||||
initialTheme,
|
|
||||||
initialMode,
|
|
||||||
signedIn,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
@@ -37,8 +37,8 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -52,7 +52,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
+15
-26
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
className,
|
className,
|
||||||
@@ -13,11 +13,11 @@ function Card({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -53,20 +53,17 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogOverlay({
|
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: DialogPrimitive.Backdrop.Props) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Backdrop
|
<DialogPrimitive.Backdrop
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogContent({
|
function DialogContent({
|
||||||
@@ -45,7 +42,7 @@ function DialogContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: DialogPrimitive.Popup.Props & {
|
}: DialogPrimitive.Popup.Props & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
@@ -54,7 +51,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -62,32 +59,21 @@ function DialogContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
render={
|
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
size="icon-sm"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Popup>
|
</DialogPrimitive.Popup>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||||
data-slot="dialog-header"
|
);
|
||||||
className={cn("flex flex-col gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({
|
function DialogFooter({
|
||||||
@@ -96,54 +82,46 @@ function DialogFooter({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
|
||||||
Close
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn("font-heading text-base leading-none font-medium", className)}
|
||||||
"font-heading text-base leading-none font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({
|
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: DialogPrimitive.Description.Props) {
|
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -157,4 +135,4 @@ export {
|
|||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
return (
|
return (
|
||||||
@@ -10,11 +10,11 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
|
|||||||
data-slot="label"
|
data-slot="label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
import { Select as SelectPrimitive } from "@base-ui/react/select";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
return (
|
return (
|
||||||
@@ -15,7 +15,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
|||||||
className={cn("scroll-my-1 p-1", className)}
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
@@ -25,7 +25,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
|||||||
className={cn("flex flex-1 text-left", className)}
|
className={cn("flex flex-1 text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
@@ -34,7 +34,7 @@ function SelectTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: SelectPrimitive.Trigger.Props & {
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
@@ -42,18 +42,16 @@ function SelectTrigger({
|
|||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon
|
<SelectPrimitive.Icon
|
||||||
render={
|
render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />}
|
||||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
@@ -83,7 +81,10 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Popup
|
<SelectPrimitive.Popup
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={alignItemWithTrigger}
|
data-align-trigger={alignItemWithTrigger}
|
||||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
className={cn(
|
||||||
|
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
@@ -92,33 +93,26 @@ function SelectContent({
|
|||||||
</SelectPrimitive.Popup>
|
</SelectPrimitive.Popup>
|
||||||
</SelectPrimitive.Positioner>
|
</SelectPrimitive.Positioner>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.GroupLabel.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.GroupLabel
|
<SelectPrimitive.GroupLabel
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.Item.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -133,20 +127,17 @@ function SelectItem({
|
|||||||
<CheckIcon className="pointer-events-none" />
|
<CheckIcon className="pointer-events-none" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: SelectPrimitive.Separator.Props) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
@@ -158,14 +149,13 @@ function SelectScrollUpButton({
|
|||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon />
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollUpArrow>
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
@@ -177,14 +167,13 @@ function SelectScrollDownButton({
|
|||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon />
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollDownArrow>
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -198,4 +187,4 @@ export {
|
|||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { X, ChevronLeft } from "lucide-react";
|
||||||
|
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
||||||
|
import { resolveWidgetConfigOptions } from "@/app/d/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Step = "pick" | "configure";
|
||||||
|
|
||||||
|
export function WidgetPicker({
|
||||||
|
onClose,
|
||||||
|
widgetMetas,
|
||||||
|
onAdd,
|
||||||
|
initialWidgetId,
|
||||||
|
initialConfig,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
widgetMetas: SerializedWidgetMeta[];
|
||||||
|
onAdd: (widgetId: string, config: unknown) => void;
|
||||||
|
initialWidgetId?: string;
|
||||||
|
initialConfig?: unknown;
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<Step>(initialWidgetId ? "configure" : "pick");
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(initialWidgetId ?? null);
|
||||||
|
const [options, setOptions] = useState<unknown>(null);
|
||||||
|
const [config, setConfig] = useState<unknown>(initialConfig ?? null);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function selectWidget(id: string) {
|
||||||
|
const meta = widgetMetas.find((m) => m.id === id);
|
||||||
|
if (!meta) return;
|
||||||
|
setSelectedId(id);
|
||||||
|
setConfig(meta.defaultConfig);
|
||||||
|
setOptions(null);
|
||||||
|
setStep("configure");
|
||||||
|
startTransition(async () => {
|
||||||
|
const opts = await resolveWidgetConfigOptions(id);
|
||||||
|
setOptions(opts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
if (!selectedId) return;
|
||||||
|
onAdd(selectedId, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = widgetMetas.reduce<Record<string, SerializedWidgetMeta[]>>((acc, m) => {
|
||||||
|
const cat = m.category ?? "Other";
|
||||||
|
(acc[cat] ??= []).push(m);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const selected = widgetMetas.find((m) => m.id === selectedId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div className="relative bg-background rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b">
|
||||||
|
{step === "configure" && !initialWidgetId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep("pick")}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2 className="font-semibold flex-1 text-sm">
|
||||||
|
{step === "pick"
|
||||||
|
? "Add widget"
|
||||||
|
: selected
|
||||||
|
? `Configure: ${selected.title}`
|
||||||
|
: "Configure"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{step === "pick" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(grouped)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([cat, items]) => (
|
||||||
|
<div key={cat}>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
|
||||||
|
{cat}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map((meta) => (
|
||||||
|
<button
|
||||||
|
key={meta.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectWidget(meta.id)}
|
||||||
|
className="w-full text-left rounded-md px-3 py-2 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{meta.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "configure" && selected && (
|
||||||
|
<WidgetConfigurator
|
||||||
|
meta={selected}
|
||||||
|
config={config}
|
||||||
|
options={options}
|
||||||
|
onChange={setConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{step === "configure" && (
|
||||||
|
<div className="flex justify-end gap-2 px-4 py-3 border-t">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleAdd}>
|
||||||
|
{initialWidgetId ? "Apply" : "Add widget"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Configurator ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FieldOption = { id: string; name: string };
|
||||||
|
|
||||||
|
function WidgetConfigurator({
|
||||||
|
meta,
|
||||||
|
config,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
meta: SerializedWidgetMeta;
|
||||||
|
config: unknown;
|
||||||
|
options: unknown;
|
||||||
|
onChange: (c: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const cfg = (config ?? meta.defaultConfig) as Record<string, unknown>;
|
||||||
|
const opts = options as Record<string, FieldOption[]> | null | undefined;
|
||||||
|
|
||||||
|
function set(key: string, value: unknown) {
|
||||||
|
onChange({ ...cfg, [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(cfg);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No options available.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{entries.map(([key, value]) => {
|
||||||
|
// "all" | string[] — multi-select with All toggle
|
||||||
|
if (
|
||||||
|
value === "all" ||
|
||||||
|
(Array.isArray(value) && (key.endsWith("Ids") || key.endsWith("ids")))
|
||||||
|
) {
|
||||||
|
const optKey = key.replace(/Ids?$/i, "s");
|
||||||
|
const items: FieldOption[] = (opts?.[optKey] as FieldOption[] | undefined) ?? [];
|
||||||
|
const isAll = value === "all";
|
||||||
|
const selected = isAll ? [] : (value as string[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="block text-sm font-medium mb-1 capitalize">
|
||||||
|
{key.replace(/([A-Z])/g, " $1").toLowerCase()}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAll}
|
||||||
|
onChange={(e) => set(key, e.target.checked ? "all" : [])}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
All
|
||||||
|
</label>
|
||||||
|
{!isAll && (
|
||||||
|
<div className="space-y-1 max-h-40 overflow-y-auto border rounded p-2">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">None available</p>
|
||||||
|
)}
|
||||||
|
{items.map((item) => (
|
||||||
|
<label key={item.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(item.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked
|
||||||
|
? [...selected, item.id]
|
||||||
|
: selected.filter((id) => id !== item.id);
|
||||||
|
set(key, next);
|
||||||
|
}}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
{item.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolean
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return (
|
||||||
|
<label key={key} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={(e) => set(key, e.target.checked)}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="capitalize">{key.replace(/([A-Z])/g, " $1").toLowerCase()}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// number
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="block text-sm font-medium mb-1 capitalize">
|
||||||
|
{key.replace(/([A-Z])/g, " $1").toLowerCase()}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => set(key, Number(e.target.value))}
|
||||||
|
className="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// string (enum / plain)
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// Detect enum-like: same key appears in options as an array of strings
|
||||||
|
const enumOpts = opts?.[key] as string[] | undefined;
|
||||||
|
if (Array.isArray(enumOpts)) {
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="block text-sm font-medium mb-1 capitalize">
|
||||||
|
{key.replace(/([A-Z])/g, " $1").toLowerCase()}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => set(key, e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{enumOpts.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardcoded enum fallback for known fields
|
||||||
|
const knownEnums: Record<string, string[]> = {
|
||||||
|
filter: ["pinned", "all"],
|
||||||
|
};
|
||||||
|
const known = knownEnums[key];
|
||||||
|
if (known) {
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="block text-sm font-medium mb-1 capitalize">
|
||||||
|
{key.replace(/([A-Z])/g, " $1").toLowerCase()}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => set(key, e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{known.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,8 +9,7 @@ function applyTheme(theme: ThemeId, mode: ThemeMode) {
|
|||||||
html.setAttribute("data-theme", theme);
|
html.setAttribute("data-theme", theme);
|
||||||
const dark =
|
const dark =
|
||||||
mode === "dark" ||
|
mode === "dark" ||
|
||||||
(mode === "system" &&
|
(mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
html.classList.toggle("dark", dark);
|
html.classList.toggle("dark", dark);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("theme", theme);
|
localStorage.setItem("theme", theme);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
const { startReminderWorker } = await import("@/modules/_core/reminders");
|
||||||
|
startReminderWorker();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,10 @@ import NextAuth, { type DefaultSession } from "next-auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
households,
|
|
||||||
householdMembers,
|
|
||||||
sessions,
|
sessions,
|
||||||
users,
|
users,
|
||||||
verificationTokens,
|
verificationTokens,
|
||||||
} from "@/modules/_core/schema";
|
} from "@/modules/_core/schema";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { ensureDefaultCalendarsForMembership } from "@/modules/calendar/server/defaults";
|
|
||||||
import { ensureDefaultListsForHousehold } from "@/modules/lists/server/defaults";
|
|
||||||
|
|
||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -41,35 +36,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
signIn: "/login",
|
signIn: "/login",
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async signIn({ user }) {
|
|
||||||
if (!user.id) return true;
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(householdMembers)
|
|
||||||
.where(eq(householdMembers.userId, user.id))
|
|
||||||
.limit(1);
|
|
||||||
if (existing.length === 0) {
|
|
||||||
const [household] = await db.select().from(households).limit(1);
|
|
||||||
if (household) {
|
|
||||||
const [anyMember] = await db
|
|
||||||
.select()
|
|
||||||
.from(householdMembers)
|
|
||||||
.where(eq(householdMembers.householdId, household.id))
|
|
||||||
.limit(1);
|
|
||||||
const role = anyMember ? "member" : "owner";
|
|
||||||
await db
|
|
||||||
.insert(householdMembers)
|
|
||||||
.values({ householdId: household.id, userId: user.id, role })
|
|
||||||
.onConflictDoNothing();
|
|
||||||
await ensureDefaultCalendarsForMembership({
|
|
||||||
householdId: household.id,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
await ensureDefaultListsForHousehold(household.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
session({ session, user }) {
|
session({ session, user }) {
|
||||||
session.user.id = user.id;
|
session.user.id = user.id;
|
||||||
return session;
|
return session;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { getRegistry } from "@/modules/_core";
|
||||||
|
|
||||||
|
export type WidgetPlacement = {
|
||||||
|
widgetId: string;
|
||||||
|
config: unknown;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardLayout = {
|
||||||
|
version: 1;
|
||||||
|
widgets: WidgetPlacement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const placementSchema = z.object({
|
||||||
|
widgetId: z.string(),
|
||||||
|
config: z.unknown(),
|
||||||
|
x: z.number().int().min(0),
|
||||||
|
y: z.number().int().min(0),
|
||||||
|
w: z.number().int().min(1).max(12),
|
||||||
|
h: z.number().int().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutSchema = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
widgets: z.array(placementSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function parseDashboardLayout(raw: unknown): DashboardLayout | null {
|
||||||
|
const result = layoutSchema.safeParse(raw);
|
||||||
|
if (!result.success) return null;
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDefaultLayout(): DashboardLayout {
|
||||||
|
const { widgets } = getRegistry();
|
||||||
|
const sorted = [...widgets].sort((a, b) => a.defaultPriority - b.defaultPriority);
|
||||||
|
|
||||||
|
const placements: WidgetPlacement[] = [];
|
||||||
|
let curX = 0;
|
||||||
|
let curY = 0;
|
||||||
|
let rowH = 0;
|
||||||
|
|
||||||
|
for (const widget of sorted) {
|
||||||
|
const { w, h } = widget.defaultSize;
|
||||||
|
if (curX + w > 12) {
|
||||||
|
curY += rowH;
|
||||||
|
curX = 0;
|
||||||
|
rowH = 0;
|
||||||
|
}
|
||||||
|
placements.push({
|
||||||
|
widgetId: widget.id,
|
||||||
|
config: widget.defaultConfig,
|
||||||
|
x: curX,
|
||||||
|
y: curY,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
});
|
||||||
|
curX += w;
|
||||||
|
rowH = Math.max(rowH, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: 1, widgets: placements };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user