Compare commits

...
39 Commits
Author SHA1 Message Date
ginnoirandClaude Sonnet 4.6 9c60b58947 ci(release): fix env var names for act_runner compatibility
CI / checks (push) Successful in 1m44s
CI / build (push) Successful in 3m28s
Release Image / build-and-push (push) Successful in 5m21s
gitea act_runner exposes GITHUB_* not GITEA_* variable aliases;
switch to GITHUB_REF_NAME and GITHUB_OUTPUT so tag extraction works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:22:44 -05:00
ginnoirandClaude Sonnet 4.6 4084c3af91 ci: publish famapp images to valhalla registry
CI / checks (push) Successful in 1m46s
CI / build (push) Successful in 2m45s
Release Image / build-and-push (push) Failing after 49s
Add Gitea Actions workflows (ci.yml, release.yml) that build and push
to registry.ginnoir.com. Disable GitHub release creation. Update all
doc/compose references from ghcr.io to registry.ginnoir.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:16:27 -05:00
ginnoirandClaude Sonnet 4.6 4f92a98921 docs: add drizzle-best-practices to regular agent skills
CI / checks (push) Successful in 11m6s
CI / build (push) Successful in 13m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 05:33:07 -05:00
ginnoir a312d4ce39 fix: tighten sharing and shadcn composition
CI / checks (push) Successful in 12m55s
CI / build (push) Successful in 15m16s
2026-06-13 05:20:01 -05:00
ginnoir e1774c802d refactor(garden): replace hand-rolled badge classes with shadcn Badge 2026-06-13 03:31:37 -05:00
ginnoir 55b0e68b35 feat(shadcn): install 10 missing components 2026-06-13 03:08:56 -05:00
ginnoir 615d9c9377 fix: declare unlisted dependencies surfaced by knip
CI / checks (push) Has been cancelled
CI / build (push) Has been cancelled
sharp (icon script), @auth/core (adapter types in _core/schema),
react-resizable (css imported by dashboard-editor), and server-only
were imported but undeclared, resolving only via pnpm hoisting.
2026-06-09 15:55:36 -05:00
ginnoir 944dcdf48f refactor: remove dead code identified by knip
Delete unused Fab component (superseded by QuickAddFab), unused server
actions and queries (listDashboards, reorderDashboards, deleteCareLog,
canAccessContainer, getOverduePlants, getCareDueSoon, addItemToDefaultList,
reorderItems, getOrCreateDefaultList), unused helpers (getCurrentUser,
BrandWordmark, TopbarSpacer, DEV_LOGIN_COOKIE, no-arg ensureDefault*
wrappers, addListItem re-export), and drop export keyword from five
internal-only functions.

Verified with typecheck, eslint, and production build after each removal.
2026-06-09 15:55:21 -05:00
ginnoir d4ac20f511 docs: link homelabstack infra repo in claude.md
CI / checks (push) Has been cancelled
CI / build (push) Has been cancelled
2026-06-03 22:14:05 -05:00
ginnoir fd97eb306d fix: list item checkbox/delete broken by pointer capture, badge wrapping
setPointerCapture on the swipe row div was swallowing click events from
child buttons; guard now bails early when the pointer originates from a
button or input. Also added whitespace-nowrap + shrink-0 to the list
type/count badge to prevent it wrapping across two lines.
2026-06-03 22:01:33 -05:00
ginnoir a12208d2fe chore: release v0.5.3
Release / build-and-push (push) Has been cancelled
2026-06-03 20:18:21 -05:00
ginnoir 14175d3faa fix: use next_public_app_url for share link base url instead of localhost fallback 2026-06-03 20:16:59 -05:00
ginnoir c3e78932fe chore: release v0.5.2
Release / build-and-push (push) Has been cancelled
2026-06-03 20:06:55 -05:00
ginnoir a332935ba7 fix: render dashboard greeting and date client-side to respect local timezone 2026-06-03 20:06:25 -05:00
ginnoir 51c8d3c92d chore: release v0.5.1
Release / build-and-push (push) Has been cancelled
2026-06-03 19:55:57 -05:00
ginnoir 62c3d33350 fix: dashboard switcher, platform-aware search shortcut, mobile search, static topbar
- Always show dashboard tab strip + add button (was gated behind length > 1)
- Search shortcut now shows Ctrl+K on Windows/Linux, ⌘K on Mac
- Add icon-only search button in topbar for mobile viewports
- Lock html/body overflow and use 100dvh to prevent topbar scrolling off-screen on mobile
2026-06-03 19:55:00 -05:00
ginnoir 2408d37f8e chore: release v0.5.0
Release / build-and-push (push) Has been cancelled
2026-06-03 19:20:37 -05:00
ginnoir f47c1c3a23 fix: replace settings mobile tabs with 2-col grid and remove fab 2026-06-03 19:17:17 -05:00
ginnoir 6e92648a41 feat: mobile experience overhaul — touch targets, calendar mobile layout, swipe gestures 2026-06-03 18:50:15 -05:00
ginnoir 6802ae08db fix: db:seed script points to seed.mjs not nonexistent seed.ts 2026-06-03 16:19:29 -05:00
ginnoir b935ff209c chore: release v0.4.10
Release / build-and-push (push) Has been cancelled
2026-06-03 16:16:01 -05:00
ginnoir 3fb5ddc5dd chore: upgrade node 22 to 24 lts 2026-06-03 16:15:00 -05:00
ginnoir ca536b5c33 chore: release v0.4.9
Release / build-and-push (push) Has been cancelled
2026-06-03 16:00:38 -05:00
ginnoir ec2f3930ad feat: proper logo, favicon, and pwa icons
- replace placeholder house svg with clean geometric house mark
  on indigo-700 (#4338CA) — roof triangle, body, door cutout
- add favicon.svg with prefers-color-scheme dark variant
- add icon-16/32 sizes; regenerate all pngs from svg via sharp
- update BrandMark component to use inline svg house instead of 'f'
- wire favicon + sized pngs into layout metadata
- fix middleware to allow icon/manifest/favicon paths without auth
  (android launcher fetches icons in a cookieless system context)
- fix getCurrentSession() to redirect to /login instead of throwing
  when session cookie is stale/invalid
2026-06-03 15:59:34 -05:00
ginnoir 10cad3df17 chore: release v0.4.8
Release / build-and-push (push) Has been cancelled
2026-06-02 20:26:57 -05:00
ginnoir 77db935329 chore: register 0018_container_images in drizzle migration journal 2026-06-02 20:19:57 -05:00
ginnoir c6a34f7471 feat: share links visible on plants/containers; container image gallery
- Fix ShareButton silently swallowing errors from createShareLink; now
  shows inline error text so failures are visible to the user
- Add getShareLinksForEntity server action and EntityShareLink type to
  _core/share.ts
- Add ShareLinkList component — renders active share links per entity
  with per-row Revoke; renders nothing when empty
- Wire ShareLinkList into plant and container detail pages (loaded
  server-side in parallel with the entity fetch)
- Add images jsonb column to garden_containers schema + migration 0018
- Add addContainerImage / removeContainerImage / setContainerPrimaryImage
  server actions mirroring the plant image pattern (10-image cap, first
  upload auto-sets cover)
- Update ContainerDetailDto, listContainers, getContainer to include images
- Rewrite ContainerDetail with Info/Gallery tabs; Gallery tab mirrors
  plant gallery (3-col grid, star/X overlays, upload button, counter)
- Update ContainerShareData and container renderSharedView to show cover
  image hero and secondary image grid on public share pages
2026-06-02 20:15:29 -05:00
ginnoir 076e35aced docs: add contributing guide, env reference, and operations runbook 2026-06-02 19:53:18 -05:00
ginnoir 8bf00c326d docs: update decisions index and task index for garden module and adr backfill
Populates the decisions index with the two existing ADRs (list SSE/notify,
release workflow). Adds task 09 to the phase 1 list and adds the phase 8
garden module task entries to the task index.
2026-06-02 19:52:55 -05:00
ginnoir 6a1ec8bb7b docs: add professional readme with full feature coverage and release pipeline hook
Replaces the 8-line placeholder with a complete README covering all four
modules, platform capabilities, quick start, dev setup, configuration
reference, and deployment links. Badges use dynamic shields.io URLs so
version/CI status update automatically on each release.

Wires README review into the release pipeline via a release-it
before:git:release hook and a new item in the pre-deploy checklist.
2026-06-02 19:52:24 -05:00
ginnoir fe4673f1cd chore: replace personal domains and ips with generic placeholders 2026-06-02 19:33:49 -05:00
ginnoir 4ec1f34150 chore: gitignore maintainer deploy scripts, scrub personal domain from dev.mjs 2026-06-02 19:30:27 -05:00
ginnoir 7938425850 chore: gitignore maintainer-specific production deploy files
deploy/compose.yaml, deploy/Caddyfile, and deploy/Caddyfile.dev.snippet
contain homelab-specific config and are not useful as public templates.
Public-facing equivalents: compose.example.yaml and Caddyfile.snippet.
2026-06-02 19:22:31 -05:00
ginnoir 13d94b3751 chore(deploy): separate maintainer config from public deployment templates
- rename Caddyfile.snippet → Caddyfile (maintainer's full production file)
- restore Caddyfile.snippet as a minimal public reference (famapp blocks only)
- add compose.example.yaml: standalone famapp + authentik template for new
  deployers; compose.yaml remains the maintainer's homelab monolith
- update deploy/README.md: file table, setup steps referencing example files
- apply-compose.ps1: point Caddy push at deploy/Caddyfile (not snippet)
2026-06-02 19:19:27 -05:00
ginnoir aae3e30843 fix(deploy): separate reactive resume auth_secret from famapp
- rename AUTH_SECRET to RESUME_AUTH_SECRET in production .env and compose
  app service — famapp's AUTH_SECRET is now unambiguous
- remove orphaned stale AUTH_SECRET entry from .env
- apply-compose.ps1: pull + restart famapp immediately after pushing compose
  rather than waiting for watchtower; uses dc alias via bash -ic
- update .env.production.example to reflect corrected variable names
2026-06-02 19:12:34 -05:00
ginnoir 23a14e1650 chore: replace placeholder deploy config with actual production files
- deploy/compose.yaml: replace generated placeholder with actual monolith
  compose (Foundry, Caddy, media stack, famapp, Authentik, Watchtower, etc.)
- deploy/Caddyfile.snippet: replace with full production Caddyfile
- .env.production.example: update variable names to match production
  (famapp_MINIO_ROOT_USER/PASSWORD/BUCKET, CF_API_TOKEN, etc.)
- scripts/apply-compose.ps1: add -Compose/-Caddy flags; push Caddyfile
  and reload caddy in addition to compose restart
2026-06-02 19:03:53 -05:00
ginnoir cce3bac5b2 chore: repo cleanup and prod sync tooling
- Remove stale deploy/docker-entrypoint.sh (Dockerfile copies root version)
- Remove scripts/seed.ts (superseded by seed.mjs)
- Add src/app/error.tsx and global-error.tsx (untracked error boundaries)
- Gitignore deploy/.prod/ for synced production configs
- Add scripts/sync-prod.ps1 to pull compose/env/Caddyfile from valhalla
- Add scripts/apply-compose.ps1 to push compose changes and restart services
2026-06-02 18:51:59 -05:00
ginnoir 31c5805b34 chore: release v0.4.7 2026-06-02 18:30:48 -05:00
ginnoir 989bae8f1e feat(garden): add share button to plant and container detail views 2026-06-02 18:30:28 -05:00
121 changed files with 4552 additions and 1280 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# Public app URL (used for OIDC redirect URIs, share links, etc.)
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
NEXT_PUBLIC_APP_URL=https://fam.yourdomain.com
# Postgres
DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
@@ -15,7 +15,7 @@ DEV_LOGIN_NAME=Dev User
DEV_HOUSEHOLD_NAME=Home
# OIDC (Authentik)
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=replace-me
AUTH_OIDC_CLIENT_SECRET=replace-me
+23 -17
View File
@@ -1,51 +1,57 @@
# ── famapp ────────────────────────────────────────────────────────────────────
# These vars are consumed by the famapp service in docker-compose.yml.
# 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
# Public URL for the app
AUTH_URL=https://fam.yourdomain.com
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
# famapp Postgres
FAMAPP_DB_USER=famapp
FAMAPP_DB_PASSWORD=replace-with-strong-password
FAMAPP_DB_NAME=famapp
# Auth.js session secret — generate with: openssl rand -base64 32
# NOTE: Reactive Resume uses RESUME_AUTH_SECRET; AUTH_SECRET is famapp-only.
AUTH_SECRET=replace-with-openssl-rand-base64-32
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_ISSUER=https://auth.yourdomain.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 (run from the repo)
# Web Push VAPID keys — generate with: pnpm vapid:generate
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Must be "mailto:<address>" or a URL
VAPID_SUBJECT=mailto:3nigma.matt@gmail.com
VAPID_SUBJECT=mailto:your-email@example.com
# 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 push fallback
NTFY_URL=https://ntfy.yourdomain.com
NTFY_TOPIC=famapp
# Log level: error | warn | info | debug
LOG_LEVEL=info
# ── Authentik ─────────────────────────────────────────────────────────────────
# MinIO object storage (garden image uploads)
famapp_MINIO_ROOT_USER=famapp
famapp_MINIO_ROOT_PASSWORD=replace-with-strong-password
famapp_MINIO_BUCKET=garden
# Authentik Postgres credentials (separate DB per Matt's rule)
# OpenPlantBook API (optional — used for plant species lookup)
famapp_OPENPLANTBOOK_CLIENT_ID=
famapp_OPENPLANTBOOK_CLIENT_SECRET=
# ── Authentik ─────────────────────────────────────────────────────────────────
AUTHENTIK_DB_USER=authentik
AUTHENTIK_DB_PASSWORD=replace-with-strong-password
AUTHENTIK_DB_NAME=authentik
# Authentik secret key — generate with: openssl rand -base64 60
AUTHENTIK_SECRET_KEY=replace-with-openssl-rand-base64-60
# ── Cloudflare (Caddy DNS-01 TLS) ─────────────────────────────────────────────
CF_API_TOKEN=replace-with-cloudflare-api-token
+49
View File
@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
DATABASE_URL: postgres://ci_user:ci_password@localhost:5432/ci_database
AUTH_SECRET: ci-auth-secret-for-build
NEXT_PUBLIC_APP_URL: http://localhost:3000
+48
View File
@@ -0,0 +1,48 @@
name: Release Image
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Derive image tags
id: meta
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
MAJOR_MINOR="$(printf '%s' "$VERSION" | awk -F. '{print $1"."$2}')"
{
echo "version=$VERSION"
echo "major_minor=$MAJOR_MINOR"
echo "image=registry.ginnoir.com/ginnoir/famapp"
} >> "$GITHUB_OUTPUT"
- name: Install docker
run: apt-get update -qq && apt-get install -y -qq docker.io
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_PUSH_PASSWORD }}" | docker login registry.ginnoir.com \
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
--password-stdin
- name: Build image
run: |
docker build \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}" \
-t "${{ steps.meta.outputs.image }}:latest" \
.
- name: Push image
run: |
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}"
docker push "${{ steps.meta.outputs.image }}:latest"
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
@@ -39,7 +39,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
+8
View File
@@ -49,5 +49,13 @@ tests/.auth/
# Backups
deploy/backups/data/
# Production configs and deploy scripts — maintainer-specific, never commit
deploy/.prod/
deploy/compose.yaml
deploy/Caddyfile
deploy/Caddyfile.dev.snippet
scripts/apply-compose.ps1
scripts/sync-prod.ps1
# Design handoff bundle (reference only, not committed)
.design-tmp/
+1 -1
View File
@@ -1 +1 @@
22
24
+4 -2
View File
@@ -8,8 +8,10 @@
"requireBranch": "main"
},
"github": {
"release": true,
"releaseName": "v${version}"
"release": false
},
"hooks": {
"before:git:release": "echo 'Check: README.md reflects current modules and env vars before tagging'"
},
"plugins": {
"@release-it/conventional-changelog": {
+7 -2
View File
@@ -6,6 +6,11 @@ This file is the canonical brief. Read it at the start of every session before m
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
## Regular agent skills
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
---
## Goals
@@ -31,7 +36,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
| Package manager | pnpm | Fast, content-addressed store, strict deps |
| Runtime | Node 22 LTS | In `node:22-alpine` container |
| Runtime | Node 24 LTS | In `node:24-alpine` container |
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
@@ -106,7 +111,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
```yaml
# compose.yaml (sketch — final lives in /deploy)
services:
famapp: # node:22-alpine, Next.js
famapp: # node:24-alpine, Next.js
famapp-db: # postgres:16
authentik-server:
authentik-worker:
+48
View File
@@ -1,5 +1,53 @@
# Changelog
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
### Bug Fixes
- use next_public_app_url for share link base url instead of localhost fallback ([14175d3](https://github.com/ginnoir/famapp/commit/14175d3faa146ebff7c825848837c75047a8eb71))
## [0.5.2](https://github.com/ginnoir/famapp/compare/v0.5.1...v0.5.2) (2026-06-03)
### Bug Fixes
- **dashboard:** render greeting and date client-side so local timezone is used instead of server UTC
## [0.5.1](https://github.com/ginnoir/famapp/compare/v0.5.0...v0.5.1) (2026-06-03)
### Bug Fixes
- **dashboard:** always show tab strip and add-dashboard button regardless of dashboard count
- **search:** show Ctrl+K on Windows/Linux instead of ⌘K
- **mobile:** add search icon button in topbar for mobile viewports
- **mobile:** lock html/body overflow and use 100dvh to prevent topbar scrolling off-screen
## [0.5.0](https://github.com/ginnoir/famapp/compare/v0.4.10...v0.5.0) (2026-06-03)
### Features
- **mobile:** full mobile experience overhaul — bottom nav, touch targets, calendar mobile layout with day/week/agenda views and calendars bottom sheet, bidirectional swipe gestures on list items (right to complete, left to delete), iOS safe-area and keyboard support, input zoom prevention
- **mobile:** settings navigation replaced with 2-column icon grid (no horizontal scroll)
- **mobile:** remove floating action button in favour of persistent topbar quick-add button
## [0.4.10](https://github.com/ginnoir/famapp/compare/v0.4.9...v0.4.10) (2026-06-03)
## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03)
### Features
- **garden:** add share button to plant and container detail views ([989bae8](https://github.com/ginnoir/famapp/commit/989bae8f1e2d5577da3e8777b6a35320c6da274c))
- share links visible on plants/containers; container image gallery ([c6a34f7](https://github.com/ginnoir/famapp/commit/c6a34f74717505e39ac4a88077728efa9fc50478))
### Bug Fixes
- **deploy:** separate reactive resume auth_secret from famapp ([aae3e30](https://github.com/ginnoir/famapp/commit/aae3e30843f54406244b637073aaf5e9ccc6b1d3))
### Documentation
- add contributing guide, env reference, and operations runbook ([076e35a](https://github.com/ginnoir/famapp/commit/076e35aced9303973da71ddd0ebded13f6c98833))
- add professional readme with full feature coverage and release pipeline hook ([6a1ec8b](https://github.com/ginnoir/famapp/commit/6a1ec8bb7be7ce64e82e5a4cee6b9f4ef92c7af8))
- update decisions index and task index for garden module and adr backfill ([8bf00c3](https://github.com/ginnoir/famapp/commit/8bf00c326db591851bdf8eee2fcedbfbed53d076))
## [0.4.6](https://github.com/ginnoir/famapp/compare/v0.4.5...v0.4.6) (2026-06-02)
### Bug Fixes
+30 -3
View File
@@ -6,6 +6,12 @@ This file is the canonical brief. Read it at the start of every session before m
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
## Regular agent skills
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
- Use the drizzle-best-practices skill for any database schema changes, migrations, queries, or ORM usage.
---
## Goals
@@ -31,7 +37,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
| Package manager | pnpm | Fast, content-addressed store, strict deps |
| Runtime | Node 22 LTS | In `node:22-alpine` container |
| Runtime | Node 24 LTS | In `node:24-alpine` container |
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
@@ -106,7 +112,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
```yaml
# compose.yaml (sketch — final lives in /deploy)
services:
famapp: # node:22-alpine, Next.js
famapp: # node:24-alpine, Next.js
famapp-db: # postgres:16
authentik-server:
authentik-worker:
@@ -159,6 +165,27 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
---
## Infrastructure repo
Compose stack, Caddyfile, and `.env` for the whole homelab (including famapp) live at:
```
C:\Users\MattC\Documents\homelabstack
```
GitHub: `ginnoir/homelabstack` (private). This is the source of truth for all infra files — **not** `deploy/` in this repo.
When famapp development requires an infra change (new env var, new service, Caddyfile route, etc.):
1. `cd C:\Users\MattC\Documents\homelabstack` and run `sync-prod.ps1` first (pulls current prod state)
2. Edit `docker-compose.yml`, `.env`, and/or `Caddyfile` as needed
3. Run `apply-compose.ps1` (flags: `-Compose`, `-Caddy`, `-EnvFile`) to push to valhalla
4. Commit and push the homelabstack repo
The homelabstack `CLAUDE.md` is the authoritative brief for that repo — read it before editing infra files.
---
## Where things live
- **This brief:** `CLAUDE.md`
@@ -166,5 +193,5 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
- **Architecture decisions worth preserving:** `docs/decisions/NNNN-title.md` (lightweight ADR — only when a non-obvious choice is made)
- **App code:** `src/`
- **Drizzle migrations:** `drizzle/`
- **Deploy:** `deploy/` (compose.yaml, Caddyfile snippet, Authentik bootstrap)
- **Infra (compose/Caddyfile/.env):** `C:\Users\MattC\Documents\homelabstack`
- **Memory (cross-session):** `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`
+2 -2
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# ── Stage 1: fetch dependencies ───────────────────────────────────────────────
FROM node:22-alpine AS deps
FROM node:24-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml .npmrc ./
@@ -20,7 +20,7 @@ RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
pnpm build
# ── Stage 3: production runtime ───────────────────────────────────────────────
FROM node:22-alpine AS runner
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
+155 -5
View File
@@ -1,8 +1,158 @@
# famapp
Self-hosted family coordination: shared calendar, lists, notes.
Self-hosted family coordination web app. Shared calendar, lists, notes, and garden tracker — installable as a PWA, protected by OIDC single sign-on.
- **Plan & architecture:** [`CLAUDE.md`](CLAUDE.md)
- **Current progress:** [`STATUS.md`](STATUS.md)
- **Task briefs (for sub-sessions):** [`docs/tasks/`](docs/tasks/)
- **Architecture decisions:** [`docs/decisions/`](docs/decisions/)
[![CI](https://github.com/ginnoir/famapp/actions/workflows/ci.yml/badge.svg)](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
[![Latest release](https://img.shields.io/github/v/release/ginnoir/famapp?sort=semver)](https://github.com/ginnoir/famapp/releases)
[![Docker image](https://img.shields.io/badge/image-ghcr.io%2Fginnoir%2Ffamapp-blue?logo=docker)](https://github.com/ginnoir/famapp/pkgs/container/famapp)
[![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen?logo=node.js)](https://nodejs.org)
---
## Features
### Modules
| Module | What it does |
| ------------ | ------------------------------------------------------------------------------------------ |
| **Calendar** | Month / week / day views, drag-to-create, household and private calendars, event reminders |
| **Lists** | Shopping and task lists with real-time sync, reorder, keyboard-first item entry |
| **Notes** | Markdown notes with pinning, safe preview, and optional remind-at scheduling |
| **Garden** | Plant and container tracking, care schedules, care logs, species lookup via OpenPlantBook |
### Platform capabilities
| Capability | Detail |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| **Dashboard** | Per-user named dashboards, drag-resize widget grid, configurable widget instances |
| **Quick-add** | `Cmd/Ctrl+K` command palette and `+` FAB — every module registers its own actions |
| **Share links** | Any entity produces a temporary public link (`/s/<token>`), scoped read or read-write |
| **Activity log** | Household-scoped feed of every create / update / delete across all modules |
| **Reminders** | Generic reminder engine ticked every 30 s; notes and calendar events both use it |
| **Push notifications** | VAPID web push + in-app inbox + optional ntfy fallback |
| **Themes** | Two palettes (`default` / `warm`) × light / dark / system, zero flash on load |
| **PWA / offline** | Installable on iOS and Android, offline shell, stale-while-revalidate caching |
| **Structured logging** | JSON logs via pino in production, pretty-printed in dev, `LOG_LEVEL` configurable |
| **Nightly backups** | `pg_dump` cron for both databases, 14-day daily / 8-week weekly / 6-month monthly retention |
---
## Requirements
- **Docker** with the Compose plugin (v2)
- **Caddy** (or any reverse proxy that handles HTTPS)
- **Authentik** — famapp delegates all authentication to an Authentik OIDC provider; see the [bootstrap guide](deploy/authentik/README.md)
- A domain with DNS pointing to your host
---
## Quick start
Full details are in [`deploy/README.md`](deploy/README.md). The short version:
```bash
# 1. Clone and enter the repo
git clone https://github.com/ginnoir/famapp.git /srv/famapp
cd /srv/famapp
# 2. Create your environment file
cp .env.production.example deploy/.env
# Edit deploy/.env — see Configuration below for required values
# 3. Configure your reverse proxy
# Add deploy/Caddyfile.snippet to your Caddyfile, then reload Caddy
# 4. Bootstrap Authentik and record the OIDC client id/secret in deploy/.env
# See deploy/authentik/README.md
# 5. Start the stack
docker compose -f deploy/compose.example.yaml up -d
```
The container runs database migrations automatically on start. The first user to sign in becomes the household owner; a second sign-in joins the same household as a member.
Pin `FAMAPP_IMAGE` in `deploy/.env` after the first deploy:
```
FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.4.7
```
---
## Development setup
Requires Node ≥ 22 and pnpm 10.
```bash
git clone https://github.com/ginnoir/famapp.git
cd famapp
pnpm install
# Copy the example env and fill in dev values (set ENABLE_DEV_LOGIN=true)
cp .env.example .env
# Start the local database, run migrations, seed, and launch the dev server
pnpm dev:local
```
Then open `http://localhost:3000/login` and click **Dev login**.
Additional dev commands:
| Command | Purpose |
| --------------------- | ---------------------------------------------- |
| `pnpm dev:reset` | Drop and reseed the local database |
| `pnpm db:studio` | Open Drizzle Studio against the local database |
| `pnpm db:generate` | Generate a new migration after schema changes |
| `pnpm typecheck` | Run `tsc --noEmit` |
| `pnpm lint` | ESLint |
| `pnpm test:e2e` | Playwright E2E suite |
| `pnpm vapid:generate` | Print VAPID key env vars to stdout |
| `pnpm gen:icons` | Regenerate PWA icons from `public/icon.svg` |
See [`docs/dev-login.md`](docs/dev-login.md) for push notification and E2E testing setup.
---
## Configuration
Copy `.env.production.example` to `deploy/.env` and fill in values. Key variables:
| Variable | Required | How to generate |
| -------------------------------- | -------- | -------------------------------------------------------------------- |
| `AUTH_URL` | Yes | Public HTTPS URL for the app (e.g. `https://fam.yourdomain.com`) |
| `AUTH_SECRET` | Yes | `openssl rand -base64 32` |
| `AUTH_OIDC_ISSUER` | Yes | From Authentik — `https://auth.yourdomain.com/application/o/famapp/` |
| `AUTH_OIDC_CLIENT_ID` | Yes | From Authentik application |
| `AUTH_OIDC_CLIENT_SECRET` | Yes | From Authentik application |
| `FAMAPP_DB_PASSWORD` | Yes | Strong random password |
| `AUTHENTIK_DB_PASSWORD` | Yes | Strong random password |
| `AUTHENTIK_SECRET_KEY` | Yes | `openssl rand -base64 60` |
| `VAPID_PUBLIC_KEY` | Push | `pnpm vapid:generate` |
| `VAPID_PRIVATE_KEY` | Push | `pnpm vapid:generate` |
| `VAPID_SUBJECT` | Push | `mailto:your-email@example.com` |
| `NTFY_URL` / `NTFY_TOPIC` | Optional | ntfy push fallback |
| `famapp_OPENPLANTBOOK_CLIENT_ID` | Optional | OpenPlantBook API — plant species lookup |
| `LOG_LEVEL` | Optional | `error` / `warn` / `info` / `debug` (default: `info`) |
---
## Architecture
famapp uses an extensibility-first module system: every feature lives under `src/modules/<name>/` and declares its tables, routes, dashboard widgets, quick-add actions, and share/reminder/search behaviors via a manifest. Core services (sharing, push, reminders, activity log, search) operate generically — adding a new module does not require touching core code.
See [`CLAUDE.md`](CLAUDE.md) for the full architecture brief and [`docs/decisions/`](docs/decisions/) for ADRs.
---
## Deployment
| Resource | Link |
| -------------------- | -------------------------------------------------------------------------------- |
| Full host setup | [`deploy/README.md`](deploy/README.md) |
| Authentik bootstrap | [`deploy/authentik/README.md`](deploy/authentik/README.md) |
| Backup / restore | [`deploy/backups/README.md`](deploy/backups/README.md) |
| Pre-deploy checklist | [`docs/tasks/09-pre-deploy-checklist.md`](docs/tasks/09-pre-deploy-checklist.md) |
| Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
Cutting a release: tag `vX.Y.Z` on `main` and push — Gitea Actions builds and pushes `registry.ginnoir.com/ginnoir/famapp:vX.Y.Z` automatically.
+7 -6
View File
@@ -1,10 +1,11 @@
# famapp — paste into your existing Caddyfile.
# famapp runs on 3010, authentik-server on 9200 (both bound to the host by deploy/compose.yaml).
# Add these blocks to your existing Caddyfile.
# Assumes famapp and authentik-server are reachable by Caddy
# (e.g. on a shared Docker network).
fam.ginnoir.com {
reverse_proxy 192.168.1.69:3010
fam.yourdomain.com {
reverse_proxy famapp:3000
}
auth.ginnoir.com {
reverse_proxy 192.168.1.69:9200
auth.yourdomain.com {
reverse_proxy authentik-server:9000
}
+20 -6
View File
@@ -2,16 +2,30 @@
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.
## Files in this directory
| File | Purpose |
| ----------------------- | -------------------------------------------------------------------------- |
| `compose.example.yaml` | **Start here.** Standalone famapp + Authentik stack for new deployments. |
| `compose.yaml` | Maintainer's production compose (full homelab monolith — not a template). |
| `Caddyfile.snippet` | Reverse proxy blocks to add to your Caddyfile. |
| `Caddyfile.dev.snippet` | Dev machine proxy block (maintainer-specific). |
| `Caddyfile` | Maintainer's full production Caddyfile (not a template). |
| `authentik/README.md` | Authentik bootstrap guide. |
| `backups/` | Backup container scripts (used by `famapp-backup` in the example compose). |
## 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.
3. Copy `deploy/compose.example.yaml``/srv/famapp/deploy/compose.yaml`.
4. 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`.
- `openssl rand -hex 64` for the MinIO passwords.
- `pnpm vapid:generate` (locally, from the repo) for the three VAPID lines.
5. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
6. Wire Caddy (or any reverse proxy) using `deploy/Caddyfile.snippet`.
## Cutting a release
@@ -21,14 +35,14 @@ 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.
`.gitea/workflows/release.yml` builds + pushes `registry.ginnoir.com/ginnoir/famapp:v0.1.0`, `:0.1`, and `:latest` to the self-hosted registry.
## 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
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.1.0|' .env
docker compose pull famapp
docker compose up -d famapp
docker compose logs -f famapp # watch migrations + boot
+7 -7
View File
@@ -6,13 +6,13 @@ Run these steps once after the first `docker compose up -d` in the `deploy/` dir
## 1. Set the admin password
Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin** password.
Visit `https://auth.yourdomain.com/if/flow/initial-setup/` and set the **akadmin** password.
---
## 2. Create the OIDC provider
1. Log in to the Authentik Admin UI at `https://auth.ginnoir.com/if/admin/`.
1. Log in to the Authentik Admin UI at `https://auth.yourdomain.com/if/admin/`.
2. Go to **Applications → Providers → Create**.
3. Choose **OAuth2/OpenID Provider**.
4. Configure:
@@ -21,7 +21,7 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
- **Client type:** `Confidential`
- **Client ID:** (auto-generated — copy this)
- **Client Secret:** (auto-generated — copy this)
- **Redirect URIs:** `https://fam.ginnoir.com/api/auth/callback/authentik`
- **Redirect URIs:** `https://fam.yourdomain.com/api/auth/callback/authentik`
- **Signing Key:** `authentik Self-signed Certificate`
- **Token validity:** 24 hours (or your preference)
5. Save and note the **Issuer URL** shown on the provider detail page.
@@ -29,13 +29,13 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
The issuer URL will look like:
```
https://auth.ginnoir.com/application/o/famapp/
https://auth.yourdomain.com/application/o/famapp/
```
Set this (and the client ID/secret) in famapp's `.env` / production secrets:
```env
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=<client-id>
AUTH_OIDC_CLIENT_SECRET=<client-secret>
```
@@ -49,7 +49,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
- **Name:** `famapp`
- **Slug:** `famapp`
- **Provider:** select the `famapp` provider created above
- **Launch URL:** `https://fam.ginnoir.com`
- **Launch URL:** `https://fam.yourdomain.com`
3. Save.
---
@@ -68,7 +68,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
Each user can enroll a passkey from their Authentik profile:
1. Sign in as the user at `https://auth.ginnoir.com`.
1. Sign in as the user at `https://auth.yourdomain.com`.
2. Go to **Settings → MFA Devices → Add → WebAuthn Device**.
3. Follow the browser prompt to register a Touch ID / Face ID / hardware key.
+196
View File
@@ -0,0 +1,196 @@
# Standalone famapp deployment template.
# Copy this to compose.yaml (or reference with -f), fill in .env from
# .env.production.example, then: docker compose up -d
#
# Caddy (or any reverse proxy) should sit in front — see Caddyfile.snippet.
# If Caddy runs in a separate compose stack, add famapp to that stack's
# external network instead of exposing ports directly.
name: famapp
networks:
famapp_net:
volumes:
famapp_db_data:
authentik_db_data:
authentik_redis_data:
garden_uploads:
backups:
services:
famapp:
image: ${FAMAPP_IMAGE:-registry.ginnoir.com/ginnoir/famapp:latest}
pull_policy: ${FAMAPP_PULL_POLICY:-always}
restart: unless-stopped
environment:
NODE_ENV: production
AUTH_URL: ${AUTH_URL}
DATABASE_URL: postgres://${FAMAPP_DB_USER}:${FAMAPP_DB_PASSWORD}@famapp-db:5432/${FAMAPP_DB_NAME}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER}
AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID}
AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
NTFY_URL: ${NTFY_URL:-}
NTFY_TOPIC: ${NTFY_TOPIC:-}
LOG_LEVEL: ${LOG_LEVEL:-info}
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
MINIO_ENDPOINT: http://famapp-minio:9000
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
MINIO_BUCKET: ${famapp_MINIO_BUCKET:-garden}
OPENPLANTBOOK_CLIENT_ID: ${famapp_OPENPLANTBOOK_CLIENT_ID:-}
OPENPLANTBOOK_CLIENT_SECRET: ${famapp_OPENPLANTBOOK_CLIENT_SECRET:-}
ports:
- "${FAMAPP_PORT:-3000}:3000"
depends_on:
famapp-db:
condition: service_healthy
famapp-minio:
condition: service_healthy
networks:
- famapp_net
famapp-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${FAMAPP_DB_USER}
POSTGRES_PASSWORD: ${FAMAPP_DB_PASSWORD}
POSTGRES_DB: ${FAMAPP_DB_NAME}
volumes:
- famapp_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${FAMAPP_DB_USER} -d ${FAMAPP_DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- famapp_net
famapp-minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
volumes:
- garden_uploads:/data
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
interval: 30s
timeout: 20s
retries: 3
start_period: 30s
networks:
- famapp_net
famapp-backup:
image: alpine:3.20
restart: unless-stopped
environment:
FAMAPP_DB_HOST: famapp-db
FAMAPP_DB_PORT: "5432"
FAMAPP_DB_USER: ${FAMAPP_DB_USER}
FAMAPP_DB_PASSWORD: ${FAMAPP_DB_PASSWORD}
FAMAPP_DB_NAME: ${FAMAPP_DB_NAME}
AUTHENTIK_DB_HOST: authentik-db
AUTHENTIK_DB_PORT: "5432"
AUTHENTIK_DB_USER: ${AUTHENTIK_DB_USER:-authentik}
AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_DB_NAME: ${AUTHENTIK_DB_NAME:-authentik}
volumes:
- backups:/backups
- ./backups:/scripts:ro
depends_on:
famapp-db:
condition: service_healthy
authentik-db:
condition: service_healthy
networks:
- famapp_net
entrypoint: ["sh", "/scripts/entrypoint.sh"]
authentik-server:
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
ports:
- "9200:9000"
depends_on:
authentik-db:
condition: service_healthy
authentik-redis:
condition: service_healthy
networks:
- famapp_net
authentik-worker:
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
depends_on:
authentik-db:
condition: service_healthy
authentik-redis:
condition: service_healthy
networks:
- famapp_net
authentik-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${AUTHENTIK_DB_USER:-authentik}
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
POSTGRES_DB: ${AUTHENTIK_DB_NAME:-authentik}
volumes:
- authentik_db_data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${AUTHENTIK_DB_USER:-authentik} -d ${AUTHENTIK_DB_NAME:-authentik}",
]
interval: 10s
timeout: 5s
retries: 5
networks:
- famapp_net
authentik-redis:
image: redis:7-alpine
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- authentik_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- famapp_net
-11
View File
@@ -1,11 +0,0 @@
#!/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
+158
View File
@@ -0,0 +1,158 @@
# Contributing
## Prerequisites
- **Node.js** 24 LTS (`node --version` should print `v24.x.x`)
- **pnpm** 10 (`pnpm --version` should print `10.x.x`)
- **Docker** with the Compose plugin (used for the local Postgres + MinIO containers)
## Setup
```bash
git clone https://github.com/ginnoir/famapp.git
cd famapp
pnpm install
cp .env.example .env
# Edit .env — minimum required for local dev:
# NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000
# DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
# ENABLE_DEV_LOGIN=true
# AUTH_SECRET=<any-32-char-string-for-local>
```
Start everything (database + migrations + seed + dev server):
```bash
pnpm dev:local
```
Then open `http://localhost:3000/login` and click **Dev login**. See `docs/dev-login.md` for the full local setup including HTTPS/PWA testing.
## Available scripts
<!-- AUTO-GENERATED from package.json scripts -->
| Command | Description |
| --------------------- | ----------------------------------------------------------------------------------------- |
| `pnpm dev` | Next.js dev server (localhost only) |
| `pnpm dev:network` | Dev server bound to `0.0.0.0` (LAN access for phone testing) |
| `pnpm dev:local` | Full local stack — starts DB container, runs migrations, seeds, launches dev server |
| `pnpm dev:reset` | Tear down local DB and start fresh (destructive — deletes all local data) |
| `pnpm build` | Production Next.js build |
| `pnpm start` | Start the production build locally |
| `pnpm lint` | ESLint check |
| `pnpm lint:fix` | ESLint with auto-fix |
| `pnpm format` | Prettier — format all files |
| `pnpm format:check` | Prettier — check only (used in CI) |
| `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) |
| `pnpm test:e2e` | Playwright end-to-end tests |
| `pnpm db:generate` | Generate a new Drizzle migration from schema changes |
| `pnpm db:migrate` | Apply pending Drizzle migrations |
| `pnpm db:seed` | Seed the database with dev fixtures |
| `pnpm db:studio` | Open Drizzle Studio (local DB browser) |
| `pnpm gen:icons` | Regenerate PWA icon set from source |
| `pnpm vapid:generate` | Generate VAPID key pair for Web Push |
| `pnpm release` | Interactive release (prompts for semver bump, tags, publishes changelog + GitHub Release) |
| `pnpm release:patch` | Non-interactive patch release |
| `pnpm release:minor` | Non-interactive minor release |
| `pnpm release:major` | Non-interactive major release |
| `pnpm release:dry` | Dry-run release — preview without writing |
<!-- END AUTO-GENERATED -->
## Running tests
### Type check + lint (CI equivalent)
```bash
pnpm typecheck
pnpm lint
pnpm format:check
```
### End-to-end tests
The app must be running first (`pnpm dev:local`). Generate a Playwright auth state file, then run tests:
```powershell
# Generate auth state (run once after starting the app)
New-Item -ItemType Directory -Force tests\.auth | Out-Null
@'
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://127.0.0.1:3000/login');
await page.getByRole('button', { name: 'Dev login' }).click();
await page.waitForURL('http://127.0.0.1:3000/');
await page.context().storageState({ path: 'tests/.auth/dev-user.json' });
await browser.close();
})();
'@ | node -
# Run tests
$env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json'
pnpm test:e2e
```
### Writing tests
- Unit tests: Vitest under `tests/unit/` — only where it pays off (utilities, pure logic).
- E2E tests: Playwright under `tests/e2e/` — one happy-path test per module. Do not write brittle selector-heavy tests for trivial CRUD.
## Code style
- **TypeScript strict** — no `any` without a written reason.
- **No comments** unless the _why_ is non-obvious. Names carry intent.
- **Immutable** — always return new objects; never mutate in place.
- **Module isolation** — a module imports from `_core` and `lib/` only; never from a sibling module.
- **File size** — 200400 lines typical, 800 hard cap.
Formatting and lint run automatically on staged files via `lint-staged` at commit time. You can also run them manually with `pnpm lint:fix` and `pnpm format`.
## Commit format
Commits must follow [Conventional Commits](https://www.conventionalcommits.org/). `commitlint` enforces this at the `commit-msg` hook.
```
<type>: <short description>
[optional body]
```
Allowed types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
Examples:
```
feat: add plant species search to garden module
fix: calendar event end time off by one day
chore: bump next to 15.6
```
## Adding a module
Every feature lives under `src/modules/<name>/`. A new module needs:
- `schema.ts` — Drizzle tables
- `server/` — server actions and queries
- `components/` — React components
- `manifest.ts` — registers the module with the core registry (nav, entity types, dashboard widget, quick-add actions)
See `CLAUDE.md` for the full architectural brief. The module loader in `src/modules/_core/` discovers manifests automatically — no changes to core code required for a new module.
## Schema changes
1. Edit the relevant `schema.ts`.
2. Run `pnpm db:generate` to create a new migration file under `drizzle/`.
3. Commit the migration alongside the schema change.
4. Never edit a shipped migration — always add a new one.
## PR checklist
- [ ] `pnpm typecheck` passes
- [ ] `pnpm lint` passes
- [ ] `pnpm build` succeeds
- [ ] New Drizzle migration committed if schema changed
- [ ] E2E test added or updated if a user-visible flow changed
- [ ] `docs/tasks/09-pre-deploy-checklist.md` reviewed if touching auth or env
+112
View File
@@ -0,0 +1,112 @@
# Environment Variables
<!-- AUTO-GENERATED from .env.example and deploy/compose.example.yaml -->
## Quick start
```bash
cp .env.example .env
# Fill in required values, then:
pnpm dev:local
```
## Core
| Variable | Required | Description | Example / Default |
| --------------------- | -------- | ------------------------------------------------------------------ | ------------------------------------------------ |
| `NEXT_PUBLIC_APP_URL` | Yes | Public URL of the app — used in OIDC redirect URIs and share links | `https://fam.yourdomain.com` |
| `DATABASE_URL` | Yes | Postgres connection string | `postgres://famapp:famapp@localhost:5432/famapp` |
| `AUTH_SECRET` | Yes | Auth.js signing secret — generate with `openssl rand -base64 32` | — |
## OIDC (Authentik)
Required in production. Obtain from the Authentik admin panel after bootstrapping the provider — see `deploy/authentik/README.md`.
| Variable | Required | Description | Example |
| ------------------------- | ---------- | --------------------------------- | --------------------------------------------------- |
| `AUTH_OIDC_ISSUER` | Yes (prod) | Authentik OIDC issuer URL | `https://auth.yourdomain.com/application/o/famapp/` |
| `AUTH_OIDC_CLIENT_ID` | Yes (prod) | OIDC client ID from Authentik | — |
| `AUTH_OIDC_CLIENT_SECRET` | Yes (prod) | OIDC client secret from Authentik | — |
## Dev login
Local development only. Never set `ENABLE_DEV_LOGIN=true` in production — a startup assertion in `src/lib/dev-login-config.ts` will crash the container if you do.
| Variable | Required | Description | Default |
| -------------------- | -------- | ------------------------------------------------- | ------------------ |
| `ENABLE_DEV_LOGIN` | No | Show a one-click **Dev login** button on `/login` | `false` |
| `DEV_LOGIN_EMAIL` | No | Email for the auto-created dev session | `dev@famapp.local` |
| `DEV_LOGIN_NAME` | No | Display name for the dev user | `Dev User` |
| `DEV_HOUSEHOLD_NAME` | No | Household created for the dev user | `Home` |
See `docs/dev-login.md` for the full local run procedure.
## Web Push (VAPID)
All three are required together. Generate them once with `pnpm vapid:generate` and copy all three lines into `.env`.
| Variable | Required | Description |
| ------------------- | -------- | --------------------------------------------- | ------------------------ |
| `VAPID_PUBLIC_KEY` | Yes | VAPID public key — also passed to the browser |
| `VAPID_PRIVATE_KEY` | Yes | VAPID private key — server only |
| `VAPID_SUBJECT` | Yes | Contact URI for push servers | `mailto:you@example.com` |
## ntfy (optional)
Leave both blank to disable the ntfy notification channel. Web Push is the primary channel.
| Variable | Required | Description | Example |
| ------------ | -------- | ------------------------ | ----------------- |
| `NTFY_URL` | No | ntfy server base URL | `https://ntfy.sh` |
| `NTFY_TOPIC` | No | ntfy topic to publish to | `famapp-alerts` |
## Logging
| Variable | Required | Description | Values |
| ----------- | -------- | ------------------ | ----------------------------------------------------------- |
| `LOG_LEVEL` | No | Pino log verbosity | `trace`, `debug`, `info`, `warn`, `error` (default: `info`) |
## MinIO object storage
Required when the garden module is enabled. The dev compose stack starts a local MinIO instance automatically via `pnpm dev:local`.
| Variable | Required | Description | Default |
| --------------------- | -------- | ------------------------------------ | ----------------------- |
| `MINIO_ENDPOINT` | Yes | MinIO server base URL | `http://localhost:9000` |
| `MINIO_ROOT_USER` | Yes | MinIO root access key | `famapp` |
| `MINIO_ROOT_PASSWORD` | Yes | MinIO root secret key | `changeme` |
| `MINIO_BUCKET` | No | Bucket used for garden image uploads | `garden` |
## OpenPlantBook
Optional. Enables plant species lookup in the garden module. Free account at <https://open.plantbook.io>.
| Variable | Required | Description |
| ----------------------------- | -------- | -------------------- |
| `OPENPLANTBOOK_CLIENT_ID` | No | OAuth2 client ID |
| `OPENPLANTBOOK_CLIENT_SECRET` | No | OAuth2 client secret |
## Release tooling
Only needed on the machine that cuts releases (`pnpm release`).
| Variable | Required | Description |
| -------------- | ------------- | ------------------------------------------------ |
| `GITHUB_TOKEN` | Yes (release) | Personal access token — creates a GitHub Release |
## Production-only compose variables
Used by `deploy/compose.example.yaml`. Set in `deploy/.env` on the server — not in the local `.env`.
| Variable | Description |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `FAMAPP_IMAGE` | Docker image tag to deploy (e.g. `registry.ginnoir.com/ginnoir/famapp:v0.4.7`) |
| `FAMAPP_PORT` | Host port to bind (default: `3000`) |
| `FAMAPP_PULL_POLICY` | Docker pull policy (default: `always`) |
| `FAMAPP_DB_USER` / `FAMAPP_DB_PASSWORD` / `FAMAPP_DB_NAME` | Postgres credentials for the famapp database |
| `AUTHENTIK_DB_USER` / `AUTHENTIK_DB_PASSWORD` / `AUTHENTIK_DB_NAME` | Postgres credentials for the Authentik database |
| `AUTHENTIK_SECRET_KEY` | Authentik signing key — generate with `openssl rand -base64 60` |
| `AUTHENTIK_IMAGE_TAG` | Authentik server image tag (default: `2024.12.3`) |
| `RUN_MIGRATIONS` | Set `false` to skip auto-migration on start (default: `true`) |
<!-- END AUTO-GENERATED -->
+158
View File
@@ -0,0 +1,158 @@
# Runbook
Operational reference for famapp in production. See `deploy/README.md` for the one-time host setup and `docs/tasks/09-pre-deploy-checklist.md` for the pre-deploy checklist.
## Cutting a release
From `main` on the dev machine, with a clean working tree:
```bash
pnpm release:patch # or :minor / :major
# — bumps package.json version
# — prepends to CHANGELOG.md
# — creates and pushes a signed git tag (v0.x.y)
# — creates a GitHub Release with generated notes
# Requires GITHUB_TOKEN in .env
```
Gitea Actions (`release.yml`) then builds and pushes the Docker image to the self-hosted registry:
- `registry.ginnoir.com/ginnoir/famapp:v0.x.y`
- `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias)
- `registry.ginnoir.com/ginnoir/famapp:latest`
## Deploying a release
On the home server, in `/srv/famapp/deploy/`:
```bash
# Pin the new tag
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.x.y|' .env
# Pull and restart only the app container
docker compose pull famapp
docker compose up -d famapp
# Watch the boot log — migrations run before the server starts
docker compose logs -f famapp
```
The container entrypoint runs `node scripts/migrate.mjs` automatically. A healthy boot ends with a log line like `ready on http://0.0.0.0:3000`.
## Health check
```bash
# Container status
docker compose ps
# App response (200 = healthy)
curl -sf https://fam.yourdomain.com/ -o /dev/null -w "%{http_code}\n"
# Recent app logs
docker compose logs --tail=100 famapp
# Database connectivity
docker compose exec famapp-db pg_isready -U famapp -d famapp
```
## Rollback
1. Find the previous working tag in `CHANGELOG.md` or `docker images`.
2. Pin it in `deploy/.env`:
```bash
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.x.y|' .env
```
3. Restart the container:
```bash
docker compose up -d famapp
```
If the rollback target predates a migration that has already been applied to the database, restore from backup first — see **Backups** below. To skip auto-migration on a given start (rarely needed):
```bash
RUN_MIGRATIONS=false docker compose up -d famapp
```
## Backups
The `famapp-backup` container runs nightly `pg_dump` for both `famapp` and `authentik` databases into the `backups` volume.
```bash
# Check last backup run
docker compose logs famapp-backup | tail -20
# List backup files
docker compose exec famapp-backup ls -lh /backups
# Manual backup now
docker compose exec famapp-backup sh /scripts/backup.sh
# Restore from a backup file
docker compose exec famapp-backup sh /scripts/restore.sh famapp_2026-06-01.sql.gz
```
See `deploy/backups/README.md` for retention policy and full restore details.
## Common issues
### App container exits immediately
```bash
docker compose logs famapp
```
Likely causes:
- **Missing required env var** — the app throws on startup if `AUTH_SECRET`, `DATABASE_URL`, or `AUTH_OIDC_*` are absent.
- **`ENABLE_DEV_LOGIN=true` in production** — `src/lib/dev-login-config.ts` throws an assertion error on import. Remove the variable from the production env.
- **Database not ready** — Postgres healthcheck should prevent this, but if the DB is slow to start, increase `start_period` in `compose.yaml`.
### Migrations fail on boot
```bash
docker compose logs famapp | grep -i migration
```
- Schema is ahead of code: roll back the image or forward-migrate manually.
- Database unreachable: confirm `famapp-db` is healthy (`docker compose ps`).
### OIDC login fails
1. Confirm `AUTH_OIDC_ISSUER` matches the Authentik provider URL exactly (trailing slash matters).
2. Confirm the OIDC client redirect URI in Authentik includes `https://fam.yourdomain.com/api/auth/callback/oidc`.
3. Check Authentik logs: `docker compose logs authentik-server | tail -50`.
### Push notifications not arriving
1. Confirm `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_SUBJECT` are all set in the production env.
2. Verify the browser's push subscription is still valid (Settings → notifications → re-enable).
3. If using ntfy as a secondary channel, confirm `NTFY_URL` and `NTFY_TOPIC` are set.
### MinIO / garden image uploads failing
```bash
docker compose logs famapp-minio | tail -30
```
- Confirm `MINIO_ENDPOINT` is `http://famapp-minio:9000` in the compose env (not `localhost`).
- Confirm the `garden` bucket exists — create it manually via the MinIO console at port `9001` if it is missing.
### Out of disk space
```bash
df -h /var/lib/docker
docker system prune --volumes # removes stopped containers, dangling images, unused volumes
```
Old backup files accumulate in the `backups` volume. The retention script (`deploy/backups/retain.sh`) runs nightly — check its logs if the volume keeps growing.
## Authentik admin access
The Authentik admin UI is at `https://auth.yourdomain.com`. Log in with the superuser credentials set during bootstrap — see `deploy/authentik/README.md`.
To reset the Authentik admin password from the CLI:
```bash
docker compose exec authentik-server ak create_recovery_key 1 akadmin
# prints a one-time recovery URL
```
+5 -1
View File
@@ -22,4 +22,8 @@ What this costs us, what it buys us.
## Index
- (none yet — add as decisions are made)
<!-- AUTO-GENERATED -->
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
<!-- END AUTO-GENERATED -->
+8 -8
View File
@@ -42,11 +42,11 @@ pnpm dev:local
The script prints three URLs at startup:
| URL | Use for |
| -------------------------- | ---------------------------------------------------------- |
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
| URL | Use for |
| ---------------------------- | ---------------------------------------------------------- |
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.x.y:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.yourdomain.com` | Push notifications + PWA install (needs Caddy — see below) |
Then open `/login` and click **Dev login**.
@@ -74,11 +74,11 @@ Route through the existing Caddy server on the home server instead — no extra
**Step 1 — DHCP reservation**
Set a reservation on the router so the dev machine always gets `192.168.1.74`.
Set a reservation on the router so the dev machine always gets `192.168.x.y`.
**Step 2 — DNS record**
Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`.
Add a `dev.yourdomain.com` A record pointing to the same public IP as `fam.yourdomain.com`.
**Step 3 — Windows Firewall**
@@ -97,7 +97,7 @@ Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload:
caddy reload --config /path/to/Caddyfile
```
After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert.
After this, `https://dev.yourdomain.com` proxies to the dev machine with a real Let's Encrypt cert.
## Current Local E2E Procedure
@@ -0,0 +1,141 @@
# React and shadcn Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the security-sensitive share-link authorization gap and the highest-confidence shadcn Base UI composition issues from the audit.
**Architecture:** Add registry-level share authorization callbacks and require them in the generic share service before token creation and shared data loading. Update current modules to provide household/user-scoped authorization and share loaders, then align selected UI call sites with shadcn Base API.
**Tech Stack:** Next.js 15 App Router, TypeScript, Drizzle, shadcn/ui Base UI, Tailwind v4, Node test runner via `tsx --test`.
---
### Task 1: Share Authorization Helper
**Files:**
- Create: `tests/unit/share-authorization.test.ts`
- Create: `src/modules/_core/share-authorization.ts`
- Modify: `src/modules/_core/module.ts`
- [ ] **Step 1: Write the failing test**
Create tests for missing callbacks, denied callbacks, and allowed callbacks.
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: failure because `share-authorization.ts` does not exist.
- [ ] **Step 3: Implement the helper and registry types**
Add `ShareContext`, `ensureEntityShareAuthorized`, `EntityTypeRegistration.canShareEntity`, and scoped `loadForShare` context typing.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: all tests pass.
### Task 2: Wire Authorization Into Share Service
**Files:**
- Modify: `src/modules/_core/share.ts`
- Modify: `src/app/s/[token]/page.tsx`
- [ ] **Step 1: Call `ensureEntityShareAuthorized` in `createShareLink`**
Require the active user and household to be authorized before token insertion.
- [ ] **Step 2: Pass household context into public share loading**
Call `entityReg.loadForShare(resolved.entityId, { householdId: resolved.householdId })`.
- [ ] **Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: type errors until module adapters are updated.
### Task 3: Add Module Share Adapters
**Files:**
- Modify: `src/modules/calendar/manifest.tsx`
- Modify: `src/modules/calendar/server/share-queries.ts`
- Modify: `src/modules/lists/manifest.tsx`
- Modify: `src/modules/lists/server/share-queries.ts`
- Modify: `src/modules/notes/manifest.tsx`
- Modify: `src/modules/notes/server/share-queries.ts`
- Modify: `src/modules/garden/manifest.tsx`
- Modify: `src/modules/garden/server/share-queries.ts`
- [ ] **Step 1: Implement `canShareEntity` for every shareable entity**
Use household scoping and existing private-calendar rules.
- [ ] **Step 2: Scope `loadForShare` queries by token household**
Require the public token household to match the entity household.
- [ ] **Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
### Task 4: Fix Base UI Select Composition
**Files:**
- Modify: `src/modules/calendar/components/calendar-shell.tsx`
- Modify: `src/components/theme-picker.tsx`
- [ ] **Step 1: Add `items` arrays and `SelectGroup` wrappers**
Keep existing labels and values; do not redesign layout.
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
### Task 5: Targeted shadcn Menu and Icon Cleanup
**Files:**
- Modify: `src/components/dashboard-switcher.tsx`
- Modify: `src/components/dashboard-editor.tsx`
- Modify: `src/components/share-button.tsx`
- [ ] **Step 1: Wrap dropdown items in `DropdownMenuGroup`**
Preserve menu behavior.
- [ ] **Step 2: Replace icon sizing inside shadcn buttons/menu items**
Use `data-icon` where icons sit in `Button`; remove explicit `size-4 mr-*` from dropdown item icons.
- [ ] **Step 3: Run lint**
Run: `pnpm lint`
Expected: no new errors; existing warnings may remain.
### Task 6: Final Verification
**Files:**
- No direct edits.
- [ ] **Step 1: Run targeted test**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: pass.
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
- [ ] **Step 3: Run lint**
Run: `pnpm lint`
Expected: exit 0; warnings only if pre-existing.
@@ -0,0 +1,35 @@
# React and shadcn Fixes Design
## Goal
Fix the audit findings that have security or correctness impact, and make a small targeted pass on shadcn Base UI composition without turning this into a broad visual rewrite.
## Approaches Considered
1. **Security-first, targeted UI cleanup.** Add a registry authorization hook for share links, implement it for current modules, fix Base `Select` usage, and clean up the compact dropdown/button issues found in the audit.
2. **Full shadcn migration sweep.** Replace all `.btn`, raw forms, raw colors, and hand-rolled overlays in one pass. This would touch many dirty files and blur behavior changes with style churn.
3. **Security only.** Fix share authorization and defer UI. This leaves known Base UI composition drift in place.
Chosen approach: **Option 1**. It fixes the exploitable path, handles the highest-confidence shadcn correctness issue, and leaves broad styling convergence to the existing shadcn-tier task docs.
## Architecture
Share authorization belongs in the entity registry, not in `_core` switch statements. Each shareable entity registration will expose `canShareEntity(id, ctx)` and the generic `createShareLink` action will require it to return true before inserting a token.
Public share rendering will also pass the token household into `loadForShare(id, ctx)` so loaders can scope database reads. This prevents a bad token row from loading an unrelated entity by id alone.
## UI Composition
The project uses shadcn `base-nova`, so `Select` roots need an `items` prop and `SelectItem` children should be wrapped in `SelectGroup`. The calendar and theme picker selects will be updated to that shape.
Compact menu/button drift will be cleaned where it does not require redesign: dropdown items should sit in `DropdownMenuGroup`, menu icons should rely on component icon sizing, and button icons should use `data-icon`.
## Testing
Add a small Node test around the new share authorization helper first. The test will prove that missing or false registry authorization rejects share creation and that approved entities pass through. Typecheck and lint will cover module hook signatures and Base UI prop usage.
## Out of Scope
- Full replacement of `.btn` and raw garden forms.
- Reworking command palette and quick-add sheet into shadcn dialogs.
- Measuring bundle deltas or adding dynamic imports for global overlays.
+4
View File
@@ -2,6 +2,10 @@
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.
## README
- [ ] `README.md` reflects the current module list and any env vars added since the last release.
## Env hygiene
- [ ] Server `.env` (next to `deploy/compose.yaml`) does **not** set:
+104
View File
@@ -0,0 +1,104 @@
# Tier 1: Garden badges → Badge, topbar avatar → Avatar, + fallback, share-green → semantic token
## Goal
Replace ad-hoc badge and avatar styling across the app with shadcn/ui component primitives. Standardize color tokens for green success/warning indicators. Create a reusable avatar fallback pattern.
## Affected files (6)
### Task 1a: Garden health badges → Badge variant
**File:** `src/modules/garden/components/plant-detail.tsx`
- **Current state (line ~44-48):** `healthBadgeClass(status)` returns hand-crafted class names (`badge-success`, `badge-danger`, `badge-warning`) not from shadcn.
- **Line 119-123:** Badge rendered as a span with those classes.
- **Change:** Replace with `<Badge>` component:
- "healthy" → `variant="default"` (primary color)
- "sick" → `variant="destructive"`
- "sick/other" → `variant="secondary"`
- Delete `healthBadgeClass()` function. Remove the span and replace with Badge import + usage.
### Task 1b: Topbar avatar → `<Avatar>`
**File:** `src/components/topbar.tsx`
- **Current state (line ~60-75):** Manual `<span className="avatar">` with inline width/height/background styles, plus conditional img tag or initial text fallback.
- **Change:** Replace with shadcn `<Avatar>`:
```tsx
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
<Avatar style={{ width: 28, height: 28 }}>
{userRow?.image ? (
<AvatarImage src={userRow.image} alt="" />
) : (
<AvatarFallback>{initial}</AvatarFallback>
)}
</Avatar>;
```
- Remove the `getUserAvatar` function — already fetches name/email/image. Just pass the image directly.
### Task 1c: HouseholdPill avatars → Avatar with fallback
**File:** `src/components/sidebar.tsx`
- **Current state (line ~106-120):** Manual span-based avatar circles for household members. Uses `avatarColor()` hash function.
- **Change:** Replace inner spans with `<Avatar>`:
```tsx
<Avatar
style={{
width: 32,
height: 32,
marginLeft: i > 0 ? -6 : 0,
boxShadow: "0 0 0 1.5px var(--card)",
}}
>
{m.image ? (
<AvatarImage src={m.image} alt={m.name ?? m.email ?? ""} />
) : (
<AvatarFallback style={{ background: avatarColor(m.id) }}>{initial}</AvatarFallback>
)}
</Avatar>
```
- The `avatarColor()` function stays as-is — it produces the color variable for the fallback background.
### Task 1d: Create a generic Avatar fallback wrapper (reusable component)
**File:** `src/components/avatar-fallback.tsx` (new)
- **Purpose:** A thin wrapper that takes `(name|initial, image?)` and renders the right Avatar/AvatarImage/Fallback pattern with proper fallback initials.
- **Export:** `AvatarFallbackWithName` — accepts `{ name?: string; initial?: string; image?: string | null; size?: "sm" | "default" | "lg" }`
- Used by both topbar and sidebar avatars, removing code duplication.
### Task 1e: share-green → semantic token
**Files affected:**
1. `src/components/push-opt-in.tsx` line ~104: `text-green-600 dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
2. `src/modules/garden/components/care-schedule-editor.tsx` line ~185: green borders for "Active" button → use a semantic border class like `border-[var(--c-success)] text-[var(--c-success)]`
3. `src/modules/garden/components/plant-widget.tsx` urgency colors: ensure overdue/due-today colors use semantic CSS vars rather than hardcoded greens/red/amber
**Changes:**
- In `src/lib/themes.ts` or the global CSS root, add: `--c-success: #16a34a; dark: --c-success: #4ade80;` (or pick from existing palette)
- Replace all `text-green-600`/`dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
- Similarly for border-green-400 → `border-[var(--c-success)]`
## Acceptance criteria
1. No hardcoded badge-success/badge-danger/badge-warning class names remain in plant-detail.tsx
2. Topbar avatar renders via shadcn Avatar components (verified by DOM inspection)
3. HouseholdPill avatars use shadcn Avatar components
4. AvatarFallbackWithName component exists and is imported by both topbar.tsx and sidebar.tsx
5. All green-600/green-500 references replaced with --c-success semantic variable
6. Zero TypeScript errors, zero lint errors
## Steps
1. Edit plant-detail.tsx: replace healthBadgeClass usage with <Badge> component
2. Create src/components/avatar-fallback.tsx
3. Edit topbar.tsx: use Avatar from shadcn + AvatarFallbackWithName
4. Edit sidebar.tsx: update HouseholdPill to use Avatar components
5. Search for remaining green-600/green-500/border-green references and replace with --c-success
6. Run `pnpm lint` and `pnpm typecheck` to verify no errors
+183
View File
@@ -0,0 +1,183 @@
# Tier 2: Skeleton loading states, settings Tabs navigation, toast notifications via sonner, form FieldGroup audit, manual separators → <Separator>
## Goal
Standardize loading UX, navigation, and toast patterns across the app using shadcn components. Audit form field consistency. Replace hand-crafted separator borders with the shadcn Separator component.
---
### Task 2a: Skeleton loading states
**Files affected:**
1. `src/app/d/[slug]/page.tsx` line ~120 — inline `<Suspense fallback>` with animate-pulse divs (3 lines). Convert to `<Skeleton>` components.
2. `src/modules/garden/components/plant-detail.tsx` line 55 — `uploadingImage` state during image upload, no visual feedback other than button text. Add a skeleton overlay or shimmer on the gallery card area.
3. `src/modules/garden/components/container-detail.tsx` line 30 — same pattern: `uploadingImage` state without skeleton.
4. Any garden page with loading states that use animate-pulse instead of Skeleton.
**Changes:**
1. **d/[slug]/page.tsx:** Replace inline fallback divs:
```tsx
<Suspense fallback={
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
}>
```
2. **plant-detail.tsx / container-detail.tsx:** When `uploadingImage` is true, wrap the image area with a Skeleton overlay (absolute positioned, full-width). Or add a shimmer effect using the existing skeleton component.
---
### Task 2b: Settings Tabs navigation → `<Tabs>`
**File:** `src/app/settings/page.tsx` + `src/components/settings-section.tsx`
- **Current state:** Uses a sidebar nav (vertical list with URL hash sync). This is fine for desktop but doesn't use `<Tabs>`.
- **What needs to change:** The **garden detail pages** that have manual tab buttons need to use shadcn Tabs, NOT the settings page. The tier says "settings Tabs navigation" — checking the context: plant-detail.tsx line 164 and container-detail.tsx line 139 both have hand-crafted tab buttons with border-bottom active state. These should become `<Tabs>` + `<TabsList>` + `<TabsTrigger>` + `<TabsContent>`.
**Changes:**
1. **plant-detail.tsx (lines 162-180):** Replace manual tab buttons:
```tsx
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
<TabsTrigger value="care">Care</TabsTrigger>
</TabsList>
<TabsContent value="info">{/* existing info section */}</TabsContent>
<TabsContent value="gallery">{/* existing gallery section */}</TabsContent>
<TabsContent value="care">{/* existing care section */}</TabsContent>
</Tabs>;
```
2. **container-detail.tsx (lines 138-155):** Same pattern — info/gallery tabs:
```tsx
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<TabsContent value="info">{/* ... */}</TabsContent>
<TabsContent value="gallery">{/* ... */}</TabsContent>
</Tabs>
```
---
### Task 2c: Toast notifications via sonner
**Files affected:** Need to find all manual error/success messages and convert to toast calls.
1. **`src/app/layout.tsx`:** Add `<Toaster />` component to the app layout root so toasts are globally available.
2. **Places that need toast conversion (search for inline `setError` / success messages):**
- `src/modules/garden/components/plant-detail.tsx` — delete confirmation success/error → `toast.success()` / `toast.error()`
- `src/modules/garden/components/container-detail.tsx` — same pattern
- `src/modules/garden/components/care-schedule-editor.tsx` line 192 (`calendarSuccess`) and error states → toast
- `src/components/share-button.tsx` — "Share link created" dialog could be a toast instead (or keep as dialog but add toast on copy)
**Changes:**
```tsx
// In any component that needs toasts:
import { toast } from "sonner";
// On success:
toast.success("Event added to calendar");
// On error:
toast.error("Failed to create share link");
// In layout.tsx (client component or client wrapper):
import { Toaster } from "@/components/ui/sonner";
return <Toaster position="bottom-right" />;
```
**Acceptance for 2c:**
- `<Toaster>` is present in the app shell layout
- All delete confirmations show toasts on success/error
- The share dialog can remain as-is (it's a distinct UX), but copy action adds toast confirmation
---
### Task 2d: Form FieldGroup audit
**Scope:** Audit all form field components across the app. Check for consistency in label-input-error patterns.
**Files to audit:**
- `src/modules/garden/components/container-form.tsx` — garden container form fields
- `src/modules/garden/components/plant-form.tsx` — garden plant form fields
- `src/app/settings/household/*` — household edit forms
- Any other server/action-driven forms
**Audit checklist:**
1. Do all form inputs have `<Label>` from shadcn? (Not just plain text labels)
2. Are error messages rendered consistently below each field?
3. Are `Input` components from shadcn used everywhere? (No native `<input>` tags with manual styles)
4. Is there a FieldGroup wrapper pattern being used, or is it ad-hoc div nesting?
**Deliverable:** A list of inconsistencies found and fixes applied. If no major issues, document the findings. Do not introduce a FieldGroup abstraction unless one already exists in the codebase.
---
### Task 2e: Manual separators → `<Separator>`
**Files affected:**
1. `src/modules/garden/components/plant-detail.tsx` — border-bottom on tabs bar (`border-b border-[var(--ink-faint)]`) → use `<Separator>` instead
2. `src/modules/garden/components/container-detail.tsx` — same pattern for info/gallery tab separator
3. Any other manual `border-b`, `border-t`, or `border-l` borders used purely as visual separators (not structural layout borders)
**Changes:**
```tsx
import { Separator } from "@/components/ui/separator"
// Replace:
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{/* tab buttons */}
</div>
// With:
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<Separator className="my-2" />
// For the garden tab bar:
{tab === "gallery" && (
<Separator className="my-4" orientation="horizontal" />
)}
```
**Note:** The settings sidebar uses a `.nav-divider` class which is handled by CSS — leave that alone. Only convert inline border divs to Separator components.
---
## Acceptance criteria
1. All loading states use `<Skeleton>` (no animate-pulse patterns outside skeleton.tsx)
2. Garden detail page tabs use shadcn Tabs/TabsList/TabsTrigger/TabsContent
3. Toaster component rendered in app layout, delete/cancel actions show toasts
4. Form fields audit complete with findings documented or fixed
5. Manual border separators replaced with `<Separator>` where appropriate
## Steps
1. Add Skeleton imports and replace inline animation patterns (2a)
2. Replace garden detail page tabs with shadcn Tabs (2b)
3. Add Toaster to layout, add toast() calls to action handlers (2c)
4. Audit form fields for consistency (2d)
5. Replace border separators with Separator component (2e)
6. Run `pnpm lint` and `pnpm typecheck`
+143
View File
@@ -0,0 +1,143 @@
# Tier 3: Garden empty-state component, toggle-group for notify channels
## Goal
Create a reusable EmptyState component for garden (and potentially other modules), and replace manual checkbox patterns with shadcn ToggleGroup for notification channel selection.
---
### Task 3a: Garden empty-state component
**New file:** `src/modules/garden/components/empty-state.tsx`
**Purpose:** A shared empty-state component for the garden module — replacing ad-hoc "No plants yet." / "No containers yet." paragraphs scattered across garden list/detail pages.
**Design (base on shadcn's `<EmptyState>` from `src/components/ui/empty.tsx`):**
The empty component already exists at `src/components/ui/empty.tsx` — it provides a generic EmptyState wrapper with icon + title + description slots. We should **use it directly**, not create a garden-specific one, unless the garden needs garden-themed defaults.
**Garden-specific variants needed:**
- "No plants yet" — icon: leaf/plant, title: "No plants yet", description: "Add your first plant to get started."
- "No containers yet" — icon: box/pot, title: "No containers yet", description: "Add a container to group your plants."
**Files to update:**
1. `src/modules/garden/components/plant-list.tsx` line 22: `<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>``<EmptyState ... />`
2. `src/modules/garden/components/container-list.tsx` line 39: `"No containers yet. Add one to start organising your plants."``<EmptyState ... />`
3. `src/modules/garden/components/container-detail.tsx` line 169: `<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>` → inline EmptyState or keep as brief fallback (it's a sub-section, not a full page)
**Import:**
```tsx
import { EmptyState } from "@/components/ui/empty"
import { Sprout, Container } from "lucide-react"
// In plant-list:
<EmptyState
icon={Sprout}
title="No plants yet"
description="Add your first plant to get started."
/>
// In container-list:
<EmptyState
icon={Container}
title="No containers yet"
description="Add a container to start organising your plants."
/>
```
**Acceptance for 3a:**
- EmptyState imported from `@/components/ui/empty` in all garden list pages
- Zero ad-hoc empty text paragraphs remain in garden component files (check plant-list, container-list, and any other garden page)
- Garden empty states are visually consistent (same icon size, title weight, description color)
---
### Task 3b: Toggle-group for notify channels
**File:** `src/components/notify-channel-toggles.tsx`
**Current state:** Each channel is a native `<input type="checkbox">` wrapped in a label with custom styling. No grouping or visual cohesion.
**Change to ToggleGroup + ToggleGroupItem:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
export function NotifyChannelToggles({
push, inapp, ntfy, ntfyConfigured,
}: { ... }) {
const channels: { key: Channel; value: boolean; label: string; disabled?: boolean }[] = [
{ key: "push", value: push, label: "Web push" },
{ key: "inapp", value: inapp, label: "In-app inbox" },
{ key: "ntfy", value: ntfy, label: "ntfy", disabled: !ntfyConfigured },
];
const enabledKeys = channels.filter(c => c.value && !c.disabled).map(c => c.key);
return (
<ToggleGroup type="multiple" defaultValue={enabledKeys} onValueChange={(v) => handleToggle(v)}>
{channels.map(({ key, value, label, disabled }) => (
<ToggleGroupItem
key={key}
value={key}
disabled={disabled || !("PushManager" in window) && key === "push"}
aria-label={label}
>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
```
**Important:** The ToggleGroup needs to integrate with the existing server action flow (`setNotifChannel`). The `onValueChange` handler should:
1. Detect which channels were added/removed from the selection
2. Call `setNotifChannel(channel, enabled)` for each change
3. Still trigger an optimistic UI update
**Alternative approach** (safer — use individual ToggleGroupItems as independent switches): Since these are truly independent toggles (enabling push doesn't require disabling inapp), we keep the current per-channel rendering but swap `<input type="checkbox">` to shadcn's `<Switch>` component from `@/components/ui/switch`:
```tsx
import { Switch } from "@/components/ui/switch";
// Each channel:
<label className="flex items-center justify-between gap-4">
<span className="text-sm">{label}</span>
<Switch
checked={value}
disabled={disabled || isPending}
onCheckedChange={(v) => toggle(key, v)}
/>
</label>;
```
**Decision:** The `<Switch>` approach is more appropriate here. ToggleGroup is for mutually-exclusive selection; Switch is for independent on/off toggles — which matches the notification channels use case perfectly. **Use `Switch` from shadcn, not ToggleGroup.**
**Acceptance for 3b:**
- Each channel uses shadcn `<Switch>` component (not native checkbox)
- Visual appearance matches the app's design system
- Server action integration works correctly (toggle enables/disables on the server)
- Disabled state handled properly for ntfy when unconfigured
---
## Acceptance criteria
1. EmptyState component from `@/components/ui/empty` used in all garden list pages
2. All ad-hoc "No plants yet." / "No containers yet." text replaced with EmptyState or kept as contextual inline text where appropriate
3. Notification channels use shadcn `<Switch>` components
4. Visual consistency across garden empty states
## Steps
1. Use existing `src/components/ui/empty.tsx` EmptyState in plant-list.tsx and container-list.tsx
2. Replace Switch component imports in notify-channel-toggles.tsx
3. Verify server action integration for toggles
4. Run `pnpm lint` and `pnpm typecheck`
+114
View File
@@ -0,0 +1,114 @@
# 76 — Shadcn component implementation (Tiers 13)
## Goal
Implement the three shadcn component adoption tiers that replace ad-hoc UI patterns across the app with shadcn/ui primitives. This is the **execution** phase — components are already installed (see `2026-06-13-shadcn-component-install.md`).
| Tier | Name | Tasks | Files touched |
| ---- | ------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
| 1 | Garden badges → Badge, topbar avatar → Avatar, + fallback, semantic token | 5 sub-tasks | topbar.tsx, sidebar.tsx, plant-detail.tsx, push-opt-in.tsx, care-schedule-editor.tsx, plant-widget.tsx |
| 2 | Skeleton loading, Tabs nav, Sonner toasts, FieldGroup audit, Separator | 5 sub-tasks | d/[slug]/page.tsx, plant-detail.tsx, container-detail.tsx, layout.tsx, notify-channel-toggles.tsx |
| 3 | Garden empty-state, Switch for notify channels | 2 sub-tasks | plant-list.tsx, container-list.tsx, notify-channel-toggles.tsx |
## Depends on
- `2026-06-13-shadcn-component-install.md` — components are already in `src/components/ui/`
- The shadcn migration map at `2026-06-13-shadcn-migration.md`
## Scope by tier
### Tier 1 (detailed brief: `docs/tasks/1a-badges-avatars.md`)
**Task 1a — Garden health badges → `<Badge>`**
- File: `src/modules/garden/components/plant-detail.tsx` lines 44-48, 119-123
- Replace hand-crafted `badge-success/danger/warning` classes with shadcn `<Badge variant="default|destructive|secondary">`
- Delete the `healthBadgeClass()` helper function
**Task 1b — Topbar avatar → `<Avatar>`**
- File: `src/components/topbar.tsx` lines 60-75
- Replace manual `<span className="avatar">` with `<Avatar><AvatarImage/><AvatarFallback/></Avatar>`
**Task 1c — HouseholdPill avatars → `<Avatar>`**
- File: `src/components/sidebar.tsx` lines 106-120
- Replace inner span elements with Avatar components (keep avatarColor hash for fallback background)
**Task 1d — Generic AvatarFallbackWithName component**
- New file: `src/components/avatar-fallback.tsx`
- Thin wrapper that handles the Image/Fallback pattern with name/initial logic
- Imported by both topbar.tsx and sidebar.tsx to remove duplication
**Task 1e — share-green → semantic token**
- Files: push-opt-in.tsx (line ~104), care-schedule-editor.tsx (line ~185), plant-widget.tsx urgencyLabel, plus any other green-600/green-500 in garden components
- Add `--c-success` CSS variable to the global theme
- Replace all hardcoded green-600 references with `var(--c-success)`
### Tier 2 (detailed brief: `docs/tasks/1b-skeleton-tabs-toasts.md`)
**Task 2a — Skeleton loading states**
- d/[slug]/page.tsx inline animate-pulse → `<Skeleton>` components
- plant-detail.tsx & container-detail.tsx uploadingImage → skeleton overlay
- Any other animate-pulse patterns outside skeleton.tsx itself
**Task 2b — Settings/garden Tabs navigation → `<Tabs>`**
- File: `src/modules/garden/components/plant-detail.tsx` lines 162-180
- File: `src/modules/garden/components/container-detail.tsx` lines 138-155
- Replace manual button-based tabs with shadcn Tabs/TabsList/TabsTrigger/TabsContent
**Task 2c — Toast notifications via sonner**
- Add `<Toaster>` to app layout (`src/app/layout.tsx`)
- Convert delete confirmations, calendar schedule success/error, and share-link actions to `toast.success()`/`toast.error()` calls
**Task 2d — Form FieldGroup audit**
- Audit: container-form.tsx, plant-form.tsx, household edit forms
- Check consistency of Label + Input + error pattern
- Fix any non-shadcn form fields found
**Task 2e — Manual separators → `<Separator>`**
- Remove border-bottom on garden detail tab bars, replace with shadcn Separator (usually alongside the Tabs component)
### Tier 3 (detailed brief: `docs/tasks/1c-emptystate-switch.md`)
**Task 3a — Garden empty-state component**
- File: `src/modules/garden/components/plant-list.tsx` line 22 → use EmptyState from ui/empty.tsx
- File: `src/modules/garden/components/container-list.tsx` line 39 → use EmptyState
- Use appropriate icons (Sprout for plants, Container for containers)
**Task 3b — Notify channel toggles → `<Switch>`**
- File: `src/components/notify-channel-toggles.tsx`
- Replace native `<input type="checkbox">` with shadcn `<Switch>` component
- Keep server action integration (setNotifChannel)
- Disabled state for ntfy when unconfigured
## Acceptance criteria (all tiers)
1. Zero `badge-success`, `badge-danger`, `badge-warning` class names remain in garden code
2. All avatars rendered via shadcn Avatar components (verified by DOM/JSX inspection)
3. All loading states use `<Skeleton>` — no animate-pulse patterns outside skeleton.tsx
4. Garden detail pages use shadcn Tabs component (not manual buttons)
5. Toaster rendered in app layout, delete/cancel actions show toasts
6. EmptyState used in all garden list pages (no ad-hoc empty text paragraphs)
7. Notification channels use shadcn Switch components
8. `pnpm lint` passes with zero errors/warnings
9. `pnpm typecheck` passes with zero errors
## Execution order
Execute tiers sequentially: 1 → 2 → 3. Each tier builds on the previous one's patterns and fixes foundational issues before adding polish.
Detailed task briefs are in:
- Tier 1: `docs/tasks/1a-badges-avatars.md`
- Tier 2: `docs/tasks/1b-skeleton-tabs-toasts.md`
- Tier 3: `docs/tasks/1c-emptystate-switch.md`
+10
View File
@@ -33,6 +33,7 @@ Every task file has these sections:
- [06 — Authentik install + OIDC integration](06-authentik-oidc.md)
- [07 — Household seeding & session](07-household-seed.md)
- [08 — Theming infrastructure (multi-theme + dark mode)](08-theming.md)
- [09 — Pre-deploy checklist (recurring)](09-pre-deploy-checklist.md)
### Phase 2 — Core modules
@@ -69,3 +70,12 @@ Every task file has these sections:
- [60 — Postgres backups (pg_dump cron)](60-backups.md)
- [61 — Rate limiting on share links](61-rate-limit.md)
- [62 — Structured logging](62-logging.md)
### Phase 8 — Garden module
- [70 — Garden infrastructure (MinIO + upload route + schema)](70-garden-infrastructure.md)
- [71 — Garden containers](71-garden-containers.md)
- [72 — Garden plants](72-garden-plants.md)
- [73 — Garden care tracking](73-garden-care-tracking.md)
- [74 — Garden integrations](74-garden-integrations.md)
- [75 — Garden dashboard](75-garden-dashboard.md)
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "garden_containers" ADD COLUMN "images" jsonb DEFAULT '[]'::jsonb NOT NULL;
+7
View File
@@ -127,6 +127,13 @@
"when": 1780391596552,
"tag": "0017_bang_counter",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1748995200000,
"tag": "0018_container_images",
"breakpoints": true
}
]
}
+11 -4
View File
@@ -1,11 +1,11 @@
{
"name": "famapp",
"version": "0.4.6",
"version": "0.5.3",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
"engines": {
"node": ">=22"
"node": ">=24"
},
"scripts": {
"dev": "next dev",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx --env-file=.env scripts/seed.ts",
"db:seed": "node --env-file=.env scripts/seed.mjs",
"db:studio": "drizzle-kit studio",
"gen:icons": "node scripts/generate-icons.mjs",
"vapid:generate": "node scripts/vapid-generate.mjs",
@@ -53,7 +53,7 @@
"@release-it/conventional-changelog": "^11.0.1",
"@tailwindcss/postcss": "^4.2.4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.9.0",
"@types/node": "^24.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/web-push": "^3.6.4",
@@ -67,17 +67,20 @@
"pino-pretty": "^13.1.3",
"prettier": "^3.3.3",
"release-it": "^20.2.0",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.4",
"tsx": "^4.19.4",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0"
},
"dependencies": {
"@auth/core": "0.41.2",
"@auth/drizzle-adapter": "^1.11.2",
"@base-ui/react": "^1.4.1",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"canvas-confetti": "^1.9.4",
@@ -89,12 +92,16 @@
"minio": "^8.0.7",
"next": "^15.5.15",
"next-auth": "5.0.0-beta.31",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"postgres": "^3.4.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3",
"react-resizable": "^3.1.3",
"server-only": "^0.0.1",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
+152 -97
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@auth/core':
specifier: 0.41.2
version: 0.41.2
'@auth/drizzle-adapter':
specifier: ^1.11.2
version: 1.11.2
@@ -23,6 +26,9 @@ importers:
'@fullcalendar/interaction':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/list':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/react':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -56,6 +62,9 @@ importers:
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31(next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
pino:
specifier: ^10.3.1
version: 10.3.1
@@ -71,9 +80,18 @@ importers:
react-grid-layout:
specifier: ^2.2.3
version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-resizable:
specifier: ^3.1.3
version: 3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
server-only:
specifier: ^0.0.1
version: 0.0.1
shadcn:
specifier: ^4.7.0
version: 4.7.0(@types/node@22.19.17)(typescript@5.9.3)
version: 4.7.0(@types/node@24.12.4)(typescript@5.9.3)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -89,7 +107,7 @@ importers:
devDependencies:
'@commitlint/cli':
specifier: ^21.0.2
version: 21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
version: 21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
'@commitlint/config-conventional':
specifier: ^21.0.2
version: 21.0.2
@@ -101,7 +119,7 @@ importers:
version: 1.59.1
'@release-it/conventional-changelog':
specifier: ^11.0.1
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))
'@tailwindcss/postcss':
specifier: ^4.2.4
version: 4.2.4
@@ -109,8 +127,8 @@ importers:
specifier: ^1.9.0
version: 1.9.0
'@types/node':
specifier: ^22.9.0
version: 22.19.17
specifier: ^24.0.0
version: 24.12.4
'@types/react':
specifier: ^19.2.14
version: 19.2.14
@@ -149,7 +167,10 @@ importers:
version: 3.8.3
release-it:
specifier: ^20.2.0
version: 20.2.0(@types/node@22.19.17)
version: 20.2.0(@types/node@24.12.4)
sharp:
specifier: ^0.34.5
version: 0.34.5
tailwindcss:
specifier: ^4.2.4
version: 4.2.4
@@ -976,6 +997,11 @@ packages:
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/list@6.1.20':
resolution: {integrity: sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/react@6.1.20':
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
peerDependencies:
@@ -1862,8 +1888,8 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@24.12.4':
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -3943,6 +3969,12 @@ packages:
nodemailer:
optional: true
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.5.15:
resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -4513,6 +4545,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
@@ -4596,6 +4631,12 @@ packages:
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -4895,8 +4936,8 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.24.5:
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
@@ -5347,11 +5388,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@commitlint/cli@21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
'@commitlint/cli@21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
dependencies:
'@commitlint/format': 21.0.1
'@commitlint/lint': 21.0.2
'@commitlint/load': 21.0.2(@types/node@22.19.17)(typescript@5.9.3)
'@commitlint/load': 21.0.2(@types/node@24.12.4)(typescript@5.9.3)
'@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
'@commitlint/types': 21.0.1
tinyexec: 1.2.4
@@ -5396,14 +5437,14 @@ snapshots:
'@commitlint/rules': 21.0.2
'@commitlint/types': 21.0.1
'@commitlint/load@21.0.2(@types/node@22.19.17)(typescript@5.9.3)':
'@commitlint/load@21.0.2(@types/node@24.12.4)(typescript@5.9.3)':
dependencies:
'@commitlint/config-validator': 21.0.1
'@commitlint/execute-rule': 21.0.1
'@commitlint/resolve-extends': 21.0.1
'@commitlint/types': 21.0.1
cosmiconfig: 9.0.1(typescript@5.9.3)
cosmiconfig-typescript-loader: 6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
es-toolkit: 1.47.0
is-plain-obj: 4.1.0
picocolors: 1.1.1
@@ -5810,6 +5851,10 @@ snapshots:
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/list@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@fullcalendar/core': 6.1.20
@@ -5841,8 +5886,7 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.1.0':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
@@ -5942,140 +5986,140 @@ snapshots:
'@inquirer/ansi@2.0.7': {}
'@inquirer/checkbox@5.2.1(@types/node@22.19.17)':
'@inquirer/checkbox@5.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/confirm@6.0.12(@types/node@22.19.17)':
'@inquirer/confirm@6.0.12(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.1.9(@types/node@22.19.17)
'@inquirer/type': 4.0.5(@types/node@22.19.17)
'@inquirer/core': 11.1.9(@types/node@24.12.4)
'@inquirer/type': 4.0.5(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/core@11.1.9(@types/node@22.19.17)':
'@inquirer/core@11.1.9(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@22.19.17)
'@inquirer/type': 4.0.5(@types/node@24.12.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/core@11.2.1(@types/node@22.19.17)':
'@inquirer/core@11.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/editor@5.2.2(@types/node@22.19.17)':
'@inquirer/editor@5.2.2(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/external-editor': 3.0.3(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/external-editor': 3.0.3(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/expand@5.1.1(@types/node@22.19.17)':
'@inquirer/expand@5.1.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/external-editor@3.0.3(@types/node@22.19.17)':
'@inquirer/external-editor@3.0.3(@types/node@24.12.4)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/figures@2.0.5': {}
'@inquirer/figures@2.0.7': {}
'@inquirer/input@5.1.2(@types/node@22.19.17)':
'@inquirer/input@5.1.2(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/number@4.1.1(@types/node@22.19.17)':
'@inquirer/number@4.1.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/password@5.1.1(@types/node@22.19.17)':
'@inquirer/password@5.1.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/prompts@8.4.2(@types/node@22.19.17)':
'@inquirer/prompts@8.4.2(@types/node@24.12.4)':
dependencies:
'@inquirer/checkbox': 5.2.1(@types/node@22.19.17)
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
'@inquirer/editor': 5.2.2(@types/node@22.19.17)
'@inquirer/expand': 5.1.1(@types/node@22.19.17)
'@inquirer/input': 5.1.2(@types/node@22.19.17)
'@inquirer/number': 4.1.1(@types/node@22.19.17)
'@inquirer/password': 5.1.1(@types/node@22.19.17)
'@inquirer/rawlist': 5.3.1(@types/node@22.19.17)
'@inquirer/search': 4.2.1(@types/node@22.19.17)
'@inquirer/select': 5.2.1(@types/node@22.19.17)
'@inquirer/checkbox': 5.2.1(@types/node@24.12.4)
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
'@inquirer/editor': 5.2.2(@types/node@24.12.4)
'@inquirer/expand': 5.1.1(@types/node@24.12.4)
'@inquirer/input': 5.1.2(@types/node@24.12.4)
'@inquirer/number': 4.1.1(@types/node@24.12.4)
'@inquirer/password': 5.1.1(@types/node@24.12.4)
'@inquirer/rawlist': 5.3.1(@types/node@24.12.4)
'@inquirer/search': 4.2.1(@types/node@24.12.4)
'@inquirer/select': 5.2.1(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/rawlist@5.3.1(@types/node@22.19.17)':
'@inquirer/rawlist@5.3.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/search@4.2.1(@types/node@22.19.17)':
'@inquirer/search@4.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/select@5.2.1(@types/node@22.19.17)':
'@inquirer/select@5.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/type@4.0.5(@types/node@22.19.17)':
'@inquirer/type@4.0.5(@types/node@24.12.4)':
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/type@4.0.7(@types/node@22.19.17)':
'@inquirer/type@4.0.7(@types/node@24.12.4)':
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -6431,7 +6475,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))':
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))':
dependencies:
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
concat-stream: 2.0.0
@@ -6439,7 +6483,7 @@ snapshots:
conventional-changelog-angular: 8.3.1
conventional-changelog-conventionalcommits: 9.3.1
conventional-recommended-bump: 11.2.0
release-it: 20.2.0(@types/node@22.19.17)
release-it: 20.2.0(@types/node@24.12.4)
semver: 7.7.4
transitivePeerDependencies:
- conventional-commits-filter
@@ -6551,9 +6595,9 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/node@22.19.17':
'@types/node@24.12.4':
dependencies:
undici-types: 6.21.0
undici-types: 7.16.0
'@types/normalize-package-data@2.4.4': {}
@@ -6571,7 +6615,7 @@ snapshots:
'@types/set-cookie-parser@2.4.10':
dependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@types/statuses@2.0.6': {}
@@ -6579,7 +6623,7 @@ snapshots:
'@types/web-push@3.6.4':
dependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
@@ -7159,9 +7203,9 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cosmiconfig-typescript-loader@6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
dependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
cosmiconfig: 9.0.1(typescript@5.9.3)
jiti: 2.6.1
typescript: 5.9.3
@@ -8658,9 +8702,9 @@ snapshots:
ms@2.1.3: {}
msw@2.14.3(@types/node@22.19.17)(typescript@5.9.3):
msw@2.14.3(@types/node@24.12.4)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
'@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
@@ -8707,6 +8751,11 @@ snapshots:
next: 15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
next-themes@0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
next@15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@next/env': 15.5.15
@@ -9233,9 +9282,9 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
release-it@20.2.0(@types/node@22.19.17):
release-it@20.2.0(@types/node@24.12.4):
dependencies:
'@inquirer/prompts': 8.4.2(@types/node@22.19.17)
'@inquirer/prompts': 8.4.2(@types/node@24.12.4)
'@octokit/rest': 22.0.1
'@phun-ky/typeof': 2.0.3
async-retry: 1.3.3
@@ -9375,6 +9424,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
server-only@0.0.1: {}
set-cookie-parser@3.1.0: {}
set-function-length@1.2.2:
@@ -9401,7 +9452,7 @@ snapshots:
setprototypeof@1.2.0: {}
shadcn@4.7.0(@types/node@22.19.17)(typescript@5.9.3):
shadcn@4.7.0(@types/node@24.12.4)(typescript@5.9.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.3
@@ -9422,7 +9473,7 @@ snapshots:
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
msw: 2.14.3(@types/node@22.19.17)(typescript@5.9.3)
msw: 2.14.3(@types/node@24.12.4)(typescript@5.9.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
@@ -9474,7 +9525,6 @@ snapshots:
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0:
dependencies:
@@ -9545,6 +9595,11 @@ snapshots:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -9860,7 +9915,7 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.24.5: {}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<style>
@media (prefers-color-scheme: dark) { .bg { fill: #6366F1; } }
</style>
<rect class="bg" width="512" height="512" rx="96" fill="#4338CA"/>
<rect x="130" y="238" width="252" height="190" fill="white"/>
<polygon points="256,82 68,254 444,254" fill="white"/>
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+4 -10
View File
@@ -1,12 +1,6 @@
<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"/>
<rect width="512" height="512" rx="96" fill="#4338CA"/>
<rect x="130" y="238" width="252" height="190" fill="white"/>
<polygon points="256,82 68,254 444,254" fill="white"/>
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
</svg>

Before

Width:  |  Height:  |  Size: 594 B

After

Width:  |  Height:  |  Size: 345 B

+8 -5
View File
@@ -4,23 +4,26 @@
"description": "Family coordination app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4F46E5",
"background_color": "#4338CA",
"theme_color": "#4338CA",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-384.png",
"sizes": "384x384",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512-maskable.png",
+1 -1
View File
@@ -85,7 +85,7 @@ export async function main() {
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(` Local → http://localhost:3000`);
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`);
console.log(` HTTPS → https://dev.<your-domain> (push/PWA — needs Caddyfile.dev.snippet)`);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
// 6. Spawn Next.js dev server bound to all interfaces.
+27 -79
View File
@@ -1,100 +1,48 @@
/**
* Generates placeholder PNG icons for the famapp PWA.
* Rasterizes public/icon.svg into all required PNG icon sizes.
*
* Usage: node scripts/generate-icons.mjs
*
* Produces:
* public/icon-16.png
* public/icon-32.png
* public/icon-180.png (apple-touch-icon)
* 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.
* public/icon-512-maskable.png (square bg, content in safe zone)
*/
import { deflateSync } from "zlib";
import { writeFileSync } from "fs";
import { resolve, dirname } from "path";
import sharp from "sharp";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dir = dirname(fileURLToPath(import.meta.url));
const publicDir = resolve(__dir, "../public");
const __dirname = dirname(fileURLToPath(import.meta.url));
const pub = join(__dirname, "..", "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;
}
const svg = readFileSync(join(pub, "icon.svg"));
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 = [
const sizes = [
{ name: "icon-16.png", size: 16 },
{ name: "icon-32.png", size: 32 },
{ name: "icon-180.png", size: 180 },
{ 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));
for (const { name, size } of sizes) {
await sharp(svg).resize(size, size).png().toFile(join(pub, name));
console.log(` ✓ public/${name} (${size}×${size})`);
}
// Maskable: remove rounded corners so the background fills the full canvas,
// allowing the OS to apply any mask shape without clipping the icon corners.
const maskableSvg = readFileSync(join(pub, "icon.svg"), "utf-8").replace('rx="96"', "");
await sharp(Buffer.from(maskableSvg))
.resize(512, 512)
.png()
.toFile(join(pub, "icon-512-maskable.png"));
console.log(` ✓ public/icon-512-maskable.png (512×512 maskable)`);
-30
View File
@@ -1,30 +0,0 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { households } from "@/modules/_core/schema";
import { ensureDefaultCalendars } from "@/modules/calendar/server/defaults";
import { ensureDefaultLists } from "@/modules/lists/server/defaults";
const client = postgres(process.env["DATABASE_URL"]!);
const db = drizzle(client);
async function seed() {
const [existing] = await db.select().from(households).limit(1);
if (existing) {
console.log(`Household already exists ("${existing.name}"), skipping.`);
} else {
await db.insert(households).values({ name: "Home" });
console.log('Seeded household "Home".');
}
await ensureDefaultCalendars();
console.log("Ensured default calendars.");
await ensureDefaultLists();
console.log("Ensured default lists.");
await client.end();
process.exit(0);
}
seed().catch((err) => {
console.error(err);
process.exit(1);
});
+8 -21
View File
@@ -9,7 +9,9 @@ import { DashboardEditor } from "@/components/dashboard-editor";
import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab";
import { DashboardGreeting } from "@/components/dashboard-greeting";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { dashboards } from "@/modules/_core/schema";
@@ -31,9 +33,6 @@ const smColSpan: Record<number, string> = {
12: "sm:col-span-12",
};
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
export default async function DashboardPage({
params,
searchParams,
@@ -83,17 +82,11 @@ export default async function DashboardPage({
const dashLayout = (user.themeDashLayout as DashLayout) ?? "classic";
const containerCls =
dashLayout === "glance" ? "max-w-[760px] mx-auto" : dashLayout === "split" ? "" : "";
const greeting = greetingForHour(new Date().getHours());
const today = new Date().toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
const firstName = (user.name ?? "").split(" ")[0] ?? "";
return (
<div className={containerCls}>
{userDashboards.length > 1 && (
{userDashboards.length >= 1 && (
<div className="flex items-center gap-1 mb-4 overflow-x-auto">
{userDashboards.map((d) => (
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
@@ -103,13 +96,7 @@ export default async function DashboardPage({
)}
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
</div>
<DashboardGreeting firstName={firstName} />
<EditDashboardButton />
</div>
@@ -127,10 +114,10 @@ export default async function DashboardPage({
<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 className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
}
>
-29
View File
@@ -19,22 +19,6 @@ export type DashboardMeta = {
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
@@ -160,19 +144,6 @@ export async function setDefaultDashboard(id: string): Promise<void> {
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
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<button
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Try again
</button>
</div>
);
}
+6 -2
View File
@@ -1,14 +1,18 @@
import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const container = await getContainer(id);
const [container, shareLinks] = await Promise.all([
getContainer(id),
getShareLinksForEntity("garden.container", id),
]);
if (!container) notFound();
return (
<div className="page-content">
<ContainerDetail container={container} />
<ContainerDetail container={container} shareLinks={shareLinks} />
</div>
);
}
+4 -1
View File
@@ -2,14 +2,16 @@ import { notFound } from "next/navigation";
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [plant, careLogs, careSchedules, calendars] = await Promise.all([
const [plant, careLogs, careSchedules, calendars, shareLinks] = await Promise.all([
getPlant(id),
getCareLogs(id),
getCareSchedules(id),
listCalendars(),
getShareLinksForEntity("garden.plant", id),
]);
if (!plant) notFound();
@@ -20,6 +22,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
careLogs={careLogs}
careSchedules={careSchedules}
calendars={calendars}
shareLinks={shareLinks}
/>
</div>
);
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<html>
<body className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<button
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Try again
</button>
</body>
</html>
);
}
+91 -2
View File
@@ -67,6 +67,7 @@
--radius-md: var(--r-md);
--radius-lg: var(--r-lg);
--radius-xl: var(--r-xl);
--color-ok-dark: var(--c-success);
}
/* ── Paper-and-ink primitives (shared across all palettes) ────────── */
@@ -98,7 +99,7 @@
--ok: #4f7a3f;
--warn: #c99a3f;
--bad: #b05246;
--c-success: var(--ok);
/* Density (regular by default; overridden via [data-density]) */
--row: 48px;
--pad: 14px;
@@ -342,6 +343,7 @@
--ok: #82b070;
--warn: #dcb46a;
--bad: #d8746a;
--c-success: var(--ok);
--background: var(--paper);
--foreground: var(--ink);
@@ -490,6 +492,8 @@
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
@apply bg-background text-foreground;
@@ -521,6 +525,7 @@
display: grid;
grid-template-columns: 232px 1fr;
height: 100vh;
height: 100dvh;
background: var(--paper);
}
:where(html[data-nav="rail"]) .app {
@@ -725,7 +730,7 @@
}
:where(html[data-nav="bottom"]) .scroll-area,
:where(html[data-nav="fab"]) .scroll-area {
padding: 12px 14px 88px;
padding: 12px 14px 74px;
}
.scroll-area::-webkit-scrollbar {
@@ -1424,3 +1429,87 @@ select {
.animate-emoji-float {
animation: emojiFloat 1.8s ease-out both;
}
/* ── Calendar min-height CSS variable ───────────────────────────── */
/* Used by calendar-shell so the grid fills the viewport correctly */
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
:root {
--cal-topbar-h: 57px;
--cal-nav-h: 0px;
}
:where(html[data-nav="bottom"]),
:where(html[data-nav="fab"]) {
--cal-nav-h: 62px;
}
/* ── Mobile overrides (≤ 759 px) ────────────────────────────────── */
@media (max-width: 759px) {
/* Prevent iOS Safari from zooming into inputs (requires ≥ 16 px) */
.input,
input,
select,
textarea {
font-size: 16px;
}
/* Topbar: tighten padding; avatar is redundant (Settings in bottom nav) */
.topbar {
padding: 10px 14px;
gap: 8px;
}
.topbar-actions > .avatar {
display: none;
}
/* Keyboard shortcut hints are meaningless on touch */
.kbd {
display: none;
}
/* Eliminate 300 ms tap delay on interactive controls */
.btn,
.checkbox,
.nav-item,
.list-row,
.fab,
.bottom-nav button,
.bottom-nav a {
touch-action: manipulation;
}
/* Momentum scrolling + prevent page bounce fighting in-app scroll */
.scroll-area {
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
/* Checkbox: keep the 18 px visual but expand the tap area to 44 px */
.checkbox {
position: relative;
flex-shrink: 0;
}
.checkbox::after {
content: "";
position: absolute;
inset: -13px; /* 18 + 2×13 = 44 px tap target */
}
/* Delete button on list rows: always visible on touch (no hover state) */
.list-row-delete {
opacity: 1 !important;
}
}
/* Landscape phone: compress bottom nav to icon-only to reclaim vertical space */
@media (max-width: 759px) and (orientation: landscape) and (max-height: 500px) {
.bottom-nav {
height: 44px;
padding: 2px 8px env(safe-area-inset-bottom, 2px);
}
.bottom-nav .nav-label {
display: none;
}
:where(html[data-nav="bottom"]) .scroll-area {
padding-bottom: 52px;
}
}
+8
View File
@@ -15,6 +15,7 @@ import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -42,6 +43,7 @@ const jetbrains = JetBrains_Mono({
export const viewport: Viewport = {
themeColor: "#1F1B16",
viewportFit: "cover",
};
export const metadata: Metadata = {
@@ -54,6 +56,11 @@ export const metadata: Metadata = {
title: "famapp",
},
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/icon-32.png", sizes: "32x32", type: "image/png" },
{ url: "/icon-16.png", sizes: "16x16", type: "image/png" },
],
apple: "/icon-180.png",
},
};
@@ -163,6 +170,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<CommandPalette />
<InstallPrompt />
<PwaRegister />
<AppToaster position="bottom-right" />
</QuickAddProvider>
</body>
</html>
+3 -1
View File
@@ -38,7 +38,9 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
return <ShareError message="This content type cannot be shared." />;
}
const data = await entityReg.loadForShare(resolved.entityId);
const data = await entityReg.loadForShare(resolved.entityId, {
householdId: resolved.householdId,
});
if (!data) {
recordFailure(rlKey);
return <ShareError />;
+6 -7
View File
@@ -9,6 +9,7 @@ import { PushOptIn } from "@/components/push-opt-in";
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
@@ -43,7 +44,7 @@ export default async function SettingsPage({
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
<ResponsiveSidebar active={section} />
<div className="space-y-4">
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
{section === "household" && <HouseholdSection household={household} user={user} />}
{section === "sharing" && <SharingSection />}
{section === "notifications" && (
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
@@ -58,10 +59,10 @@ export default async function SettingsPage({
function HouseholdSection({
household,
userName,
user,
}: {
household: { id: string; name: string };
userName: string | null;
user: { name: string | null; image: string | null };
}) {
return (
<>
@@ -91,11 +92,9 @@ function HouseholdSection({
</CardHeader>
<CardContent>
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
{(userName ?? "?").trim()[0]?.toUpperCase()}
</span>
<AvatarFallbackWithName name={user.name ?? undefined} image={user.image} />
<div className="label">
<div className="t">{userName ?? "Anonymous"}</div>
<div className="t">{user.name ?? "Anonymous"}</div>
<div className="d">Signed in via Authentik</div>
</div>
</div>
-2
View File
@@ -4,7 +4,6 @@ import type { DashboardMeta } from "@/app/d/actions";
import { Sidebar } from "@/components/sidebar";
import { Topbar } from "@/components/topbar";
import { BottomNav } from "@/components/bottom-nav";
import { Fab } from "@/components/fab";
import { NavModeProvider } from "@/components/nav-mode-provider";
const BARE_PREFIXES = ["/s/", "/login"];
@@ -38,7 +37,6 @@ export async function AppShell({ signedIn, navStyle, children }: Props) {
<div className="scroll-area">{children}</div>
</main>
<BottomNav />
<Fab />
<NavModeProvider navStyle={navStyle} />
</div>
);
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
export function AppToaster({ ...props }: ToasterProps) {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const sync = () => {
setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return (
<Sonner
theme={theme}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
export function AvatarFallbackWithName({
name,
initial,
image,
size = "default",
className,
fallbackStyle,
title,
}: {
name?: string;
initial?: string;
image?: string | null;
size?: "sm" | "default" | "lg";
className?: string;
fallbackStyle?: React.CSSProperties;
title?: string;
}) {
const computedInitial = initial ?? name?.trim()[0]?.toUpperCase() ?? "?";
const sizeClass =
size === "sm" ? "size-7 text-xs" : size === "lg" ? "size-10 text-base" : "size-8 text-sm";
return (
<span title={title}>
<Avatar className={cn(sizeClass, className)}>
{image ? (
<AvatarImage src={image} alt={name ?? ""} />
) : (
<AvatarFallback
style={fallbackStyle}
className={cn(!fallbackStyle && "bg-[var(--c-household)]")}
>
{computedInitial}
</AvatarFallback>
)}
</Avatar>
</span>
);
}
+12 -10
View File
@@ -1,21 +1,23 @@
import { cn } from "@/lib/utils";
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
const iconSize = size === "sm" ? 14 : 17;
return (
<span
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
aria-hidden="true"
>
f
</span>
);
}
export function BrandWordmark({ className }: { className?: string }) {
return (
<span className={cn("brand", className)}>
<BrandMark />
<span className="brand-name">famapp</span>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={iconSize}
height={iconSize}
fill="currentColor"
>
<polygon points="12,2 1,12 23,12" />
<rect x="3.5" y="11" width="17" height="12" />
<rect x="9.5" y="16" width="5" height="7" rx="1" style={{ fill: "var(--ink)" }} />
</svg>
</span>
);
}
+3 -3
View File
@@ -139,7 +139,7 @@ export function DashboardEditor({
onClick={() => setPresetMenuOpen((o) => !o)}
disabled={isPending}
>
<LayoutGrid className="size-4 mr-1" />
<LayoutGrid data-icon="inline-start" />
Preset
</Button>
{presetMenuOpen && (
@@ -173,7 +173,7 @@ export function DashboardEditor({
)}
</div>
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
<RotateCcw className="size-4 mr-1" />
<RotateCcw data-icon="inline-start" />
Reset
</Button>
<Button
@@ -182,7 +182,7 @@ export function DashboardEditor({
onClick={() => setPickerOpen(true)}
disabled={isPending}
>
<Plus className="size-4 mr-1" />
<Plus data-icon="inline-start" />
Add widget
</Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}>
+24
View File
@@ -0,0 +1,24 @@
"use client";
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
export function DashboardGreeting({ firstName }: { firstName: string }) {
const now = new Date();
const greeting = greetingForHour(now.getHours());
const today = now.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
return (
<div>
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
</div>
);
}
+23 -18
View File
@@ -13,6 +13,7 @@ import type { DashboardMeta } from "@/app/d/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
@@ -168,28 +169,32 @@ function DashboardKebab({
<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
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine />
Rename
</DropdownMenuItem>
)}
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star />
Set as default
</DropdownMenuItem>
)}
</DropdownMenuGroup>
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 className="size-4 mr-2" />
Delete
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
-14
View File
@@ -1,14 +0,0 @@
"use client";
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
export function Fab() {
const { openSheet } = useQuickAdd();
return (
<button type="button" className="fab" aria-label="Quick add" onClick={openSheet}>
<NavIcon name="plus" className="size-6" strokeWidth={2.4} />
</button>
);
}
+3 -4
View File
@@ -2,6 +2,7 @@
import { useTransition } from "react";
import { setNotifChannel } from "@/app/settings/notify-actions";
import { Switch } from "@/components/ui/switch";
type Channel = "push" | "inapp" | "ntfy";
@@ -46,12 +47,10 @@ export function NotifyChannelToggles({
<span className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
)}
</span>
<input
type="checkbox"
<Switch
checked={value}
disabled={isPending || disabled}
onChange={(e) => toggle(key, e.target.checked)}
className="size-4 cursor-pointer"
onCheckedChange={(checked) => toggle(key, checked)}
/>
</label>
))}
+1 -1
View File
@@ -101,7 +101,7 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
if (status === "subscribed") {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-green-600 dark:text-green-400">
<span className="text-sm text-[var(--c-success)] dark:text-[var(--c-success)]">
Push notifications enabled
</span>
<Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}>
+7 -1
View File
@@ -29,8 +29,14 @@ const QUICK_ADD_ICON: Record<string, string> = {
"file-plus": "note",
};
function useShortcutLabel() {
if (typeof navigator === "undefined") return "⌘K";
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘K" : "Ctrl+K";
}
export function QuickAddSheet() {
const { sheetOpen, closeSheet, actions } = useQuickAdd();
const shortcut = useShortcutLabel();
const router = useRouter();
const backdropRef = useRef<HTMLDivElement>(null);
@@ -110,7 +116,7 @@ export function QuickAddSheet() {
}}
>
<Sparkles className="size-3" />
<span>Pick what to add or use K to search.</span>
<span>Pick what to add or use {shortcut} to search.</span>
</div>
<div className="max-h-[60vh] overflow-y-auto">
+12 -6
View File
@@ -17,7 +17,7 @@ const SECTIONS = [
export type SectionId = (typeof SECTIONS)[number]["id"];
export function SettingsSidebar({ active }: { active: SectionId }) {
function SettingsSidebar({ active }: { active: SectionId }) {
const router = useRouter();
const pathname = usePathname();
@@ -78,23 +78,29 @@ function SectionHashSync({ pathname }: { pathname: string | null }) {
return null;
}
export function SettingsTabsMobile({ active }: { active: SectionId }) {
function SettingsTabsMobile({ active }: { active: SectionId }) {
const router = useRouter();
return (
<div className="seg w-full overflow-x-auto" style={{ display: "flex" }}>
<div className="grid grid-cols-2 gap-1.5 mb-1">
{SECTIONS.map((s) => (
<button
key={s.id}
type="button"
role="tab"
aria-selected={active === s.id}
aria-current={active === s.id ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 px-3 py-2.5 rounded-[var(--r-md)] border-[0.5px] text-left text-[13px] font-medium transition-colors",
active === s.id
? "bg-[var(--card)] border-[var(--hair-2)] text-[var(--ink)] shadow-[var(--shadow-1)]"
: "border-transparent text-[var(--ink-soft)] hover:bg-[var(--shade)]",
)}
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.set("s", s.id);
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
}}
>
{s.label.split(" ")[0]}
<NavIcon name={s.icon} className="size-4 shrink-0" />
<span>{s.label.split(" ")[0]}</span>
</button>
))}
</div>
+23 -10
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react";
import { Link, Check, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -19,15 +20,23 @@ export function ShareButton({
const [open, setOpen] = useState(false);
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function share() {
setError(null);
startTransition(async () => {
const result = await createShareLink(entityType, entityId, {
capabilities: { read: true, write: canWrite },
});
setShareUrl(result.url);
setOpen(true);
try {
const result = await createShareLink(entityType, entityId, {
capabilities: { read: true, write: canWrite },
});
setShareUrl(result.url);
setOpen(true);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create share link";
setError(message);
toast.error(message);
}
});
}
@@ -35,16 +44,20 @@ export function ShareButton({
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
});
}
return (
<>
<Button variant="outline" onClick={share} disabled={isPending}>
<Link />
Share
</Button>
<div className="flex flex-col items-end gap-1">
<Button variant="outline" onClick={share} disabled={isPending}>
<Link data-icon="inline-start" />
Share
</Button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
@@ -58,7 +71,7 @@ export function ShareButton({
<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 />}
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
</Button>
</div>
</DialogContent>
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { revokeShareLink } from "@/modules/_core/share";
import type { EntityShareLink } from "@/modules/_core/share";
type Props = {
links: EntityShareLink[];
};
export function ShareLinkList({ links }: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
if (links.length === 0) return null;
function handleRevoke(id: string) {
startTransition(async () => {
await revokeShareLink(id);
router.refresh();
});
}
return (
<div className="flex flex-col gap-1 pt-1 border-t border-[var(--ink-faint)]">
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] tracking-wide">
Active share links ({links.length})
</p>
{links.map((link) => (
<div key={link.id} className="flex items-center justify-between gap-2 text-sm py-0.5">
<div className="flex flex-col">
<span className="text-[var(--ink-mute)]">
Created {new Date(link.createdAt).toLocaleDateString()}
</span>
{link.expiresAt && (
<span className="text-xs text-[var(--ink-mute)]">
Expires {new Date(link.expiresAt).toLocaleDateString()}
</span>
)}
</div>
<button
className="btn btn-ghost btn-sm text-red-500 hover:text-red-600"
onClick={() => handleRevoke(link.id)}
disabled={isPending}
>
Revoke
</button>
</div>
))}
</div>
);
}
+12 -17
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { eq } from "drizzle-orm";
import { cn } from "@/lib/utils";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { getRegistry } from "@/modules/_core/registry";
@@ -7,6 +8,7 @@ import { householdMembers, households, users } from "@/modules/_core/schema";
import { BrandMark } from "@/components/brand-mark";
import { NavIcon } from "@/components/nav-icon";
import { NavLink } from "@/components/nav-link";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
type Variant = "side" | "top";
@@ -102,23 +104,16 @@ function HouseholdPill({
return (
<div className="household-pill" title={info.household.name}>
<div style={{ display: "flex" }}>
{visible.map((m, i) => {
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return (
<span
key={m.id}
className="avatar avatar-sm"
style={{
background: avatarColor(m.id),
marginLeft: i > 0 ? -6 : 0,
boxShadow: "0 0 0 1.5px var(--card)",
}}
title={m.name ?? m.email ?? undefined}
>
{initial}
</span>
);
})}
{visible.map((m, i) => (
<AvatarFallbackWithName
key={m.id}
name={m.name ?? m.email ?? undefined}
image={m.image}
className={cn(i > 0 && "-ml-1.5", "shadow-[0_0_0_1.5px_var(--card)]")}
fallbackStyle={{ background: avatarColor(m.id) }}
title={m.name ?? m.email ?? undefined}
/>
))}
</div>
<div className="household-text" style={{ minWidth: 0 }}>
<div
+63 -33
View File
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
@@ -41,6 +42,11 @@ interface Props {
signedIn?: boolean;
}
const PALETTE_ITEMS = PALETTES.map((p) => ({ label: p.label, value: p.id }));
const FONT_PAIR_ITEMS = FONT_PAIRS.map((f) => ({ label: f.label, value: f.id }));
const DASH_LAYOUT_ITEMS = DASH_LAYOUTS.map((d) => ({ label: d.label, value: d.id }));
const NAV_STYLE_ITEMS = NAV_STYLES.map((n) => ({ label: n.label, value: n.id }));
export function ThemePicker({
initialPalette = "clay",
initialMode = "system",
@@ -68,7 +74,11 @@ export function ThemePicker({
<div className="space-y-5">
<div className="space-y-1.5">
<Label>Theme</Label>
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
<Select
items={PALETTE_ITEMS}
value={t.palette}
onValueChange={(v) => t.setPalette(v as Palette)}
>
<SelectTrigger className="w-56">
<div className="flex items-center gap-2 min-w-0">
<span
@@ -84,20 +94,22 @@ export function ThemePicker({
</div>
</SelectTrigger>
<SelectContent>
{PALETTES.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center justify-between gap-6 w-40">
<span>{p.label}</span>
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
</div>
</SelectItem>
))}
<SelectGroup>
{PALETTES.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center justify-between gap-6 w-40">
<span>{p.label}</span>
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -121,16 +133,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Type pairing</Label>
<Select value={t.fontPair} onValueChange={(v) => t.setFontPair(v as FontPair)}>
<Select
items={FONT_PAIR_ITEMS}
value={t.fontPair}
onValueChange={(v) => t.setFontPair(v as FontPair)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_PAIRS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
<SelectGroup>
{FONT_PAIRS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -154,16 +172,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Dashboard layout</Label>
<Select value={t.dashLayout} onValueChange={(v) => t.setDashLayout(v as DashLayout)}>
<Select
items={DASH_LAYOUT_ITEMS}
value={t.dashLayout}
onValueChange={(v) => t.setDashLayout(v as DashLayout)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DASH_LAYOUTS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
<SelectGroup>
{DASH_LAYOUTS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -187,16 +211,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Navigation</Label>
<Select value={t.navStyle} onValueChange={(v) => t.setNavStyle(v as NavStyle)}>
<Select
items={NAV_STYLE_ITEMS}
value={t.navStyle}
onValueChange={(v) => t.setNavStyle(v as NavStyle)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NAV_STYLES.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.label}
</SelectItem>
))}
<SelectGroup>
{NAV_STYLES.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
+36 -16
View File
@@ -3,25 +3,45 @@
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
function useShortcutLabel() {
if (typeof navigator === "undefined") return "⌘K";
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘K" : "Ctrl+K";
}
export function TopbarSearch() {
const { openPalette } = useQuickAdd();
const shortcut = useShortcutLabel();
return (
<button
type="button"
onClick={openPalette}
aria-label="Search (⌘K)"
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]"
style={{
border: "0.5px solid var(--hair-2)",
background: "var(--card)",
color: "var(--ink-mute)",
width: 220,
}}
>
<NavIcon name="search" className="size-3.5" />
<span style={{ flex: 1, textAlign: "left" }}>Search</span>
<span className="kbd">K</span>
</button>
<>
{/* Desktop: pill with shortcut hint */}
<button
type="button"
onClick={openPalette}
aria-label={`Search (${shortcut})`}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]"
style={{
border: "0.5px solid var(--hair-2)",
background: "var(--card)",
color: "var(--ink-mute)",
width: 220,
}}
>
<NavIcon name="search" className="size-3.5" />
<span style={{ flex: 1, textAlign: "left" }}>Search</span>
<span className="kbd">{shortcut}</span>
</button>
{/* Mobile: icon-only */}
<button
type="button"
onClick={openPalette}
aria-label="Search"
className="flex md:hidden items-center justify-center h-8 w-8 rounded-md"
style={{ color: "var(--ink-mute)" }}
>
<NavIcon name="search" className="size-4" />
</button>
</>
);
}
+6 -28
View File
@@ -6,7 +6,7 @@ import { NotificationBell } from "@/components/notification-bell";
import { TopbarSearch } from "@/components/topbar-search";
import { TopbarTitle } from "@/components/topbar-title";
import { TopbarNewButton } from "@/components/topbar-new-button";
import { NavIcon } from "@/components/nav-icon";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
async function getNotifications(userId: string) {
const rows = await db
@@ -36,7 +36,6 @@ export async function Topbar() {
? await getNotifications(userId)
: { rows: [], unread: 0 };
const userRow = userId ? await getUserAvatar(userId) : null;
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return (
<div className="topbar">
@@ -57,35 +56,14 @@ export async function Topbar() {
)}
<TopbarNewButton />
{userId && (
<span
className="avatar"
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }}
<AvatarFallbackWithName
name={userRow?.name ?? userRow?.email ?? undefined}
image={userRow?.image}
size="sm"
title={userRow?.name ?? userRow?.email ?? undefined}
>
{userRow?.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={userRow.image}
alt=""
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
/>
) : (
initial
)}
</span>
/>
)}
</div>
</div>
);
}
export function TopbarSpacer() {
return (
<div className="topbar">
<h1 className="serif">famapp</h1>
<div className="topbar-actions">
<NavIcon name="bell" className="size-4 text-[var(--ink-mute)]" />
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} />
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };
+93
View File
@@ -0,0 +1,93 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
+49
View File
@@ -0,0 +1,49 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants };
+94
View File
@@ -0,0 +1,94 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
},
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("font-heading text-sm font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className,
)}
{...props}
/>
);
}
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+87
View File
@@ -0,0 +1,87 @@
"use client";
import * as React from "react";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
});
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing, orientation }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
);
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</TogglePrimitive>
);
}
export { ToggleGroup, ToggleGroupItem };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
-9
View File
@@ -49,12 +49,3 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
},
},
});
export async function getCurrentUser() {
const session = await auth();
if (!session?.user?.id) return null;
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, session.user.id),
});
return user ?? null;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { computePresetLayoutFromMetas } from "./dashboard";
/** Build a layout matching one of the design's three dashboard arrangements
* using the live module registry. Server-only the registry is empty on
* the client. */
export function computePresetLayout(preset: PresetId): DashboardLayout {
function computePresetLayout(preset: PresetId): DashboardLayout {
const { widgets } = getRegistry();
if (widgets.length === 0) return { version: 1, widgets: [] };
-2
View File
@@ -1,5 +1,3 @@
export const DEV_LOGIN_COOKIE = "authjs.session-token";
if (
process.env.NODE_ENV === "production" &&
process.env.ENABLE_DEV_LOGIN === "true" &&
+2 -1
View File
@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { householdMembers, households, users } from "@/modules/_core/schema";
@@ -13,7 +14,7 @@ export interface CurrentSession {
export async function getCurrentSession(): Promise<CurrentSession> {
const session = await auth();
if (!session?.user?.id) throw new Error("Not authenticated");
if (!session?.user?.id) redirect("/login");
const [row] = await db
.select({
+2 -2
View File
@@ -1,8 +1,8 @@
import { NextResponse, type NextRequest } from "next/server";
import { consume } from "@/lib/rate-limit";
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
const PUBLIC_PATHS = new Set(["/login"]);
const PUBLIC_PREFIXES = ["/api/auth/", "/s/", "/icon-", "/favicon"];
const PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]);
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
// Token-prefix length used as part of the rate-limit bucket key.
+11 -1
View File
@@ -17,6 +17,15 @@ export type ShareCapabilities = {
defaultCapabilities?: string[];
};
export type ShareContext = {
householdId: string;
userId: string;
};
export type PublicShareContext = {
householdId: string;
};
export type ReminderCapabilities = {
canRemind: boolean;
};
@@ -68,7 +77,8 @@ export type EntityTypeRegistration = {
reminder?: ReminderCapabilities;
search?: SearchAdapter;
resolveUrl: (id: string) => string;
loadForShare?: (id: string) => Promise<unknown>;
canShareEntity?: (id: string, ctx: ShareContext) => Promise<boolean>;
loadForShare?: (id: string, ctx: PublicShareContext) => Promise<unknown>;
renderSharedView?: (props: {
data: unknown;
capabilities: { read: boolean; write: boolean };
+1 -1
View File
@@ -54,7 +54,7 @@ export async function listReminders(entityType: string, entityId: string) {
.where(and(eq(reminders.entityType, entityType), eq(reminders.entityId, entityId)));
}
export async function tickReminders() {
async function tickReminders() {
let dueReminders: (typeof reminders.$inferSelect)[] = [];
try {
+16
View File
@@ -0,0 +1,16 @@
import type { EntityTypeRegistration, ShareContext } from "./module";
export async function ensureEntityShareAuthorized(
registration: EntityTypeRegistration,
entityId: string,
ctx: ShareContext,
): Promise<void> {
if (!registration.canShareEntity) {
throw new Error(`Entity type "${registration.type}" does not support share authorization`);
}
const allowed = await registration.canShareEntity(entityId, ctx);
if (!allowed) {
throw new Error("You are not allowed to share this entity");
}
}
+49 -1
View File
@@ -6,6 +6,7 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { getEntityType } from "./registry";
import { shareLinks } from "./schema";
import { ensureEntityShareAuthorized } from "./share-authorization";
export type ShareLinkCapabilities = { read: boolean; write: boolean };
@@ -20,7 +21,8 @@ function hashToken(raw: string): string {
}
function buildUrl(token: string): string {
const base = process.env["NEXTAUTH_URL"] ?? "http://localhost:3000";
const base =
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
return `${base}/s/${token}`;
}
@@ -35,6 +37,10 @@ export async function createShareLink(
}
const { user, household } = await getCurrentSession();
await ensureEntityShareAuthorized(registration, entityId, {
householdId: household.id,
userId: user.id,
});
const rawToken = randomBytes(32).toString("base64url");
const tokenHash = hashToken(rawToken);
@@ -100,6 +106,48 @@ export async function revokeShareLink(id: string): Promise<void> {
export type ActiveShareLink = typeof shareLinks.$inferSelect;
export type EntityShareLink = {
id: string;
createdAt: string;
expiresAt: string | null;
capabilities: ShareLinkCapabilities;
};
export async function getShareLinksForEntity(
entityType: string,
entityId: string,
): Promise<EntityShareLink[]> {
const { household } = await getCurrentSession();
const now = new Date();
const rows = await db
.select({
id: shareLinks.id,
createdAt: shareLinks.createdAt,
expiresAt: shareLinks.expiresAt,
capabilities: shareLinks.capabilities,
})
.from(shareLinks)
.where(
and(
eq(shareLinks.householdId, household.id),
eq(shareLinks.entityType, entityType),
eq(shareLinks.entityId, entityId),
isNull(shareLinks.revokedAt),
),
)
.orderBy(shareLinks.createdAt);
return rows
.filter((r) => !r.expiresAt || r.expiresAt > now)
.map((r) => ({
id: r.id,
createdAt: r.createdAt.toISOString(),
expiresAt: r.expiresAt?.toISOString() ?? null,
capabilities: r.capabilities,
}));
}
export async function getActiveShareLinks(): Promise<ActiveShareLink[]> {
const { household } = await getCurrentSession();
const now = new Date();
+405 -262
View File
@@ -4,10 +4,11 @@ import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2, X } from "lucide-react";
import { useMemo, useState, useEffect, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -16,6 +17,7 @@ import type { CalView } from "@/modules/_core/themes";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
@@ -44,7 +46,13 @@ type EventDraft = {
remindMinutesBefore: number | null;
};
type MobileView = "day" | "week" | "agenda";
const DEFAULT_COLOR = "#B85C3C";
const VISIBILITY_ITEMS = [
{ label: "Household", value: "household" },
{ label: "Private", value: "private" },
];
const VIEW_MAP: Record<CalView, string> = {
month: "dayGridMonth",
@@ -52,8 +60,13 @@ const VIEW_MAP: Record<CalView, string> = {
day: "timeGridDay",
};
const MOBILE_VIEW_MAP: Record<MobileView, string> = {
day: "timeGridDay",
week: "timeGridWeek",
agenda: "listWeek",
};
function withAlpha(hex: string, alpha: number): string {
// Accept #RGB / #RRGGBB / non-hex (return as-is for non-hex e.g. var(--))
if (!hex.startsWith("#")) return hex;
let h = hex.slice(1);
if (h.length === 3)
@@ -88,6 +101,17 @@ export function CalendarShell({
);
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
const [isPending, startTransition] = useTransition();
const [isMobile, setIsMobile] = useState(false);
const [showCalendarsPanel, setShowCalendarsPanel] = useState(false);
const [mobileView, setMobileView] = useState<MobileView>("day");
useEffect(() => {
const mq = window.matchMedia("(max-width: 759px)");
const apply = () => setIsMobile(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
? lastCalendarId
@@ -96,6 +120,10 @@ export function CalendarShell({
() => eventRows.filter((event) => visibleIds.has(event.calendarId)),
[eventRows, visibleIds],
);
const calendarSelectItems = useMemo(
() => calendarRows.map((calendar) => ({ label: calendar.name, value: calendar.id })),
[calendarRows],
);
function toggleCalendar(id: string) {
setVisibleIds((current) => {
@@ -289,9 +317,254 @@ export function CalendarShell({
});
}
// Shared calendar list + new-calendar form rendered in both the desktop aside
// and the mobile bottom-sheet panel.
const calendarsPanelContent = (
<>
<div className="space-y-2">
{calendarRows.map((calendar) => (
<div key={calendar.id} className="rounded-lg border bg-background p-2">
<div className="flex items-center gap-2">
<button
type="button"
className="grid size-7 place-items-center rounded-md border"
onClick={() => toggleCalendar(calendar.id)}
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
>
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
</button>
<span
className="size-3 rounded-full"
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
/>
<Input
aria-label={`${calendar.name} name`}
value={calendar.name}
onChange={(event) => updateCalendarName(calendar, event.target.value)}
onBlur={() => commitCalendarName(calendar)}
/>
</div>
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`${calendar.name} color`}
type="color"
value={calendar.color ?? DEFAULT_COLOR}
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
/>
<Select
items={VISIBILITY_ITEMS}
value={calendar.visibility}
onValueChange={(value) =>
updateCalendar(calendar, { visibility: value as "private" | "household" })
}
>
<SelectTrigger aria-label={`${calendar.name} visibility`}>
<SelectValue>
{calendar.visibility === "household" ? "Household" : "Private"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{VISIBILITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => removeCalendar(calendar.id)}
aria-label={`Delete ${calendar.name}`}
>
<Trash2 />
</Button>
</div>
</div>
))}
</div>
<div className="mt-4 rounded-lg border bg-background p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<CalendarPlus className="size-4" />
New calendar
</div>
<div className="space-y-2">
<Label htmlFor="calendar-name">Name</Label>
<Input
id="calendar-name"
value={calendarName}
onChange={(event) => setCalendarName(event.target.value)}
/>
<div className="grid grid-cols-[1fr_1fr] gap-2">
<Input
aria-label="Calendar color"
type="color"
value={calendarColor}
onChange={(event) => setCalendarColorValue(event.target.value)}
/>
<Select
items={VISIBILITY_ITEMS}
value={calendarVisibility}
onValueChange={(value) =>
setCalendarVisibilityValue(value as "private" | "household")
}
>
<SelectTrigger aria-label="Calendar visibility">
<SelectValue>
{calendarVisibility === "household" ? "Household" : "Private"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{VISIBILITY_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
<Check />
Create calendar
</Button>
</div>
</div>
</>
);
const fcEvents = visibleEvents.map((event) => {
const color =
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ?? DEFAULT_COLOR;
return {
id: event.id,
title: event.title,
start: event.startAt,
end: event.endAt,
allDay: event.allDay,
backgroundColor: withAlpha(color, 0.14),
borderColor: color,
textColor: "var(--ink-2)",
extendedProps: { calendarId: event.calendarId, color },
};
});
// Shared event form content — rendered inside both the desktop centered modal
// and the mobile bottom-sheet.
const eventFormContent = selectedEvent && (
<div className="grid gap-3">
<Label htmlFor="event-title">Title</Label>
<Input
id="event-title"
value={selectedEvent.title}
onChange={(event) => setSelectedEvent({ ...selectedEvent, title: event.target.value })}
/>
<Label htmlFor="event-calendar">Calendar</Label>
<Select
items={calendarSelectItems}
value={selectedEvent.calendarId}
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
>
<SelectTrigger id="event-calendar">
<SelectValue>
{calendarRows.find((c) => c.id === selectedEvent.calendarId)?.name ??
"Select a calendar"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{calendarSelectItems.map((calendar) => (
<SelectItem key={calendar.value} value={calendar.value}>
{calendar.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="event-start">Start</Label>
<Input
id="event-start"
type="datetime-local"
value={selectedEvent.startAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-end">End</Label>
<Input
id="event-end"
type="datetime-local"
value={selectedEvent.endAt}
onChange={(event) => setSelectedEvent({ ...selectedEvent, endAt: event.target.value })}
/>
</div>
</div>
<Label htmlFor="event-location">Location</Label>
<Input
id="event-location"
value={selectedEvent.location}
onChange={(event) => setSelectedEvent({ ...selectedEvent, location: event.target.value })}
/>
<Label htmlFor="event-notes">Notes</Label>
<textarea
id="event-notes"
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={selectedEvent.notes}
onChange={(event) => setSelectedEvent({ ...selectedEvent, notes: event.target.value })}
/>
{!selectedEvent.id && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 cursor-pointer"
checked={selectedEvent.remindMinutesBefore !== null}
onChange={(e) =>
setSelectedEvent({
...selectedEvent,
remindMinutesBefore: e.target.checked ? 30 : null,
})
}
/>
Remind me 30 min before
</label>
)}
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex gap-2">
{selectedEvent.id && (
<Button variant="destructive" onClick={removeSelectedEvent}>
Delete event
</Button>
)}
</div>
<div className="flex gap-2">
{selectedEvent.id && (
<ShareButton entityType="calendar.event" entityId={selectedEvent.id} />
)}
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
Save event
</Button>
</div>
</div>
</div>
);
return (
<div className="grid min-h-[calc(100vh-57px)] grid-cols-1 lg:grid-cols-[280px_1fr]">
<aside className="border-b bg-sidebar p-4 lg:border-r lg:border-b-0">
<div
className="grid grid-cols-1 lg:grid-cols-[280px_1fr]"
style={{ minHeight: "calc(100dvh - var(--cal-topbar-h) - var(--cal-nav-h))" }}
>
{/* Desktop aside — hidden on mobile */}
<aside
className={`${isMobile ? "hidden" : ""} border-b bg-sidebar p-4 lg:border-r lg:border-b-0`}
>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-xl font-semibold">Calendar</h1>
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
@@ -299,276 +572,146 @@ export function CalendarShell({
<span className="sr-only">New event</span>
</Button>
</div>
<div className="space-y-2">
{calendarRows.map((calendar) => (
<div key={calendar.id} className="rounded-lg border bg-background p-2">
<div className="flex items-center gap-2">
<button
type="button"
className="grid size-7 place-items-center rounded-md border"
onClick={() => toggleCalendar(calendar.id)}
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
>
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
</button>
<span
className="size-3 rounded-full"
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
/>
<Input
aria-label={`${calendar.name} name`}
value={calendar.name}
onChange={(event) => updateCalendarName(calendar, event.target.value)}
onBlur={() => commitCalendarName(calendar)}
/>
</div>
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`${calendar.name} color`}
type="color"
value={calendar.color ?? DEFAULT_COLOR}
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
/>
<Select
value={calendar.visibility}
onValueChange={(value) =>
updateCalendar(calendar, {
visibility: value as "private" | "household",
})
}
>
<SelectTrigger aria-label={`${calendar.name} visibility`}>
<SelectValue>
{calendar.visibility === "household" ? "Household" : "Private"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => removeCalendar(calendar.id)}
aria-label={`Delete ${calendar.name}`}
>
<Trash2 />
</Button>
</div>
</div>
))}
</div>
<div className="mt-4 rounded-lg border bg-background p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<CalendarPlus className="size-4" />
New calendar
</div>
<div className="space-y-2">
<Label htmlFor="calendar-name">Name</Label>
<Input
id="calendar-name"
value={calendarName}
onChange={(event) => setCalendarName(event.target.value)}
/>
<div className="grid grid-cols-[1fr_1fr] gap-2">
<Input
aria-label="Calendar color"
type="color"
value={calendarColor}
onChange={(event) => setCalendarColorValue(event.target.value)}
/>
<Select
value={calendarVisibility}
onValueChange={(value) =>
setCalendarVisibilityValue(value as "private" | "household")
}
>
<SelectTrigger aria-label="Calendar visibility">
<SelectValue>
{calendarVisibility === "household" ? "Household" : "Private"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
<Check />
Create calendar
</Button>
</div>
</div>
{calendarsPanelContent}
</aside>
<section className="fc-skin min-w-0 p-4">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={VIEW_MAP[defaultView]}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}}
buttonText={{
today: "Today",
month: "Month",
week: "Week",
day: "Day",
}}
dayHeaderFormat={{ weekday: "short" }}
selectable
editable
eventResizableFromStart
select={handleDateSelect}
eventClick={openExistingEvent}
eventDrop={moveEvent}
eventResize={moveEvent}
events={visibleEvents.map((event) => {
const color =
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
DEFAULT_COLOR;
return {
id: event.id,
title: event.title,
start: event.startAt,
end: event.endAt,
allDay: event.allDay,
backgroundColor: withAlpha(color, 0.14),
borderColor: color,
textColor: "var(--ink-2)",
extendedProps: { calendarId: event.calendarId, color },
};
})}
eventClassNames={(arg) => {
const id = String(arg.event.extendedProps["calendarId"] ?? "");
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
}}
height="auto"
/>
{/* Calendar section */}
<section className="fc-skin flex min-w-0 flex-col">
{/* Mobile toolbar: view switcher + calendars button + new event */}
{isMobile && (
<div
className="flex items-center gap-2 border-b px-3 py-2"
style={{ borderColor: "var(--hair)" }}
>
<div className="seg flex-1">
{(["day", "week", "agenda"] as MobileView[]).map((v) => (
<button
key={v}
type="button"
aria-selected={mobileView === v}
onClick={() => setMobileView(v)}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</button>
))}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setShowCalendarsPanel(true)}
>
<Eye size={14} />
Cals
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => openNewEvent()}>
<Plus size={14} />
New
</button>
</div>
)}
<div className="flex-1 p-4">
<FullCalendar
key={isMobile ? `mobile-${mobileView}` : `desktop-${defaultView}`}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
initialView={isMobile ? MOBILE_VIEW_MAP[mobileView] : VIEW_MAP[defaultView]}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "today" }
: {
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}
}
buttonText={{ today: "Today", month: "Month", week: "Week", day: "Day", list: "List" }}
dayHeaderFormat={{ weekday: "short" }}
selectable={!isMobile}
editable={!isMobile}
eventResizableFromStart={!isMobile}
select={handleDateSelect}
eventClick={openExistingEvent}
eventDrop={moveEvent}
eventResize={moveEvent}
events={fcEvents}
eventClassNames={(arg) => {
const id = String(arg.event.extendedProps["calendarId"] ?? "");
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
}}
height="auto"
/>
</div>
</section>
{/* Mobile: calendars bottom sheet */}
{isMobile && showCalendarsPanel && (
<div
className="fixed inset-0 z-[45] bg-black/40"
onClick={() => setShowCalendarsPanel(false)}
>
<div
className="absolute inset-x-0 overflow-auto rounded-t-[var(--r-xl)]"
style={{
bottom: "var(--cal-nav-h)",
maxHeight: "72dvh",
background: "var(--card)",
paddingBottom: "env(safe-area-inset-bottom)",
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between border-b p-4"
style={{ borderColor: "var(--hair)" }}
>
<h3 className="serif text-[16px] font-medium">Calendars</h3>
<button
type="button"
className="btn btn-ghost btn-icon"
aria-label="Close calendars panel"
onClick={() => setShowCalendarsPanel(false)}
>
<X size={16} />
</button>
</div>
<div className="p-4">{calendarsPanelContent}</div>
</div>
</div>
)}
{/* Event form — centered modal on desktop, bottom sheet on mobile */}
{selectedEvent && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/20 p-4">
<div className="w-full max-w-lg rounded-lg bg-popover p-4 text-popover-foreground shadow-lg">
<div
className="fixed inset-0 z-50 flex bg-black/20"
style={
isMobile
? { alignItems: "flex-end" }
: { alignItems: "center", justifyContent: "center" }
}
onClick={(e) => {
if (e.target === e.currentTarget) setSelectedEvent(null);
}}
>
<div
className="overflow-auto bg-popover text-popover-foreground shadow-lg"
style={{
borderRadius: isMobile ? "var(--r-xl) var(--r-xl) 0 0" : "var(--r-lg)",
width: isMobile ? "100%" : undefined,
maxWidth: isMobile ? undefined : "512px",
maxHeight: "90dvh",
padding: "1rem",
paddingBottom: isMobile ? "calc(1rem + env(safe-area-inset-bottom))" : "1rem",
}}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{selectedEvent.id ? "Edit event" : "New event"}
</h2>
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
<span aria-hidden>×</span>
<X size={16} />
<span className="sr-only">Close</span>
</Button>
</div>
<div className="grid gap-3">
<Label htmlFor="event-title">Title</Label>
<Input
id="event-title"
value={selectedEvent.title}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, title: event.target.value })
}
/>
<Label htmlFor="event-calendar">Calendar</Label>
<Select
value={selectedEvent.calendarId}
onValueChange={(value) =>
setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })
}
>
<SelectTrigger id="event-calendar">
<SelectValue>
{calendarRows.find((c) => c.id === selectedEvent.calendarId)?.name ??
"Select a calendar"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{calendarRows.map((calendar) => (
<SelectItem key={calendar.id} value={calendar.id}>
{calendar.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="event-start">Start</Label>
<Input
id="event-start"
type="datetime-local"
value={selectedEvent.startAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-end">End</Label>
<Input
id="event-end"
type="datetime-local"
value={selectedEvent.endAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })
}
/>
</div>
</div>
<Label htmlFor="event-location">Location</Label>
<Input
id="event-location"
value={selectedEvent.location}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, location: event.target.value })
}
/>
<Label htmlFor="event-notes">Notes</Label>
<textarea
id="event-notes"
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={selectedEvent.notes}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, notes: event.target.value })
}
/>
{!selectedEvent.id && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 cursor-pointer"
checked={selectedEvent.remindMinutesBefore !== null}
onChange={(e) =>
setSelectedEvent({
...selectedEvent,
remindMinutesBefore: e.target.checked ? 30 : null,
})
}
/>
Remind me 30 min before
</label>
)}
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex gap-2">
{selectedEvent.id && (
<Button variant="destructive" onClick={removeSelectedEvent}>
Delete event
</Button>
)}
</div>
<div className="flex gap-2">
{selectedEvent.id && (
<ShareButton entityType="calendar.event" entityId={selectedEvent.id} />
)}
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
Save event
</Button>
</div>
</div>
</div>
{eventFormContent}
</div>
</div>
)}
+6 -2
View File
@@ -2,6 +2,8 @@ import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries";
import {
canShareCalendar,
canShareEvent,
loadCalendarForShare,
loadEventForShare,
type CalendarShareData,
@@ -150,7 +152,8 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchCalendars },
resolveUrl: (id) => `/calendar?id=${id}`,
loadForShare: (id) => loadCalendarForShare(id),
canShareEntity: canShareCalendar,
loadForShare: loadCalendarForShare,
renderSharedView: ({ data }) => <CalendarSharedView data={data as CalendarShareData} />,
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
@@ -166,7 +169,8 @@ const manifest: ModuleManifest = {
reminder: { canRemind: true },
search: { search: searchEvents },
resolveUrl: (id) => `/calendar/events/${id}`,
loadForShare: (id) => loadEventForShare(id),
canShareEntity: canShareEvent,
loadForShare: loadEventForShare,
renderSharedView: ({ data }) => <EventSharedView data={data as EventShareData} />,
renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined;
+1 -17
View File
@@ -1,6 +1,6 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { householdMembers, households, users } from "@/modules/_core/schema";
import { householdMembers } from "@/modules/_core/schema";
import { calendars } from "../schema";
const HOME_COLOR = "#2563eb";
@@ -17,22 +17,6 @@ export async function ensureDefaultCalendarsForMembership({
await ensurePersonalCalendar({ householdId, userId });
}
export async function ensureDefaultCalendars() {
const householdRows = await db.select().from(households);
for (const household of householdRows) {
await ensureHomeCalendar(household.id);
}
const memberships = await db
.select({ householdId: householdMembers.householdId, userId: users.id })
.from(householdMembers)
.innerJoin(users, eq(householdMembers.userId, users.id));
for (const membership of memberships) {
await ensurePersonalCalendar(membership);
}
}
async function ensureHomeCalendar(householdId: string) {
const [existing] = await db
.select({ id: calendars.id })
+48 -4
View File
@@ -1,5 +1,6 @@
import { and, asc, eq, gte, lte } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { calendarEvents, calendars } from "../schema";
export type EventSummary = {
@@ -21,11 +22,51 @@ export type CalendarShareData = {
export type EventShareData = EventSummary & { calendarName: string };
export async function loadCalendarForShare(id: string): Promise<CalendarShareData | null> {
function canSeeCalendarRow(
row: { householdId: string; ownerId: string; visibility: string },
ctx: ShareContext,
): boolean {
if (row.householdId !== ctx.householdId) return false;
return row.visibility === "household" || row.ownerId === ctx.userId;
}
export async function canShareCalendar(id: string, ctx: ShareContext): Promise<boolean> {
const [calendar] = await db
.select({
householdId: calendars.householdId,
ownerId: calendars.ownerId,
visibility: calendars.visibility,
})
.from(calendars)
.where(eq(calendars.id, id))
.limit(1);
return calendar ? canSeeCalendarRow(calendar, ctx) : false;
}
export async function canShareEvent(id: string, ctx: ShareContext): Promise<boolean> {
const [row] = await db
.select({
householdId: calendars.householdId,
ownerId: calendars.ownerId,
visibility: calendars.visibility,
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.id, id))
.limit(1);
return row ? canSeeCalendarRow(row, ctx) : false;
}
export async function loadCalendarForShare(
id: string,
ctx: PublicShareContext,
): Promise<CalendarShareData | null> {
const [calendar] = await db
.select({ id: calendars.id, name: calendars.name, color: calendars.color })
.from(calendars)
.where(eq(calendars.id, id))
.where(and(eq(calendars.id, id), eq(calendars.householdId, ctx.householdId)))
.limit(1);
if (!calendar) return null;
@@ -63,7 +104,10 @@ export async function loadCalendarForShare(id: string): Promise<CalendarShareDat
};
}
export async function loadEventForShare(id: string): Promise<EventShareData | null> {
export async function loadEventForShare(
id: string,
ctx: PublicShareContext,
): Promise<EventShareData | null> {
const [row] = await db
.select({
id: calendarEvents.id,
@@ -77,7 +121,7 @@ export async function loadEventForShare(id: string): Promise<EventShareData | nu
})
.from(calendarEvents)
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
.where(eq(calendarEvents.id, id))
.where(and(eq(calendarEvents.id, id), eq(calendars.householdId, ctx.householdId)))
.limit(1);
if (!row) return null;

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