Compare commits

..
Author SHA1 Message Date
ginnoirandClaude Sonnet 4.6 e130cda6c5 Remove signIn callback — enrollment now happens in getCurrentSession
Release / build-and-push (push) Has been cancelled
Auth.js v5 calls signIn before the new user's row is fully committed, so
inserting into household_members immediately caused a FK violation for any
new user (e.g. cam on first sign-in). Enrollment is now handled lazily in
getCurrentSession's auto-enroll fallback, which runs after the session is
established and the user row is guaranteed to exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:23:34 -05:00
ginnoirandClaude Sonnet 4.6 2129f9718f Fix revalidatePath during render in getDefaultDashboardSlug
On a new user's first load, getDefaultDashboardSlug fell through to
createDashboard("Home"), which calls revalidatePath("/"). That is illegal
in a render context (page component → server action → revalidatePath).

Inline the DB insert directly in the fallback and skip revalidatePath —
the redirect that immediately follows is a fresh request, so no cache
invalidation is needed here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:58:32 -05:00
ginnoirandClaude Sonnet 4.6 484f747f66 Auto-enroll user into household if membership missing on session load
If a user has a valid session but no household_members row (e.g. they
first signed in before the household was seeded), getCurrentSession now
auto-enrolls them instead of throwing. Assigns owner if first member,
member otherwise, and creates their default calendars and lists.

Removes the permanently-stuck state that occurred on first deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:12:26 -05:00
ginnoirandClaude Sonnet 4.6 c66bf68373 Seed household on container startup via seed.mjs
Without a household row the signIn callback has nothing to attach the
first user to, causing "No household membership" on first load. Add
scripts/seed.mjs (plain ESM, only needs postgres which is already in the
image) and run it from the entrypoint after migrations. Idempotent — skips
if a household already exists. Default calendars/lists are created by the
signIn callback when the first user authenticates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:49:20 -05:00
ginnoirandClaude Sonnet 4.6 1e076bd2f3 Fix migrations: commit drizzle/meta/ so journal is present in Docker build
drizzle/meta/ was gitignored, so GitHub Actions had no _journal.json to
copy into the image. The migrator requires this file to know which SQL
files to apply. Unblock by committing the meta directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:50:07 -05:00
ginnoirandClaude Sonnet 4.6 64b8180d9a Fix migrate.mjs: explicitly copy full drizzle-orm and postgres into image
The standalone tracer only includes subpaths the Next.js app imports.
drizzle-orm/postgres-js/migrator is only used by migrate.mjs (not app
code) so it is excluded from the trace. Copy the full packages from the
builder stage to ensure all subpaths are available at startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:22:18 -05:00
ginnoirandClaude Sonnet 4.6 39fcd54f0c Fix migrate.mjs: externalize drizzle-orm and postgres from Next.js bundle
Next.js bundles all non-externalized server packages into server chunks.
drizzle-orm and postgres were bundled, so they were absent from the
standalone node_modules/ and unavailable to migrate.mjs at startup.
Adding them to serverExternalPackages keeps them as separate modules and
tells the standalone tracer to include them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:15:00 -05:00
ginnoirandClaude Sonnet 4.6 524efac05f Fix Docker build: include drizzle/ migrations in build context
drizzle/ was excluded in .dockerignore but the Dockerfile copies it into
the runner stage (needed by migrate.mjs at container startup). Remove the
exclusion so migration SQL files are present in the builder stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:50:48 -05:00
ginnoirandClaude Sonnet 4.6 bb3a770cf3 Fix Docker build: move entrypoint out of excluded deploy/ dir
.dockerignore excludes deploy/ entirely. The entrypoint script lived
there, so the COPY in Stage 3 failed with "not found". Move it to the
project root (alongside Dockerfile) where the build context includes it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:46:36 -05:00
ginnoirandClaude Sonnet 4.6 01945fbd0a Gitignore generated public/sw.js
next.config.ts regenerates public/sw.js on every build with a new
timestamp; tracking it produces noise on every build. Add to .gitignore
and untrack — it is always regenerated by `pnpm dev` / `pnpm build`
and by the Docker build stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:34:40 -05:00
ginnoirandClaude Sonnet 4.6 1185f5ab50 Production-ready wiring: VAPID prop fix, port mappings, AUTH_URL
- PushOptIn now accepts vapidKey as a prop from its server-component
  parent (settings page reads VAPID_PUBLIC_KEY at runtime) — eliminates
  the NEXT_PUBLIC_* build-time dependency so pre-built GHCR images work
  without a build arg.
- deploy/compose.yaml: famapp exposes 3010:3000, authentik-server exposes
  9200:9000 so the existing Caddy stack can proxy by IP, matching every
  other service in the homelab.  NEXT_PUBLIC_APP_URL replaced by AUTH_URL
  (correct next-auth v5 var).
- deploy/Caddyfile.snippet: updated to 192.168.1.69:3010 / :9200.
- .env.production.example: AUTH_URL, ntfy pre-wired to ntfy.ginnoir.com,
  VAPID_SUBJECT prefilled with real email.
- typecheck and pnpm build both pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 18:32:08 -05:00
ginnoir c73338e256 Code-side
src/lib/dev-login-config.ts — startup assertion: throws if NODE_ENV=production + ENABLE_DEV_LOGIN=true, scoped to runtime (skipped during next build).
Container

scripts/migrate.mjs — runs Drizzle migrations against DATABASE_URL.
deploy/docker-entrypoint.sh — runs migrations then exec node server.js. Skip with RUN_MIGRATIONS=false.
Dockerfile — copies drizzle/, scripts/migrate.mjs, entrypoint into runner stage; ENTRYPOINT now points at the script.
Compose

deploy/compose.yaml — famapp now image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest} (build still works locally as fallback). Authentik pinned via AUTHENTIK_IMAGE_TAG (default 2024.12.3). New RUN_MIGRATIONS env passed through.
.env.production.example — documents FAMAPP_IMAGE, AUTHENTIK_IMAGE_TAG, RUN_MIGRATIONS.
CI/CD

.github/workflows/ci.yml — push/PR: typecheck + lint + format:check + build.
.github/workflows/release.yml — v* tag: build + push ghcr.io/ginnoir/famapp:vX.Y.Z, :X.Y, :latest to GHCR.
Docs

deploy/README.md — full deploy/rollback/release runbook.
CHANGELOG.md — release log seeded with an Unreleased entry.
docs/tasks/09-pre-deploy-checklist.md — task 09 reframed from one-shot removal to a recurring pre-deploy checklist.
STATUS.md — updated.
Verified: pnpm typecheck, pnpm format, pnpm build, and docker compose config all clean.
2026-05-06 17:37:37 -05:00
ginnoirandClaude Sonnet 4.6 285a460eb8 Implement tasks 60, 61, 62: backups, rate limiting, structured logging
Task 60 — Postgres backups:
- deploy/backups/: backup.sh (pg_dump -Fc nightly), retain.sh (14/8/6 tiers),
  restore.sh, entrypoint.sh, crontab
- famapp-backup Alpine service + backups volume added to deploy/compose.yaml
- Restore procedure in deploy/backups/README.md

Task 61 — Rate limiting on share links:
- src/lib/rate-limit.ts: Edge-compatible sliding-window counter (50/min, LRU eviction)
  with consume(), isRateLimited(), recordFailure() exports
- middleware.ts: enforces 429 with Retry-After: 60 for /s/[token] (IP + token prefix)
- /s/[token]/page.tsx: tracks only failed resolveShareToken calls via recordFailure()

Task 62 — Structured logging:
- pino + pino-pretty installed; serverExternalPackages added to next.config.ts
- src/lib/logger.ts: JSON in production, pretty in dev, level from LOG_LEVEL env
- middleware.ts: structured JSON request log (method, path, status, ms, authenticated)
- _core/push.ts, notify.ts, reminders.ts: console.error/log → logger.error/info

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:23:29 -05:00
ginnoirandClaude Sonnet 4.6 b6abe052c9 Implement tasks 40, 41, 42: web push, notification bus, reminders engine
Task 40 — Web Push (VAPID):
- Add web-push package + @types/web-push
- pnpm vapid:generate script prints VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, NEXT_PUBLIC_VAPID_PUBLIC_KEY
- push_subscriptions schema + migration 0013
- _core/push.ts: sendPush() iterates subscriptions, prunes 404/410 stale entries
- SW push/notificationclick event handlers added to generated sw.js template
- PushOptIn client component on /settings (opt-in, disable, send test)

Task 42 — Notification bus + ntfy adapter:
- notifications table + notif_push/notif_inapp/notif_ntfy user columns (migration 0013)
- _core/notify.ts: notify() fans out to push, in-app DB, and optional ntfy POST
- NotificationBell server component in AppNav: unread badge, dropdown inbox, mark-read
- NotifyChannelToggles client component in /settings

Task 41 — Reminders engine:
- fired_at + created_by added to reminders; default channel changed to 'auto'
- _core/reminders.ts: scheduleReminder (upsert), cancelReminder, listReminders, tickReminders
- tickReminders uses pg_try_advisory_xact_lock for horizontal-scale safety
- src/instrumentation.ts starts reminder worker (30s tick) on Node.js boot
- Notes actions use scheduleReminder/cancelReminder instead of raw SQL
- Calendar createEvent: optional remindMinutesBefore, deleteEvent: cancelReminder
- Calendar-shell: "Remind me 30 min before" checkbox on new event form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:55:02 -05:00
ginnoirandClaude Sonnet 4.6 ec20f1bc87 Fix offline banner stuck after DevTools network toggle
The online event could fire between page load and useEffect registering
its listener, leaving isOffline stuck at true despite navigator.onLine
being true. A zero-delay setTimeout after listener registration re-syncs
from navigator.onLine, covering that race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:20:49 -05:00
ginnoirandClaude Sonnet 4.6 afff22e994 Implement offline shell + service worker caching (task 51)
SW is now generated by next.config.ts on every build with a versioned
cache name (timestamp in prod, "dev" in dev). Strategies: stale-while-
revalidate for static chunks and navigation, network-first with 2s
timeout for API GETs, network-only for mutations. Offline mutations
postMessage to clients; pwa-register.tsx shows amber offline banner,
red mutation-failed toast, and indigo "new version" refresh prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:14:36 -05:00
ginnoirandClaude Sonnet 4.6 8aab25e0ac Implement PWA shell (task 50)
Add web manifest, placeholder icons, app-shell service worker, and
install prompt so famapp is installable on iOS and Android home screens.

- public/manifest.webmanifest — name, short_name, standalone display,
  start_url /, all four icon sizes (192/384/512 + maskable)
- public/icon-{180,192,384,512,512-maskable}.png — indigo placeholder
  squares generated by scripts/generate-icons.mjs (pnpm gen:icons)
- public/icon.svg — house-icon SVG source for future branded export
- public/sw.js — hand-rolled app-shell SW: precaches offline.html,
  cache-first for /_next/static/, network-first navigation with offline
  fallback, network-only for /api/*
- public/offline.html — branded offline fallback page
- src/components/pwa-register.tsx — client SW registration
- src/components/install-prompt.tsx — dismissible install banner:
  beforeinstallprompt on Android, "Add to Home Screen" hint on iOS
- Root layout: viewport themeColor, manifest/appleWebApp metadata,
  apple-touch-icon, mounts PwaRegister + InstallPrompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:00:02 -05:00
ginnoirandClaude Sonnet 4.6 d5a8bf9d95 Implement tasks 25, 26 + completion visibility setting
Task 25: Multiple dashboards — dashboards table + migration, /d/[slug]
route, root redirect, dashboard tabs in nav with create/rename/delete/
set-default. Task 26: Editable dashboard — react-grid-layout drag/resize
editor, widget picker modal with per-widget configurator auto-generated
from default config, save/reset server actions with Zod validation.

Completion visibility: server-side per-user setting (hours) replaces
localStorage timer; queries filter completed items by updatedAt cutoff;
Settings page dropdown persists preference via server action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:52:59 -05:00
ginnoirandClaude Sonnet 4.6 2ad9521ef9 Delay item disappearance after check-off; add setting in Settings
Items on the lists page and dashboard widget now show as crossed-out for
a configurable duration (default 3s) before vanishing. Unchecking within
the window cancels the removal. The delay is stored in localStorage and
configurable via Settings → Lists (options: 1s / 3s / 5s / 10s / 30s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:02:04 -05:00
ginnoirandClaude Sonnet 4.6 8fda3e47dd Inline items on lists page and interactive dashboard widget
- Lists page: show up to 10 open items per list in collapsible cards;
  items can be checked off directly without opening the list
- Dashboard widget: replace static span-checkboxes with a real client
  component using useOptimistic so items can be toggled from the dashboard
- listWidgetItems now returns listId for navigation links
- New listListsWithItems query fetches counts + top 10 open items per list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:57:01 -05:00
ginnoirandClaude Sonnet 4.6 734268cb75 Wire ShareButton into list/note/calendar pages; fix nav and calendar select labels
- Add ShareButton to list detail, note editor, and calendar event panel
- Add Settings gear icon to AppNav (core page not in module registry)
- Fix calendar event dialog showing raw UUID: pass explicit children to
  SelectValue so labels display before SelectContent mounts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:41:10 -05:00
ginnoirandClaude Sonnet 4.6 39aa5704f9 Implement share viewer (task 31)
- Makes actorId nullable in activity_log (migration 0010) with onDelete:
  set null so audit history survives user deletion; adds logShareActivity
  for anonymous mutations
- Adds resolveShareToken return of householdId for downstream validation
- Adds renderSharedView to EntityTypeRegistration; each module implements
  loadForShare (no-session bare queries) and renderSharedView
- Calendar: CalendarSharedView (upcoming 90-day events) and
  EventSharedView (title/time/location/notes)
- Lists: ListSharedView (client component with optimistic toggles via
  toggleShareListItem server action in lists/server/share-actions.ts)
- Notes: NoteSharedView (title + body + updated date)
- /app/s/[token]/page.tsx: resolves token, dispatches to loadForShare +
  renderSharedView, friendly error for invalid/expired tokens, noindex
- Middleware /s/* exemption confirmed present (no change needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:26:38 -05:00
ginnoirandClaude Sonnet 4.6 d5deee9a46 Implement share-link service (task 30)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:09:10 -05:00
ginnoirandClaude Sonnet 4.6 28475e483d Implement activity log (task 22)
- activity_log table in _core/schema.ts with household+created_at index;
  migration 0008_activity_log.sql applied
- logActivity() in _core/activity.ts reads current session and inserts a row
- ActivityLogEntry type + renderActivity?(entry): string added to
  EntityTypeRegistration so modules declare human-readable labels without
  any if/else branches in the widget
- core.activity widget replaced with a real async server component that
  queries the last 20 rows and renders via the registry
- logActivity wired into every create/update/delete in calendar, lists,
  and notes server actions; getAuthorizedItem also returns text so
  toggle/delete can include item text in the payload
- pnpm typecheck, lint, build, and all 4 E2E specs pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:57:03 -05:00
ginnoirandClaude Sonnet 4.6 1133fa8aac Implement quick-add registry (task 21)
- Add url field to QuickAddAction; make action optional
- Add getQuickAdds() + SerializedQuickAddItem to registry so quick-add
  data can cross the server→client RSC boundary without serializing fns
- Update calendar/lists/notes manifests with navigation URLs
- QuickAddProvider wraps root layout: holds sheet/palette open state and
  global cmd/ctrl+k shortcut
- QuickAddSheet: bottom drawer on mobile, right-side popover on desktop,
  actions grouped by module
- CommandPalette: cmdk-powered modal with fuzzy filter and full keyboard
  nav (arrows + enter + esc)
- QuickAddFab: client button replacing the plain + stub in the dashboard
- Add .claude/** to ESLint ignores to prevent stale worktree artifacts
  from failing lint

All 4 E2E specs pass; typecheck, lint, and build clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:44:33 -05:00
ginnoirandGitHub b9d3cce73a Merge pull request #2 from ginnoir/claude/great-einstein-b073e2
Implement dashboard composition (task 20)
2026-05-06 13:26:32 -05:00
136 changed files with 13089 additions and 787 deletions
-1
View File
@@ -2,7 +2,6 @@ node_modules
.next
.git
deploy
drizzle
docs
*.md
.env*
+2 -1
View File
@@ -19,9 +19,10 @@ AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=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_PRIVATE_KEY=
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:you@example.com
# ntfy (optional fallback channel; leave blank to disable)
+19 -8
View File
@@ -1,7 +1,17 @@
# ── famapp ────────────────────────────────────────────────────────────────────
# Public URL for the app (used in share links, OIDC redirect URIs, etc.)
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
# Image tag to deploy. Pin to a specific version after first deploy
# (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_DB_USER=famapp
@@ -11,20 +21,21 @@ FAMAPP_DB_NAME=famapp
# Auth.js session secret — generate 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_CLIENT_ID=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_PRIVATE_KEY=
# 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_URL=
NTFY_TOPIC=
# ntfy push fallback — uses your existing ntfy instance at ntfy.ginnoir.com
# Leave NTFY_URL blank to disable ntfy fanout.
NTFY_URL=https://ntfy.ginnoir.com
NTFY_TOPIC=famapp
# Log level: error | warn | info | debug
LOG_LEVEL=info
+37
View File
@@ -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
+42
View File
@@ -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
View File
@@ -9,6 +9,9 @@ dist/
build/
*.tsbuildinfo
# Generated at build time by next.config.ts — do not commit
public/sw.js
# Auto-generated by Next.js — never edit, never commit
next-env.d.ts
@@ -41,8 +44,7 @@ playwright-report/
test-results/
tests/.auth/
# Drizzle generated artifacts (migrations themselves are committed)
drizzle/meta/
# drizzle/meta/ intentionally committed — migrator requires _journal.json at runtime
# Backups
deploy/backups/data/
+13
View File
@@ -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
View File
@@ -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/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
EXPOSE 3000
CMD ["node", "server.js"]
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+22 -1
View File
@@ -20,10 +20,31 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **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.
- **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 task in `docs/tasks/`.
- **Ready to tag v0.1.0.** All phase 17 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
+4 -3
View File
@@ -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 {
reverse_proxy famapp:3000
reverse_proxy 192.168.1.69:3010
}
auth.ginnoir.com {
reverse_proxy authentik-server:9000
reverse_proxy 192.168.1.69:9200
}
+45
View File
@@ -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.
+102
View File
@@ -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, ~35× 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).
+58
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
0 2 * * * /scripts/backup.sh >> /var/log/backup.log 2>&1
+21
View File
@@ -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
+26
View File
@@ -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"
+24
View File
@@ -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"
+11
View File
@@ -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
+13
View File
@@ -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
+12 -4
View File
@@ -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>`:
```css
:root { /* default light tokens */ }
.dark { /* default dark tokens */ }
:root {
/* default light tokens */
}
.dark {
/* default dark tokens */
}
[data-theme="warm"] { /* warm light */ }
[data-theme="warm"].dark { /* warm dark */ }
[data-theme="warm"] {
/* 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.
+36
View File
@@ -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.
+1 -1
View File
@@ -6,7 +6,7 @@ Implement the `calendar` module: first-class **calendars** (multiple per househo
## 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
+11 -2
View File
@@ -22,5 +22,14 @@ A single global `+` button on the dashboard (and a `cmd+k` palette anywhere) tha
## 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.
- [ ] Keyboard navigation works in the palette (arrows + enter + escape).
- [x] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
- [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.
+1 -1
View File
@@ -6,7 +6,7 @@ Each user can create any number of named dashboards, switch between them, and ch
## 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
+9 -2
View File
@@ -52,8 +52,15 @@ Same component is reused for **Configure** on an already-placed widget. Re-runs
{
"version": 1,
"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,
},
],
}
```
+16
View File
@@ -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");
+29
View File
@@ -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");
+5
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "completion_visibility_hours" integer NOT NULL DEFAULT 24;
+28
View File
@@ -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";
+39
View File
@@ -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';
+176
View File
@@ -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": {}
}
}
+343
View File
@@ -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": {}
}
}
+387
View File
@@ -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": {}
}
}
+670
View File
@@ -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": {}
}
}
+927
View File
@@ -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
+104
View File
@@ -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
View File
@@ -5,12 +5,7 @@ import nextConfig from "eslint-config-next/core-web-vitals";
export default tseslint.config(
{
ignores: [
"node_modules/**",
".next/**",
"dist/**",
"drizzle/**",
],
ignores: ["node_modules/**", ".next/**", ".claude/**", "dist/**", "drizzle/**", "public/sw.js"],
},
js.configs.recommended,
...tseslint.configs.recommended,
+176
View File
@@ -1,8 +1,184 @@
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 = {
reactStrictMode: true,
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;
+10 -1
View File
@@ -20,7 +20,9 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"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": {
"@eslint/eslintrc": "^3.3.5",
@@ -30,10 +32,13 @@
"@types/node": "^22.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-grid-layout": "^2.1.0",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.10",
"eslint": "^9.15.0",
"eslint-config-next": "^16.2.4",
"globals": "^15.12.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.3.3",
"tailwindcss": "^4.2.4",
"tsx": "^4.19.4",
@@ -50,16 +55,20 @@
"@fullcalendar/timegrid": "^6.1.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
"next-auth": "5.0.0-beta.31",
"pino": "^10.3.1",
"postgres": "^3.4.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
}
+815
View File
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

+12
View File
@@ -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

+32
View File
@@ -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"
}
]
}
+95
View File
@@ -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>
+100
View File
@@ -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})`);
}
+25
View File
@@ -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 });
}
+24
View File
@@ -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 });
}
+6
View File
@@ -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}`);
+99
View File
@@ -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>
);
}
+205
View File
@@ -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
View File
@@ -5,233 +5,233 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
/* Warm theme — light */
[data-theme="warm"] {
--background: oklch(0.98 0.012 60);
--foreground: oklch(0.18 0.03 50);
--card: oklch(0.97 0.014 58);
--card-foreground: oklch(0.18 0.03 50);
--popover: oklch(0.97 0.014 58);
--popover-foreground: oklch(0.18 0.03 50);
--primary: oklch(0.52 0.18 40);
--primary-foreground: oklch(0.98 0.01 60);
--secondary: oklch(0.92 0.02 60);
--secondary-foreground: oklch(0.25 0.04 45);
--muted: oklch(0.93 0.018 58);
--muted-foreground: oklch(0.52 0.04 50);
--accent: oklch(0.90 0.03 55);
--accent-foreground: oklch(0.22 0.04 45);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.88 0.025 60);
--input: oklch(0.88 0.025 60);
--ring: oklch(0.65 0.12 45);
--chart-1: oklch(0.72 0.16 40);
--chart-2: oklch(0.62 0.14 55);
--chart-3: oklch(0.52 0.12 45);
--chart-4: oklch(0.45 0.10 40);
--chart-5: oklch(0.35 0.08 35);
--radius: 0.625rem;
--sidebar: oklch(0.94 0.018 58);
--sidebar-foreground: oklch(0.18 0.03 50);
--sidebar-primary: oklch(0.52 0.18 40);
--sidebar-primary-foreground: oklch(0.98 0.01 60);
--sidebar-accent: oklch(0.90 0.03 55);
--sidebar-accent-foreground: oklch(0.22 0.04 45);
--sidebar-border: oklch(0.88 0.025 60);
--sidebar-ring: oklch(0.65 0.12 45);
--background: oklch(0.98 0.012 60);
--foreground: oklch(0.18 0.03 50);
--card: oklch(0.97 0.014 58);
--card-foreground: oklch(0.18 0.03 50);
--popover: oklch(0.97 0.014 58);
--popover-foreground: oklch(0.18 0.03 50);
--primary: oklch(0.52 0.18 40);
--primary-foreground: oklch(0.98 0.01 60);
--secondary: oklch(0.92 0.02 60);
--secondary-foreground: oklch(0.25 0.04 45);
--muted: oklch(0.93 0.018 58);
--muted-foreground: oklch(0.52 0.04 50);
--accent: oklch(0.9 0.03 55);
--accent-foreground: oklch(0.22 0.04 45);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.88 0.025 60);
--input: oklch(0.88 0.025 60);
--ring: oklch(0.65 0.12 45);
--chart-1: oklch(0.72 0.16 40);
--chart-2: oklch(0.62 0.14 55);
--chart-3: oklch(0.52 0.12 45);
--chart-4: oklch(0.45 0.1 40);
--chart-5: oklch(0.35 0.08 35);
--radius: 0.625rem;
--sidebar: oklch(0.94 0.018 58);
--sidebar-foreground: oklch(0.18 0.03 50);
--sidebar-primary: oklch(0.52 0.18 40);
--sidebar-primary-foreground: oklch(0.98 0.01 60);
--sidebar-accent: oklch(0.9 0.03 55);
--sidebar-accent-foreground: oklch(0.22 0.04 45);
--sidebar-border: oklch(0.88 0.025 60);
--sidebar-ring: oklch(0.65 0.12 45);
}
/* Warm theme — dark */
[data-theme="warm"].dark {
--background: oklch(0.16 0.025 45);
--foreground: oklch(0.95 0.015 60);
--card: oklch(0.22 0.03 48);
--card-foreground: oklch(0.95 0.015 60);
--popover: oklch(0.22 0.03 48);
--popover-foreground: oklch(0.95 0.015 60);
--primary: oklch(0.78 0.14 50);
--primary-foreground: oklch(0.16 0.025 45);
--secondary: oklch(0.28 0.04 50);
--secondary-foreground: oklch(0.95 0.015 60);
--muted: oklch(0.28 0.035 48);
--muted-foreground: oklch(0.68 0.04 55);
--accent: oklch(0.30 0.045 50);
--accent-foreground: oklch(0.95 0.015 60);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0.08 50);
--chart-1: oklch(0.78 0.14 50);
--chart-2: oklch(0.65 0.12 55);
--chart-3: oklch(0.52 0.10 48);
--chart-4: oklch(0.42 0.08 42);
--chart-5: oklch(0.32 0.06 38);
--sidebar: oklch(0.20 0.03 47);
--sidebar-foreground: oklch(0.95 0.015 60);
--sidebar-primary: oklch(0.78 0.14 50);
--sidebar-primary-foreground: oklch(0.16 0.025 45);
--sidebar-accent: oklch(0.30 0.045 50);
--sidebar-accent-foreground: oklch(0.95 0.015 60);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.55 0.08 50);
--background: oklch(0.16 0.025 45);
--foreground: oklch(0.95 0.015 60);
--card: oklch(0.22 0.03 48);
--card-foreground: oklch(0.95 0.015 60);
--popover: oklch(0.22 0.03 48);
--popover-foreground: oklch(0.95 0.015 60);
--primary: oklch(0.78 0.14 50);
--primary-foreground: oklch(0.16 0.025 45);
--secondary: oklch(0.28 0.04 50);
--secondary-foreground: oklch(0.95 0.015 60);
--muted: oklch(0.28 0.035 48);
--muted-foreground: oklch(0.68 0.04 55);
--accent: oklch(0.3 0.045 50);
--accent-foreground: oklch(0.95 0.015 60);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0.08 50);
--chart-1: oklch(0.78 0.14 50);
--chart-2: oklch(0.65 0.12 55);
--chart-3: oklch(0.52 0.1 48);
--chart-4: oklch(0.42 0.08 42);
--chart-5: oklch(0.32 0.06 38);
--sidebar: oklch(0.2 0.03 47);
--sidebar-foreground: oklch(0.95 0.015 60);
--sidebar-primary: oklch(0.78 0.14 50);
--sidebar-primary-foreground: oklch(0.16 0.025 45);
--sidebar-accent: oklch(0.3 0.045 50);
--sidebar-accent-foreground: oklch(0.95 0.015 60);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.55 0.08 50);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
}
body {
@apply bg-background text-foreground;
}
}
html {
@apply font-sans;
}
}
}
.fc {
--fc-border-color: var(--border);
--fc-page-bg-color: var(--background);
--fc-neutral-bg-color: var(--muted);
--fc-neutral-text-color: var(--muted-foreground);
--fc-button-bg-color: var(--primary);
--fc-button-border-color: var(--primary);
--fc-button-text-color: var(--primary-foreground);
--fc-button-hover-bg-color: var(--foreground);
--fc-button-hover-border-color: var(--foreground);
color: var(--foreground);
--fc-border-color: var(--border);
--fc-page-bg-color: var(--background);
--fc-neutral-bg-color: var(--muted);
--fc-neutral-text-color: var(--muted-foreground);
--fc-button-bg-color: var(--primary);
--fc-button-border-color: var(--primary);
--fc-button-text-color: var(--primary-foreground);
--fc-button-hover-bg-color: var(--foreground);
--fc-button-hover-border-color: var(--foreground);
color: var(--foreground);
}
.fc .fc-button {
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
padding: 0.35rem 0.6rem;
text-transform: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
padding: 0.35rem 0.6rem;
text-transform: none;
}
.fc .fc-toolbar-title {
font-size: 1.25rem;
font-weight: 600;
font-size: 1.25rem;
font-weight: 600;
}
.fc .fc-daygrid-day-number,
.fc .fc-col-header-cell-cushion {
color: var(--foreground);
text-decoration: none;
color: var(--foreground);
text-decoration: none;
}
.fc .fc-event {
border-radius: 6px;
padding: 1px 3px;
border-radius: 6px;
padding: 1px 3px;
}
+46 -10
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
@@ -6,14 +6,34 @@ import { AppNav } from "@/components/app-nav";
import "@/modules"; // registers all module manifests
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import { users } from "@/modules/_core/schema";
import { asc, eq } from "drizzle-orm";
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" });
export const viewport: Viewport = {
themeColor: "#4F46E5",
};
export const metadata: Metadata = {
title: "famapp",
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
@@ -29,13 +49,10 @@ const prePaintScript = `(function(){
} catch(e) {}
})();`;
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
let theme = "default";
let themeMode = "system";
let userDashboards: DashboardMeta[] = [];
const session = await auth();
if (session?.user?.id) {
@@ -48,12 +65,25 @@ export default async function RootLayout({
theme = row.theme;
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
// script will correct it before paint. We optimistically render light here.
const isDark = themeMode === "dark";
const quickAdds = getQuickAdds();
return (
<html
lang="en"
@@ -64,8 +94,14 @@ export default async function RootLayout({
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
</head>
<body className="min-h-screen">
<AppNav />
<main>{children}</main>
<QuickAddProvider actions={quickAdds}>
<AppNav dashboards={userDashboards} />
<main>{children}</main>
<QuickAddSheet />
<CommandPalette />
<InstallPrompt />
<PwaRegister />
</QuickAddProvider>
</body>
</html>
);
+2 -2
View File
@@ -1,7 +1,7 @@
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() {
const lists = await listLists();
const lists = await listListsWithItems();
return <ListsIndex lists={lists} />;
}
+5 -86
View File
@@ -1,88 +1,7 @@
import { Suspense } from "react";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { computeDefaultLayout, parseDashboardLayout } from "@/lib/dashboard";
import { getCurrentSession } from "@/lib/session";
import { getWidget } from "@/modules/_core";
import { users } from "@/modules/_core/schema";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { redirect } from "next/navigation";
import { getDefaultDashboardSlug } from "@/app/d/actions";
// Static lookup ensures Tailwind sees all sm:col-span-* classes at build time.
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() {
const { user, household } = await getCurrentSession();
const [row] = await db
.select({ layout: users.defaultDashboardLayout })
.from(users)
.where(eq(users.id, user.id))
.limit(1);
const layout = parseDashboardLayout(row?.layout) ?? computeDefaultLayout();
const ctx = { userId: user.id, householdId: household.id };
// Sort by y then x so document order matches visual order on mobile (single col).
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</h1>
{/* Quick-add FAB — wired up in task 21 */}
<button
aria-label="Quick add"
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>
</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>
);
export default async function RootPage() {
const slug = await getDefaultDashboardSlug();
redirect(`/d/${slug}`);
}
+85
View File
@@ -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
View File
@@ -1,25 +1,34 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
import { getCurrentSession } from "@/lib/session";
import { revokeShareLink } from "@/modules/_core/share";
export async function setUserTheme({
theme,
mode,
}: {
theme: ThemeId;
mode: ThemeMode;
}) {
export async function setUserTheme({ theme, mode }: { theme: ThemeId; mode: ThemeMode }) {
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
const { user } = await getCurrentSession();
await db
.update(users)
.set({ theme, themeMode: mode })
.where(eq(users.id, user.id));
await db.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");
}
+1 -4
View File
@@ -13,10 +13,7 @@ export async function renameHousehold(formData: FormData) {
const name = formData.get("name");
if (typeof name !== "string" || !name.trim()) throw new Error("Invalid name");
await db
.update(households)
.set({ name: name.trim() })
.where(eq(households.id, household.id));
await db.update(households).set({ name: name.trim() }).where(eq(households.id, household.id));
revalidatePath("/settings/household");
}
+2 -9
View File
@@ -4,12 +4,7 @@ import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { renameHousehold } from "./actions";
export default async function HouseholdSettingsPage() {
@@ -34,9 +29,7 @@ export default async function HouseholdSettingsPage() {
{members.map(({ user, role: memberRole }) => (
<li key={user.id} className="flex items-center justify-between">
<span>{user.name ?? user.email}</span>
<span className="text-sm text-muted-foreground capitalize">
{memberRole}
</span>
<span className="text-sm text-muted-foreground capitalize">{memberRole}</span>
</li>
))}
</ul>
+40
View File
@@ -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");
}
+81
View File
@@ -1,10 +1,20 @@
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 { Button } from "@/components/ui/button";
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";
export default async function SettingsPage() {
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 (
<div className="container max-w-2xl py-8 space-y-6">
@@ -23,6 +33,77 @@ export default async function SettingsPage() {
</CardContent>
</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
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"
+45
View File
@@ -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
View File
@@ -1,24 +1,82 @@
import Link from "next/link";
import { Settings } from "lucide-react";
import { desc, eq } from "drizzle-orm";
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 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 (
<nav className="border-b px-4 py-3 flex items-center gap-6">
<Link href="/" className="font-semibold text-sm">
famapp
</Link>
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
<header className="border-b">
<nav className="px-4 py-3 flex items-center gap-6">
<Link href="/" className="font-semibold text-sm shrink-0">
famapp
</Link>
))}
</nav>
{navItems.map((item) => (
<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>
);
}
+118
View File
@@ -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>
);
}
+232
View File
@@ -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>
);
}
+198
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+119
View File
@@ -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" />
&nbsp;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>
);
}
+112
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+106
View File
@@ -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&rsquo;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&rsquo;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>
)}
</>
);
}
+17
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+106
View File
@@ -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] ?? "") : "";
}
+68
View File
@@ -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>
</>
);
}
+1 -5
View File
@@ -25,11 +25,7 @@ export function ThemePicker({
initialMode = "system",
signedIn = false,
}: Props) {
const { theme, mode, setTheme, setMode } = useTheme(
initialTheme,
initialMode,
signedIn,
);
const { theme, mode, setTheme, setMode } = useTheme(initialTheme, initialMode, signedIn);
return (
<div className="space-y-4">
+7 -7
View File
@@ -1,7 +1,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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",
@@ -37,8 +37,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -52,7 +52,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+15 -26
View File
@@ -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({
className,
@@ -13,11 +13,11 @@ function Card({
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
className,
)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
className,
)}
{...props}
/>
)
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+30 -52
View File
@@ -1,42 +1,39 @@
"use client"
"use client";
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...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) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...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({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -45,7 +42,7 @@ function DialogContent({
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
@@ -54,7 +51,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-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}
>
@@ -62,32 +59,21 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<XIcon
/>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
);
}
function DialogFooter({
@@ -96,54 +82,46 @@ function DialogFooter({
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
className={cn("font-heading text-base leading-none font-medium", className)}
{...props}
/>
)
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -157,4 +135,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+258
View File
@@ -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,
};
+6 -6
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import * as React from "react";
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">) {
return (
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };
+6 -6
View File
@@ -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">) {
return (
@@ -10,11 +10,11 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };
+31 -42
View File
@@ -1,12 +1,12 @@
"use client"
"use client";
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
@@ -15,7 +15,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
className={cn("scroll-my-1 p-1", className)}
{...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)}
{...props}
/>
)
);
}
function SelectTrigger({
@@ -34,7 +34,7 @@ function SelectTrigger({
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -42,18 +42,16 @@ function SelectTrigger({
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />}
/>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@@ -83,7 +81,10 @@ function SelectContent({
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
@@ -92,33 +93,26 @@ function SelectContent({
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
className,
)}
{...props}
>
@@ -133,20 +127,17 @@ function SelectItem({
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -158,14 +149,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<ChevronUpIcon
/>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
)
);
}
function SelectScrollDownButton({
@@ -177,14 +167,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<ChevronDownIcon
/>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
)
);
}
export {
@@ -198,4 +187,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};
+313
View File
@@ -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>
);
}
+1 -2
View File
@@ -9,8 +9,7 @@ function applyTheme(theme: ThemeId, mode: ThemeMode) {
html.setAttribute("data-theme", theme);
const dark =
mode === "dark" ||
(mode === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
(mode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
html.classList.toggle("dark", dark);
try {
localStorage.setItem("theme", theme);
+6
View File
@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startReminderWorker } = await import("@/modules/_core/reminders");
startReminderWorker();
}
}
-34
View File
@@ -3,15 +3,10 @@ import NextAuth, { type DefaultSession } from "next-auth";
import { db } from "@/lib/db";
import {
accounts,
households,
householdMembers,
sessions,
users,
verificationTokens,
} 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" {
interface Session {
@@ -41,35 +36,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
signIn: "/login",
},
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.user.id = user.id;
return session;
+11
View File
@@ -1,5 +1,16 @@
export const DEV_LOGIN_COOKIE = "authjs.session-token";
if (
process.env.NODE_ENV === "production" &&
process.env.ENABLE_DEV_LOGIN === "true" &&
process.env.NEXT_PHASE !== "phase-production-build"
) {
throw new Error(
"Refusing to start: ENABLE_DEV_LOGIN=true with NODE_ENV=production. " +
"Unset ENABLE_DEV_LOGIN in production env.",
);
}
export function isDevLoginEnabled() {
return process.env.NODE_ENV !== "production" && process.env.ENABLE_DEV_LOGIN === "true";
}
+28
View File
@@ -0,0 +1,28 @@
import pino from "pino";
// Structured JSON logger for Node.js server code (server components, server actions,
// background workers). NOT available in the Edge runtime (middleware) — use
// console.log with JSON.stringify there instead.
//
// Secrets are never passed as log fields. Any field named `password`, `secret`,
// `token`, or `key` is explicitly excluded by callers. Sensitive env vars
// (AUTH_SECRET, VAPID_PRIVATE_KEY, etc.) must not appear in log payloads.
//
// Log level is controlled by the LOG_LEVEL env var (default: info).
// In development, pino-pretty formats output with colour for readability.
// In production, single-line JSON goes to stdout and is collected by Docker.
const level = process.env.LOG_LEVEL ?? "info";
const logger = pino(
{
level,
base: { pid: undefined, hostname: undefined },
timestamp: pino.stdTimeFunctions.isoTime,
},
process.env.NODE_ENV === "production"
? undefined
: pino.transport({ target: "pino-pretty", options: { colorize: true } }),
);
export default logger;

Some files were not shown because too many files have changed in this diff Show More