Compare commits

...
20 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
84 changed files with 3094 additions and 1022 deletions
+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"
+1 -2
View File
@@ -8,8 +8,7 @@
"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'"
+5
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
+29
View File
@@ -1,5 +1,34 @@
# 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)
+28 -1
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
@@ -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
@@ -74,7 +74,7 @@ The container runs database migrations automatically on start. The first user to
Pin `FAMAPP_IMAGE` in `deploy/.env` after the first deploy:
```
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.4.7
FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.4.7
```
---
@@ -155,4 +155,4 @@ See [`CLAUDE.md`](CLAUDE.md) for the full architecture brief and [`docs/decision
| 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 — CI builds and pushes `ghcr.io/ginnoir/famapp:vX.Y.Z` automatically.
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.
+2 -2
View File
@@ -35,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
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0|' .env
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
+1 -1
View File
@@ -20,7 +20,7 @@ volumes:
services:
famapp:
image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest}
image: ${FAMAPP_IMAGE:-registry.ginnoir.com/ginnoir/famapp:latest}
pull_policy: ${FAMAPP_PULL_POLICY:-always}
restart: unless-stopped
environment:
+10 -10
View File
@@ -98,15 +98,15 @@ Only needed on the machine that cuts releases (`pnpm release`).
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. `ghcr.io/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`) |
| 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 -->
+6 -6
View File
@@ -15,11 +15,11 @@ pnpm release:patch # or :minor / :major
# Requires GITHUB_TOKEN in .env
```
CI (`release.yml`) then builds and pushes the Docker image to GHCR:
Gitea Actions (`release.yml`) then builds and pushes the Docker image to the self-hosted registry:
- `ghcr.io/ginnoir/famapp:v0.x.y`
- `ghcr.io/ginnoir/famapp:0.x` (minor alias)
- `ghcr.io/ginnoir/famapp:latest`
- `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
@@ -27,7 +27,7 @@ On the home server, in `/srv/famapp/deploy/`:
```bash
# Pin the new tag
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.x.y|' .env
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
@@ -60,7 +60,7 @@ docker compose exec famapp-db pg_isready -U famapp -d famapp
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=ghcr.io/ginnoir/famapp:v0.x.y|' .env
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.x.y|' .env
```
3. Restart the container:
```bash
@@ -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.
+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`
+9 -2
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.4.10",
"version": "0.5.3",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -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",
@@ -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",
+58 -3
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@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
@@ -150,6 +168,9 @@ importers:
release-it:
specifier: ^20.2.0
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:
@@ -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'}
@@ -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:
@@ -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
@@ -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:
@@ -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:
+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
+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;
}
}
+3
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 = {
@@ -168,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>
);
}
-9
View File
@@ -21,12 +21,3 @@ export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; clas
</span>
);
}
export function BrandWordmark({ className }: { className?: string }) {
return (
<span className={cn("brand", className)}>
<BrandMark />
<span className="brand-name">famapp</span>
</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>
+7 -3
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";
@@ -32,7 +33,9 @@ export function ShareButton({
setShareUrl(result.url);
setOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share link");
const message = err instanceof Error ? err.message : "Failed to create share link";
setError(message);
toast.error(message);
}
});
}
@@ -41,6 +44,7 @@ export function ShareButton({
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
});
}
@@ -49,7 +53,7 @@ export function ShareButton({
<>
<div className="flex flex-col items-end gap-1">
<Button variant="outline" onClick={share} disabled={isPending}>
<Link />
<Link data-icon="inline-start" />
Share
</Button>
{error && <p className="text-xs text-red-500">{error}</p>}
@@ -67,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>
+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" &&
+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");
}
}
+7 -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);
+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;
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
deleteCareSchedule,
scheduleOnCalendar,
@@ -35,7 +35,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? "");
const [reminderMinutes, setReminderMinutes] = useState("");
const [calendarSuccess, setCalendarSuccess] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
@@ -56,6 +55,7 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
router.refresh();
} catch {
setFormError("Failed to save schedule.");
toast.error("Failed to save schedule");
}
});
}
@@ -84,10 +84,11 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined,
});
setCalendarOpenId(null);
setCalendarSuccess(scheduleId);
setTimeout(() => setCalendarSuccess(null), 4000);
toast.success("Event added to calendar");
} catch (err) {
setFormError(err instanceof Error ? err.message : "Failed to add to calendar.");
const message = err instanceof Error ? err.message : "Failed to add to calendar.";
setFormError(message);
toast.error(message);
}
});
}
@@ -189,7 +190,7 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
title={s.enabled ? "Disable" : "Enable"}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
s.enabled
? "border-green-400 text-green-700 dark:text-green-400"
? "border-[var(--c-success)] text-[var(--c-success)]"
: "border-[var(--ink-faint)] text-[var(--ink-mute)]"
}`}
>
@@ -256,15 +257,6 @@ export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
</div>
</div>
)}
{calendarSuccess === s.id && (
<p className="text-xs text-green-600 dark:text-green-400 ml-2">
Event added to calendar.{" "}
<Link href="/calendar" className="underline">
View in calendar
</Link>
</p>
)}
</div>
))}
</div>
+134 -117
View File
@@ -2,9 +2,14 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
addContainerImage,
deleteContainer,
@@ -32,9 +37,14 @@ export function ContainerDetail({ container, shareLinks }: Props) {
function handleDelete() {
startTransition(async () => {
await deleteContainer({ id: container.id });
router.push("/garden");
router.refresh();
try {
await deleteContainer({ id: container.id });
toast.success("Container deleted");
router.push("/garden");
router.refresh();
} catch {
toast.error("Failed to delete container");
}
});
}
@@ -53,6 +63,7 @@ export function ContainerDetail({ container, shareLinks }: Props) {
router.refresh();
} catch {
setGalleryError("Image upload failed.");
toast.error("Image upload failed");
} finally {
setUploadingImage(false);
e.target.value = "";
@@ -137,130 +148,136 @@ export function ContainerDetail({ container, shareLinks }: Props) {
)}
{/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{(["info", "gallery"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-2 text-sm font-medium capitalize transition-colors ${
tab === t
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)] hover:text-[var(--ink)]"
}`}
>
{t}
</button>
))}
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
<TabsList variant="line">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<Separator />
{/* Info */}
{tab === "info" && (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<a
href={`/garden/plants/new?containerId=${container.id}`}
className="btn btn-ghost btn-sm"
>
+ Add plant
</a>
<TabsContent value="info">
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<a
href={`/garden/plants/new?containerId=${container.id}`}
className="btn btn-ghost btn-sm"
>
+ Add plant
</a>
</div>
{container.plants.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{container.plants.map((p) => (
<a
key={p.id}
href={`/garden/plants/${p.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{p.primaryImageUrl && (
<img
src={p.primaryImageUrl}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
)}
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.scientificName && (
<p className="text-xs text-[var(--ink-mute)] italic">
{p.scientificName}
</p>
)}
</div>
<Badge
variant={
p.healthStatus === "healthy"
? "default"
: p.healthStatus === "sick"
? "destructive"
: "secondary"
}
className="ml-auto capitalize"
>
{p.healthStatus}
</Badge>
</div>
</a>
))}
</div>
)}
</div>
{container.plants.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{container.plants.map((p) => (
<a
key={p.id}
href={`/garden/plants/${p.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{p.primaryImageUrl && (
</TabsContent>
<TabsContent value="gallery">
<div className="flex flex-col gap-4">
{container.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="relative">
{uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
<div className="grid grid-cols-3 gap-2">
{container.images.map((url) => (
<div key={url} className="relative group">
<img
src={p.primaryImageUrl}
src={url}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
className="w-full aspect-square object-cover rounded-lg"
/>
)}
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.scientificName && (
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as cover"
className={`text-lg leading-none ${url === container.coverImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === container.coverImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Cover
</span>
)}
</div>
<span
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
>
{p.healthStatus}
</span>
</div>
</a>
))}
</div>
)}
</div>
)}
{/* Gallery */}
{tab === "gallery" && (
<div className="flex flex-col gap-4">
{container.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{container.images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as cover"
className={`text-lg leading-none ${url === container.coverImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === container.coverImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Cover
</span>
)}
))}
</div>
))}
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{container.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
</div>
)}
<span className="text-xs text-[var(--ink-mute)]">
{container.images.length}/10 photos
</span>
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{container.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
)}
<span className="text-xs text-[var(--ink-mute)]">
{container.images.length}/10 photos
</span>
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
@@ -3,6 +3,14 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Container } from "lucide-react";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { ContainerForm } from "./container-form";
import type { ContainerDto } from "../server/queries";
@@ -35,9 +43,15 @@ export function ContainerList({ containers }: Props) {
)}
{containers.length === 0 && !showNew && (
<p className="text-sm text-[var(--ink-mute)]">
No containers yet. Add one to start organising your plants.
</p>
<Empty className="border-none">
<EmptyHeader>
<EmptyMedia variant="icon">
<Container />
</EmptyMedia>
<EmptyTitle>No containers yet</EmptyTitle>
<EmptyDescription>Add a container to start organising your plants.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+151 -133
View File
@@ -3,12 +3,17 @@
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
import type { CalendarDto } from "../server/calendar-bridge";
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CareHistoryList } from "./care-history-list";
import { CareLogForm } from "./care-log-form";
import { CareScheduleEditor } from "./care-schedule-editor";
@@ -41,10 +46,10 @@ function InfoRow({
);
}
function healthBadgeClass(status: string): string {
if (status === "healthy") return "badge-success";
if (status === "sick") return "badge-danger";
return "badge-warning";
function healthBadgeVariant(status: string): "default" | "destructive" | "secondary" {
if (status === "healthy") return "default";
if (status === "sick") return "destructive";
return "secondary";
}
export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) {
@@ -57,9 +62,14 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
function handleDelete() {
startTransition(async () => {
await deletePlant({ id: plant.id });
router.push("/garden");
router.refresh();
try {
await deletePlant({ id: plant.id });
toast.success("Plant deleted");
router.push("/garden");
router.refresh();
} catch {
toast.error("Failed to delete plant");
}
});
}
@@ -78,6 +88,7 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
router.refresh();
} catch {
setGalleryError("Image upload failed.");
toast.error("Image upload failed");
} finally {
setUploadingImage(false);
e.target.value = "";
@@ -116,11 +127,13 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
<p className="text-sm text-[var(--ink-mute)] italic mt-0.5">{plant.scientificName}</p>
)}
<div className="flex flex-wrap gap-2 mt-1">
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
<Badge variant={healthBadgeVariant(plant.healthStatus)} className="capitalize">
{plant.healthStatus}
</span>
</Badge>
{plant.growthStage && (
<span className="text-xs badge badge-outline capitalize">{plant.growthStage}</span>
<Badge variant="outline" className="capitalize">
{plant.growthStage}
</Badge>
)}
</div>
</div>
@@ -160,135 +173,140 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLi
</div>
{/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{(["info", "gallery", "care"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-2 text-sm font-medium capitalize transition-colors ${
tab === t
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)]"
}`}
>
{t}
</button>
))}
</div>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-0">
<TabsList variant="line">
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
<TabsTrigger value="care">Care</TabsTrigger>
</TabsList>
<Separator />
{/* Info */}
{tab === "info" && (
<div className="flex flex-col gap-2">
<InfoRow label="Category" value={plant.category} />
{plant.containerName && (
<InfoRow label="Container">
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
{plant.containerName}
</Link>
</InfoRow>
)}
<InfoRow label="Acquired" value={plant.acquisitionDate} />
<InfoRow label="Sunlight" value={plant.sunlight} />
<InfoRow label="Watering" value={plant.wateringNotes} />
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
<InfoRow label="Notes" value={plant.notes} />
{plant.recentCareLogs.length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
Recent care
</p>
<ul className="text-sm space-y-1">
{plant.recentCareLogs.map((log) => (
<li key={log.id} className="flex gap-2 flex-wrap">
<span className="capitalize">{log.careType}</span>
<span className="text-[var(--ink-mute)]">
{new Date(log.performedAt).toLocaleDateString()}
</span>
{log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Gallery */}
{tab === "gallery" && (
<div className="flex flex-col gap-4">
{plant.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{plant.images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as primary"
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === plant.primaryImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Primary
</span>
)}
</div>
))}
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{plant.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
<TabsContent value="info">
<div className="flex flex-col gap-2">
<InfoRow label="Category" value={plant.category} />
{plant.containerName && (
<InfoRow label="Container">
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
{plant.containerName}
</Link>
</InfoRow>
)}
<InfoRow label="Acquired" value={plant.acquisitionDate} />
<InfoRow label="Sunlight" value={plant.sunlight} />
<InfoRow label="Watering" value={plant.wateringNotes} />
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
<InfoRow label="Notes" value={plant.notes} />
{plant.recentCareLogs.length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
Recent care
</p>
<ul className="text-sm space-y-1">
{plant.recentCareLogs.map((log) => (
<li key={log.id} className="flex gap-2 flex-wrap">
<span className="capitalize">{log.careType}</span>
<span className="text-[var(--ink-mute)]">
{new Date(log.performedAt).toLocaleDateString()}
</span>
{log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>}
</li>
))}
</ul>
</div>
)}
<span className="text-xs text-[var(--ink-mute)]">{plant.images.length}/10 photos</span>
</div>
</div>
)}
</TabsContent>
{/* Care */}
{tab === "care" && (
<div className="flex flex-col gap-6">
<CareScheduleEditor plantId={plant.id} schedules={careSchedules} calendars={calendars} />
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
Log care
</p>
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
<TabsContent value="gallery">
<div className="flex flex-col gap-4 relative">
{plant.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="relative">
{uploadingImage && <Skeleton className="absolute inset-0 z-10 rounded-lg" />}
<div className="grid grid-cols-3 gap-2">
{plant.images.map((url) => (
<div key={url} className="relative group">
<img
src={url}
alt=""
className="w-full aspect-square object-cover rounded-lg"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as primary"
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === plant.primaryImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Primary
</span>
)}
</div>
))}
</div>
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{plant.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
)}
<span className="text-xs text-[var(--ink-mute)]">
{plant.images.length}/10 photos
</span>
</div>
</div>
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History
</p>
<CareHistoryList logs={careLogs} />
</TabsContent>
<TabsContent value="care">
<div className="flex flex-col gap-6">
<CareScheduleEditor
plantId={plant.id}
schedules={careSchedules}
calendars={calendars}
/>
<Separator />
<div>
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
Log care
</p>
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
</div>
<Separator />
<div>
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History
</p>
<CareHistoryList logs={careLogs} />
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+30 -12
View File
@@ -1,5 +1,15 @@
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import type { PlantListItemDto } from "../server/queries";
import { Sprout } from "lucide-react";
type Props = {
plants: PlantListItemDto[];
@@ -13,21 +23,21 @@ function daysAgo(isoString: string): string {
return `${days}d ago`;
}
function healthBadgeClass(status: string): string {
if (status === "healthy") return "badge-success";
if (status === "sick") return "badge-danger";
return "badge-warning";
}
export function PlantList({ plants }: Props) {
if (plants.length === 0) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>
<Empty className="border-none py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<Sprout />
</EmptyMedia>
<EmptyTitle>No plants yet</EmptyTitle>
<EmptyDescription>Add your first plant to get started.</EmptyDescription>
</EmptyHeader>
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
Add your first plant
</Link>
</div>
</Empty>
);
}
@@ -82,9 +92,17 @@ export function PlantList({ plants }: Props) {
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{plant.name}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
<Badge
variant={
plant.healthStatus === "healthy"
? "default"
: plant.healthStatus === "sick"
? "destructive"
: "secondary"
}
>
{plant.healthStatus}
</span>
</Badge>
{plant.lastWateredAt && (
<span className="text-xs text-[var(--ink-mute)]">
Watered {daysAgo(plant.lastWateredAt)}
@@ -92,7 +110,7 @@ export function PlantList({ plants }: Props) {
)}
</div>
{plant.hasOverdueCare && (
<p className="text-xs text-amber-600 mt-0.5">Care overdue</p>
<p className="text-xs text-[var(--warn)] mt-0.5">Care overdue</p>
)}
</div>
</div>
@@ -12,9 +12,8 @@ const CARE_ICONS: Record<string, string> = {
};
function urgencyLabel(days: number): { text: string; cls: string } {
if (days < 0)
return { text: `${Math.abs(days)}d overdue`, cls: "text-red-600 dark:text-red-400" };
if (days === 0) return { text: "due today", cls: "text-amber-600 dark:text-amber-400" };
if (days < 0) return { text: `${Math.abs(days)}d overdue`, cls: "text-[var(--bad)]" };
if (days === 0) return { text: "due today", cls: "text-[var(--warn)]" };
return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" };
}
@@ -108,7 +107,7 @@ export function GardenOverviewWidget({ stats }: { stats: GardenOverviewStats })
</div>
{stats.overdueCount > 0 && (
<div className="flex flex-col items-center">
<span className="text-2xl font-bold leading-none text-red-500">
<span className="text-2xl font-bold leading-none text-[var(--bad)]">
{stats.overdueCount}
</span>
<span className="text-xs text-[var(--ink-mute)] mt-0.5">overdue</span>
+6 -2
View File
@@ -4,6 +4,8 @@ import { db } from "@/lib/db";
import { registerItemToggleHook } from "../_core/registry";
import type { ModuleManifest, WidgetContext } from "../_core/module";
import {
canShareContainer,
canSharePlant,
loadContainerForShare,
loadPlantForShare,
type ContainerShareData,
@@ -45,7 +47,8 @@ const gardenManifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: (id) => loadPlantForShare(id),
canShareEntity: canSharePlant,
loadForShare: loadPlantForShare,
renderSharedView: ({ data }) => {
const d = data as PlantShareData;
return (
@@ -109,7 +112,8 @@ const gardenManifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchContainers },
resolveUrl: (id) => `/garden/containers/${id}`,
loadForShare: (id) => loadContainerForShare(id),
canShareEntity: canShareContainer,
loadForShare: loadContainerForShare,
renderSharedView: ({ data }) => {
const d = data as ContainerShareData;
return (
-16
View File
@@ -451,22 +451,6 @@ export async function logCare(input: z.input<typeof careLogInput>) {
return log;
}
export async function deleteCareLog(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({ id: gardenCareLogs.id, plantId: gardenCareLogs.plantId })
.from(gardenCareLogs)
.where(and(eq(gardenCareLogs.id, parsed.id), eq(gardenCareLogs.householdId, household.id)))
.limit(1);
if (!row) throw new Error("Forbidden");
await db.delete(gardenCareLogs).where(eq(gardenCareLogs.id, parsed.id));
revalidatePath(`/garden/plants/${row.plantId}`);
}
// ─── Care schedule actions ────────────────────────────────────────────────────
const careScheduleInput = z.object({
@@ -1,4 +1,3 @@
export { addItem as addListItem } from "@/modules/lists/server/actions";
export { getList, listLists } from "@/modules/lists/server/queries";
import { addItem } from "@/modules/lists/server/actions";
-78
View File
@@ -110,16 +110,6 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
};
}
export async function canAccessContainer(id: string, householdId: string): Promise<boolean> {
const [row] = await db
.select({ id: gardenContainers.id })
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
.limit(1);
return !!row;
}
export async function searchContainers(query: string, householdId: string) {
const rows = await db
.select({ id: gardenContainers.id, name: gardenContainers.name })
@@ -430,74 +420,6 @@ export async function getCareSchedules(plantId: string): Promise<CareScheduleDto
});
}
export type OverduePlantDto = {
id: string;
name: string;
primaryImageUrl: string | null;
mostOverdueAt: Date;
};
export async function getOverduePlants(householdId: string): Promise<OverduePlantDto[]> {
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
mostOverdueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}
export type CareDueSoonDto = {
id: string;
name: string;
primaryImageUrl: string | null;
nextDueAt: Date;
};
export async function getCareDueSoon(
householdId: string,
withinDays: number,
): Promise<CareDueSoonDto[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + withinDays);
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
nextDueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, cutoff),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}
// ─── Widget queries ───────────────────────────────────────────────────────────
export type CareDueWidgetRow = {
+32 -5
View File
@@ -1,5 +1,6 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { gardenContainers, gardenPlants } from "../schema";
export type ContainerShareData = {
@@ -27,7 +28,30 @@ export type PlantShareData = {
sunlight: string | null;
};
export async function loadPlantForShare(id: string): Promise<PlantShareData | null> {
export async function canSharePlant(id: string, ctx: ShareContext): Promise<boolean> {
const [plant] = await db
.select({ id: gardenPlants.id })
.from(gardenPlants)
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, ctx.householdId)))
.limit(1);
return !!plant;
}
export async function canShareContainer(id: string, ctx: ShareContext): Promise<boolean> {
const [container] = await db
.select({ id: gardenContainers.id })
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, ctx.householdId)))
.limit(1);
return !!container;
}
export async function loadPlantForShare(
id: string,
ctx: PublicShareContext,
): Promise<PlantShareData | null> {
const [plant] = await db
.select({
id: gardenPlants.id,
@@ -44,18 +68,21 @@ export async function loadPlantForShare(id: string): Promise<PlantShareData | nu
sunlight: gardenPlants.sunlight,
})
.from(gardenPlants)
.where(eq(gardenPlants.id, id))
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, ctx.householdId)))
.limit(1);
if (!plant) return null;
return plant;
}
export async function loadContainerForShare(id: string): Promise<ContainerShareData | null> {
export async function loadContainerForShare(
id: string,
ctx: PublicShareContext,
): Promise<ContainerShareData | null> {
const [container] = await db
.select()
.from(gardenContainers)
.where(eq(gardenContainers.id, id))
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, ctx.householdId)))
.limit(1);
if (!container) return null;
@@ -67,7 +94,7 @@ export async function loadContainerForShare(id: string): Promise<ContainerShareD
scientificName: gardenPlants.scientificName,
})
.from(gardenPlants)
.where(and(eq(gardenPlants.containerId, id)));
.where(and(eq(gardenPlants.containerId, id), eq(gardenPlants.householdId, ctx.householdId)));
return {
id: container.id,
+1 -1
View File
@@ -102,7 +102,7 @@ export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]>
}
}
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null> {
async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null> {
const cutoff = new Date(Date.now() - CACHE_TTL_MS);
const [cached] = await db
.select()
+133 -47
View File
@@ -108,7 +108,7 @@ export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
onChange={(event) => setList({ ...list, name: event.target.value })}
onBlur={commitListName}
/>
<span className="badge">
<span className="badge whitespace-nowrap shrink-0">
{list.type} · {list.openCount} open
</span>
</div>
@@ -194,57 +194,143 @@ function ListItemRow({
onCommit: () => void;
onRemove: () => void;
}) {
const swipeStart = useRef<number | null>(null);
const swipeOrigin = useRef<{ x: number; y: number } | null>(null);
const isScrolling = useRef(false);
const [swipeX, setSwipeX] = useState(0);
const THRESHOLD = 80; // px to commit the swipe action
const MAX_SWIPE = 100; // px maximum visual displacement
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
if ((e.target as HTMLElement).closest("button, input")) return;
e.currentTarget.setPointerCapture(e.pointerId);
swipeOrigin.current = { x: e.clientX, y: e.clientY };
isScrolling.current = false;
setSwipeX(0);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!swipeOrigin.current) return;
const dx = e.clientX - swipeOrigin.current.x;
const dy = Math.abs(e.clientY - swipeOrigin.current.y);
// If the gesture is primarily vertical, treat as scroll and abort swipe.
if (!isScrolling.current && dy > Math.abs(dx) && dy > 8) {
isScrolling.current = true;
setSwipeX(0);
return;
}
if (isScrolling.current) return;
setSwipeX(Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, dx)));
}
function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) {
if (swipeOrigin.current && !isScrolling.current) {
const dx = e.clientX - swipeOrigin.current.x;
if (dx > THRESHOLD) onToggle(!item.done); // right → complete / uncomplete
if (dx < -THRESHOLD) onRemove(); // left → delete
}
swipeOrigin.current = null;
setSwipeX(0);
}
function handlePointerCancel() {
swipeOrigin.current = null;
setSwipeX(0);
}
// Background tint that deepens as the swipe approaches the threshold.
const swipeProgress = Math.abs(swipeX) / MAX_SWIPE;
const swipeBg =
swipeX > 16
? `color-mix(in oklab, var(--ok) ${swipeProgress * 55}%, var(--card))`
: swipeX < -16
? `color-mix(in oklab, var(--bad) ${swipeProgress * 55}%, var(--card))`
: undefined;
return (
<div
className={`list-row ${item.done ? "checked" : ""} group/row`}
onPointerDown={(event) => {
swipeStart.current = event.clientX;
}}
onPointerUp={(event) => {
const start = swipeStart.current;
if (start !== null && event.clientX - start < -60) onRemove();
swipeStart.current = null;
}}
className="relative overflow-hidden border-b-[0.5px] last:border-b-0 group/row"
style={{ borderColor: "var(--hair)", background: swipeBg, touchAction: "pan-y" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
>
<button
type="button"
aria-label={`Complete ${item.text}`}
aria-pressed={item.done}
className={`checkbox ${item.done ? "on" : ""}`}
onClick={() => onToggle(!item.done)}
{/* Swipe affordance icons revealed behind the sliding content */}
<div
className="absolute inset-y-0 left-3 flex items-center"
aria-hidden
style={{ opacity: Math.max(0, swipeX - 16) / (MAX_SWIPE - 16), color: "var(--ok)" }}
>
{item.done && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12l5 5L20 7" />
</svg>
)}
</button>
<Input
aria-label={`${item.text} text`}
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px]"
value={item.text}
onChange={(event) => onEdit(event.target.value)}
onBlur={onCommit}
/>
<Button
size="icon-sm"
variant="ghost"
aria-label={`Delete ${item.text}`}
onClick={onRemove}
className="opacity-0 group-hover/row:opacity-100 transition-opacity"
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12l5 5L20 7" />
</svg>
</div>
<div
className="absolute inset-y-0 right-3 flex items-center"
aria-hidden
style={{ opacity: Math.max(0, -swipeX - 16) / (MAX_SWIPE - 16), color: "var(--bad)" }}
>
<Trash2 className="size-3.5" />
</Button>
<Trash2 size={16} />
</div>
{/* Sliding content row */}
<div
className={`flex items-center gap-3 px-[14px] py-[10px] hover:bg-[var(--shade)] ${item.done ? "checked" : ""}`}
style={{
transform: `translateX(${swipeX}px)`,
transition: swipeX === 0 ? "transform 0.22s ease-out" : "none",
}}
>
<button
type="button"
aria-label={`Complete ${item.text}`}
aria-pressed={item.done}
className={`checkbox ${item.done ? "on" : ""}`}
onClick={() => onToggle(!item.done)}
>
{item.done && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12l5 5L20 7" />
</svg>
)}
</button>
<Input
aria-label={`${item.text} text`}
className={`!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px] ${item.done ? "line-through text-[var(--ink-faint)]" : ""}`}
value={item.text}
onChange={(event) => onEdit(event.target.value)}
onBlur={onCommit}
/>
{/* Always visible on touch (list-row-delete makes opacity-0 override to 1 on mobile) */}
<Button
size="icon-sm"
variant="ghost"
aria-label={`Delete ${item.text}`}
onClick={onRemove}
className="list-row-delete opacity-0 group-hover/row:opacity-100 transition-opacity"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
}
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries";
import { loadListForShare, type ListShareData } from "./server/share-queries";
import { canShareList, loadListForShare, type ListShareData } from "./server/share-queries";
import { ListSharedView } from "./components/shared-view";
import { ListWidget } from "./components/list-widget";
@@ -34,7 +34,8 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read", "write"] },
search: { search: searchLists },
resolveUrl: (id) => `/lists/${id}`,
loadForShare: (id) => loadListForShare(id),
canShareEntity: canShareList,
loadForShare: loadListForShare,
renderSharedView: ({ data, capabilities, token }) => (
<ListSharedView data={data as ListShareData} canWrite={capabilities.write} token={token} />
),
-13
View File
@@ -8,7 +8,6 @@ import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { fireItemToggleHooks } from "@/modules/_core/registry";
import { listItems, lists } from "../schema";
import { getOrCreateDefaultList } from "./defaults";
import { canAccessList, getList } from "./queries";
import { notifyListChanged } from "./realtime";
@@ -122,18 +121,6 @@ export async function addItem(input: z.input<typeof itemInput>) {
return getList(parsed.listId);
}
export async function addItemToDefaultList(input: { type: string; text: string }) {
const parsed = z
.object({
type: listInput.shape.type,
text: itemInput.shape.text,
})
.parse(input);
const { household } = await getCurrentSession();
const list = await getOrCreateDefaultList({ householdId: household.id, type: parsed.type });
return addItem({ listId: list.id, text: parsed.text });
}
export async function toggleItem(input: { id: string; done?: boolean }) {
const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input);
const { household, user } = await getCurrentSession();
-21
View File
@@ -1,6 +1,5 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { households } from "@/modules/_core/schema";
import { lists } from "../schema";
const DEFAULT_LISTS = [
@@ -14,26 +13,6 @@ export async function ensureDefaultListsForHousehold(householdId: string) {
}
}
export async function ensureDefaultLists() {
const householdRows = await db.select({ id: households.id }).from(households);
for (const household of householdRows) {
await ensureDefaultListsForHousehold(household.id);
}
}
export async function getOrCreateDefaultList({
householdId,
type,
}: {
householdId: string;
type: "shopping" | "task" | string;
}) {
const defaults = DEFAULT_LISTS.find((list) => list.type === type);
const name = defaults?.name ?? type;
return ensureDefaultList({ householdId, type, name });
}
async function ensureDefaultList({
householdId,
type,
+17 -3
View File
@@ -1,5 +1,6 @@
import { asc, eq } from "drizzle-orm";
import { and, asc, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { listItems, lists } from "../schema";
export type ListShareItem = {
@@ -19,7 +20,20 @@ export type ListShareData = {
items: ListShareItem[];
};
export async function loadListForShare(id: string): Promise<ListShareData | null> {
export async function canShareList(id: string, ctx: ShareContext): Promise<boolean> {
const [list] = await db
.select({ id: lists.id })
.from(lists)
.where(and(eq(lists.id, id), eq(lists.householdId, ctx.householdId)))
.limit(1);
return !!list;
}
export async function loadListForShare(
id: string,
ctx: PublicShareContext,
): Promise<ListShareData | null> {
const [list] = await db
.select({
id: lists.id,
@@ -28,7 +42,7 @@ export async function loadListForShare(id: string): Promise<ListShareData | null
householdId: lists.householdId,
})
.from(lists)
.where(eq(lists.id, id))
.where(and(eq(lists.id, id), eq(lists.householdId, ctx.householdId)))
.limit(1);
if (!list) return null;
+3 -2
View File
@@ -1,7 +1,7 @@
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { listWidgetNotes, searchNotes } from "./server/queries";
import { loadNoteForShare, type NoteShareData } from "./server/share-queries";
import { canShareNote, loadNoteForShare, type NoteShareData } from "./server/share-queries";
import { NoteSharedView } from "./components/shared-view";
const notesWidgetConfigSchema = z.object({
@@ -72,7 +72,8 @@ const manifest: ModuleManifest = {
reminder: { canRemind: true },
search: { search: searchNotes },
resolveUrl: (id) => `/notes/${id}`,
loadForShare: (id) => loadNoteForShare(id),
canShareEntity: canShareNote,
loadForShare: loadNoteForShare,
renderSharedView: ({ data }) => <NoteSharedView data={data as NoteShareData} />,
renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined;
+17 -3
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import type { PublicShareContext, ShareContext } from "@/modules/_core/module";
import { notes } from "../schema";
export type NoteShareData = {
@@ -10,7 +11,20 @@ export type NoteShareData = {
updatedAt: string;
};
export async function loadNoteForShare(id: string): Promise<NoteShareData | null> {
export async function canShareNote(id: string, ctx: ShareContext): Promise<boolean> {
const [note] = await db
.select({ id: notes.id })
.from(notes)
.where(and(eq(notes.id, id), eq(notes.householdId, ctx.householdId)))
.limit(1);
return !!note;
}
export async function loadNoteForShare(
id: string,
ctx: PublicShareContext,
): Promise<NoteShareData | null> {
const [note] = await db
.select({
id: notes.id,
@@ -20,7 +34,7 @@ export async function loadNoteForShare(id: string): Promise<NoteShareData | null
updatedAt: notes.updatedAt,
})
.from(notes)
.where(eq(notes.id, id))
.where(and(eq(notes.id, id), eq(notes.householdId, ctx.householdId)))
.limit(1);
if (!note) return null;
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { ensureEntityShareAuthorized } from "../../src/modules/_core/share-authorization";
import type { EntityTypeRegistration, ShareContext } from "../../src/modules/_core/module";
const ctx: ShareContext = {
householdId: "household-1",
userId: "user-1",
};
function registration(
canShareEntity?: EntityTypeRegistration["canShareEntity"],
): EntityTypeRegistration {
return {
type: "notes.note",
label: { singular: "Note", plural: "Notes" },
share: { canShare: true },
resolveUrl: (id) => `/notes/${id}`,
canShareEntity,
};
}
describe("ensureEntityShareAuthorized", () => {
it("rejects shareable entity types that do not provide entity authorization", async () => {
await assert.rejects(
() => ensureEntityShareAuthorized(registration(), "note-1", ctx),
/does not support share authorization/,
);
});
it("rejects entities denied by their module authorization callback", async () => {
await assert.rejects(
() =>
ensureEntityShareAuthorized(
registration(async () => false),
"note-1",
ctx,
),
/not allowed to share this entity/,
);
});
it("allows entities approved by their module authorization callback", async () => {
await ensureEntityShareAuthorized(
registration(async () => true),
"note-1",
ctx,
);
});
});