Compare commits

..
18 Commits
Author SHA1 Message Date
ginnoir a09747c314 feat: journal dashboard widgets, agent polish, and edit-mode live previews
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m36s
Journal dashboard widgets and quick-add; rich-text quick-add dialogs.

Dashboard draft sync for live edit previews; assistant bubble + API tools.

Journal UX: stress slider, mood grid, query cap fix.
2026-07-04 22:03:45 -05:00
ginnoir 4a924a4107 feat(agent): add llm assistant chat with api tools (task 88)
OpenAI-compatible client with mock fallback, tool-calling loop, and /assistant UI.

Tools map to /api/v1/ endpoints per ADR 0006 direct-tools decision.
2026-07-04 19:46:09 -05:00
ginnoir 04ae809e07 feat(journal): add per-user mood journal module (task 86)
Ship journal entries with mood tracking, insights, and Recharts charts.

Adds v1 API endpoints per ADR 0005 and migration 0020_journal_entries.
2026-07-04 19:41:15 -05:00
ginnoir 8e2ddd6b72 docs: accept adr 0005 journal module decisions 2026-07-04 19:37:19 -05:00
ginnoir a4be5d5061 feat(notes): rich-text editor with tiptap (task 85)
Replace plain textarea with shared tiptap editor and sanitized html rendering.

Adds interactive checklists on read surfaces and mobile overflow fixes.
2026-07-04 19:25:15 -05:00
ginnoir 67f67525ff docs: accept adr 0004 rich-text editor decisions 2026-07-04 19:20:29 -05:00
ginnoir fb67692a08 docs: mark task 87 done in status 2026-07-04 19:14:00 -05:00
ginnoir e8d13bede8 feat: api v1 garden bangs routes and openapi 2026-07-04 19:13:49 -05:00
ginnoir d4304b005c feat: api v1 routes for calendar lists and notes 2026-07-04 19:03:08 -05:00
ginnoir ea5d1d050c feat: household api token auth foundation 2026-07-04 18:57:31 -05:00
ginnoir 2c0c31540e docs: accept adr 0006 api and agent architecture 2026-07-04 18:54:27 -05:00
ginnoir 5e711cfe0d docs: mark task 84 done, bugs batch complete 2026-07-04 18:23:48 -05:00
ginnoir d090200ec8 feat: shared back navigation on detail pages 2026-07-04 18:23:15 -05:00
ginnoir 1a1c080d18 docs: mark task 83 done in status 2026-07-04 18:21:04 -05:00
ginnoir 753f653b89 feat: bang edit and delete 2026-07-04 18:20:38 -05:00
ginnoir 1a03763e1b docs: mark task 82 done in status 2026-07-04 18:16:27 -05:00
ginnoir 700ee29f83 fix: garden container plant count 2026-07-04 18:15:54 -05:00
ginnoir 7eeb2f15bc fix: dashboard edit mode renders live widgets 2026-07-04 18:10:55 -05:00
182 changed files with 12755 additions and 902 deletions
+10
View File
@@ -31,6 +31,9 @@ NTFY_TOPIC=
# Logging
LOG_LEVEL=info
# Household API token (generated in Settings → Data & backups; stored hashed in DB)
# Clients send: Authorization: Bearer <token> on /api/v1/* requests
# GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN=
@@ -43,3 +46,10 @@ MINIO_BUCKET=garden
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
OPENPLANTBOOK_CLIENT_ID=
OPENPLANTBOOK_CLIENT_SECRET=
# LLM assistant (OpenAI-compatible — Ollama, vLLM, LiteLLM, etc.)
# Leave LLM_BASE_URL unset to use the built-in mock provider (CI / local without a model).
LLM_PROVIDER=openai
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=llama3.2
+25 -6
View File
@@ -4,6 +4,25 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
## Done
- **80 — Quick-add create UI** (commit `68a573c`). Quick-add actions open create dialogs/sheets in-place via `createKey` + `QuickAddCreateHost`; calendar, lists, notes, garden, bangs wired. E2E in `tests/e2e/quick-add.spec.ts`.
- **81 — Dashboard edit live widgets** (commit `7eeb2f1`). Edit mode server-pre-renders real widget content via `DashboardWidgetContent` + `widgetContents` map; no placeholder→content reflow on save. E2E in `tests/e2e/dashboard.spec.ts`.
- **82 — Garden container plant count** (commit `700ee29`). Fixed broken Drizzle subquery in `getContainer` and refactored `listContainers` plant count aggregation. E2E in `tests/e2e/garden.spec.ts`.
- **83 — Bang edit and delete** (commit `753f653`). `updateBang`/`deleteBang` server actions with activity log; edit/delete UI on bang widget recent rows. E2E in `tests/e2e/bangs.spec.ts`.
- **84 — Back navigation** (commit `d090200`). Shared `DetailBackLink` on garden/notes/lists detail, create, and edit pages plus household settings. E2E in `tests/e2e/navigation.spec.ts`.
- **87 — HTTP API v1** (commits `ea5d1d0`, `d4304b0`, `e8d13be`). Household bearer token + session auth; `/api/v1/` routes for calendar, lists, notes, garden, bangs; OpenAPI in `docs/api/openapi.yaml`; owner token UI in Settings → Data. Unit tests: `api-auth`, `api-v1-calendar`, `api-v1-auth`. ADR 0006. Run `pnpm db:migrate` for migration `0019_household_api_tokens`.
- **85 — Rich-text notes** (ADR 0004 accepted `67f6752`). TipTap editor in `src/components/rich-text/`; notes create/edit + index/widget/share surfaces; interactive checklists; DOMPurify sanitization; lazy plain-text→HTML migration; mobile overflow CSS; uploads `?scope=notes`. Unit tests: `rich-text.test.ts`. E2E: `tests/e2e/notes.spec.ts` (formatted note + 375px overflow).
- **86 — Journal module** (ADR 0005 accepted `8e2ddd6`). Per-user `journal_entries` schema + migration `0020_journal_entries.sql`; mood catalog (multi-select), optional stress/pills, rich-text body; index with calendar dots, entry editor, Recharts mood tracker, insights (streak/trends/correlations); `/api/v1/journal/entries` CRUD with bearer token auth; OpenAPI updated. Unit tests: `journal-analytics.test.ts`. E2E: `tests/e2e/journal.spec.ts`. Run `pnpm db:migrate` for migration `0020`.
- **88 — LLM agent chat** (ADR 0006). Opt-in floating chat bubble; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls; `POST /api/agent/chat`. Per-user `assistant_enabled` (default off). Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. Migration `0021`.
- **Journal + dashboard polish** (follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
@@ -46,15 +65,15 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
**Batch order:**
1. Bugs: tasks 8084 (quick-add, dashboard edit, garden count, bangs, back-nav)
2. API foundation: task 87 (+ ADR 0006)
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
5. LLM agent chat: task 88
1. ~~Bugs: tasks 8084~~ — done
2. ~~API foundation: task 87 (+ ADR 0006)~~ — done
3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done
4. ~~Journal: task 86 (+ ADR 0005), including journal API endpoints~~ — done
5. ~~LLM agent chat: task 88~~ — done
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519.
**How to resume:** Read AGENTS.md / CLAUDE.md / STATUS.md, open the next unchecked task in `docs/tasks/80``88`, stop at acceptance criteria.
**How to resume:** Phase 9 P1 batch complete. Next work is P2/P3 Gitea backlog or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
## Development login/testing notes
+96
View File
@@ -0,0 +1,96 @@
# famapp HTTP API (v1)
REST JSON API under `/api/v1/`. Full specification: [`openapi.yaml`](openapi.yaml).
## Authentication
Every endpoint accepts **either**:
- Auth.js session cookie (browser login), or
- `Authorization: Bearer <household-api-token>` header (recommended for external clients)
Unauthorized requests return `401` with `{ "error": "Unauthorized" }`.
Generate bearer tokens in **Settings → API tokens**. Example:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" https://fam.ginnoir.com/api/v1/calendars
```
## OpenAPI
See [`openapi.yaml`](openapi.yaml) for paths, request/response schemas, and the `bearerAuth` security scheme. Import into Swagger UI, Postman, or your HTTP client of choice.
## Endpoints
### Calendars
| Method | Path | Description |
| ------ | ----------------------- | -------------------------------------------------------- |
| GET | `/api/v1/calendars` | List visible calendars |
| POST | `/api/v1/calendars` | Create calendar (`name`, optional `color`, `visibility`) |
| GET | `/api/v1/calendars/:id` | Get calendar |
| PATCH | `/api/v1/calendars/:id` | Update `name`, `visibility`, `color` |
| DELETE | `/api/v1/calendars/:id` | Delete calendar |
### Events
| Method | Path | Description |
| ------ | --------------------------------------- | --------------------------------------------------------------------- |
| GET | `/api/v1/events?from=&to=&calendarIds=` | List events in range (`calendarIds` = `all` or comma-separated UUIDs) |
| POST | `/api/v1/events` | Create event |
| GET | `/api/v1/events/:id` | Get event |
| PATCH | `/api/v1/events/:id` | Update event |
| DELETE | `/api/v1/events/:id` | Delete event |
### Lists
| Method | Path | Description |
| ------ | --------------------------------- | ------------------------------------------------------------- |
| GET | `/api/v1/lists?type=` | List lists (optional `type` filter, e.g. `shopping`, `tasks`) |
| POST | `/api/v1/lists` | Create list (`type`, `name`) |
| GET | `/api/v1/lists/:id` | Get list with items |
| PATCH | `/api/v1/lists/:id` | Update `name` or `archived` |
| DELETE | `/api/v1/lists/:id` | Archive list |
| GET | `/api/v1/lists/:id/items` | List items |
| POST | `/api/v1/lists/:id/items` | Add item |
| PATCH | `/api/v1/lists/:id/items/:itemId` | Update or toggle item (`done`) |
| DELETE | `/api/v1/lists/:id/items/:itemId` | Delete item |
### Notes
| Method | Path | Description |
| ------ | ------------------- | ----------- |
| GET | `/api/v1/notes` | List notes |
| POST | `/api/v1/notes` | Create note |
| GET | `/api/v1/notes/:id` | Get note |
| PATCH | `/api/v1/notes/:id` | Update note |
| DELETE | `/api/v1/notes/:id` | Delete note |
### Garden
| Method | Path | Description |
| ------ | ------------------------------- | -------------------------------------- |
| GET | `/api/v1/garden/containers` | List containers |
| POST | `/api/v1/garden/containers` | Create container |
| GET | `/api/v1/garden/containers/:id` | Get container with plants |
| PATCH | `/api/v1/garden/containers/:id` | Update container |
| DELETE | `/api/v1/garden/containers/:id` | Delete container |
| GET | `/api/v1/garden/plants` | List plants (optional `?containerId=`) |
| POST | `/api/v1/garden/plants` | Create plant |
| GET | `/api/v1/garden/plants/:id` | Get plant detail |
| PATCH | `/api/v1/garden/plants/:id` | Update plant |
| DELETE | `/api/v1/garden/plants/:id` | Delete plant |
### Bangs
| Method | Path | Description |
| ------ | ------------------- | ------------------------------------------------ |
| GET | `/api/v1/bangs` | Stats: `total` + `recent` (optional `?limit=10`) |
| POST | `/api/v1/bangs` | Record bang (optional `occurredOn` YYYY-MM-DD) |
| PATCH | `/api/v1/bangs/:id` | Update `occurredOn` |
| DELETE | `/api/v1/bangs/:id` | Delete bang |
## Bearer token visibility
Bearer tokens see **household-visible calendars only** (private calendars are hidden). Mutations on calendars require ownership (session) or household visibility (bearer). Activity log records `actorId: null` for bearer mutations.
File diff suppressed because it is too large Load Diff
+77 -4
View File
@@ -1,16 +1,89 @@
# 0004 — Rich-text editor library and storage format
Date: 2026-07-03
Status: proposed
Status: accepted (2026-07-04)
## Context
Journal (task 86) and notes overhaul (task 85) need a shared rich-text editor with emoji support. Research and choose an existing library (e.g. TipTap/ProseMirror-family) rather than building one. Gitea: [#8](https://gitea.ginnoir.com/ginnoir/famapp/issues/8) (epic [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7)).
Notes (task 85) and journal (task 86) need a shared rich-text editor with emoji and modern formatting. Notes today use a plain `<textarea>` and a minimal custom markdown preview (`#` headings, `-` lists only). Body is stored as plain text in `notes.body`. Mobile horizontal overflow is a known bug (Gitea #5). Gitea epic [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7).
## What we decided (2026-07-04)
| # | Topic | Choice |
| --- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Editor library | **TipTap** (ProseMirror family) — WYSIWYG, not home-grown |
| 2 | Storage format | **HTML** in existing `notes.body` text column; sanitize on every render |
| 3 | v1 formatting | **Full-featured**: headings, bold/italic, bullet + numbered lists, links, emoji, blockquotes, code blocks, tables, checklists, images, file attachments, undo/redo |
| 4 | Images & attachments | **Upload to famapp** via existing `/api/uploads`; embed returned URLs in note HTML |
| 5 | Shared component location | **`src/components/rich-text/`** — imported by notes now, journal later |
| 6 | Existing plain-text notes | **Lazy migration**: on read, non-HTML bodies wrapped as paragraphs; persisted as HTML on next save. No schema change. |
| 7 | Checklists on read surfaces | **Interactive everywhere** — index, dashboard widget, and share view can toggle checklist items (updates note via API) |
## Decision
To be filled when research completes.
### Editor: TipTap
- Use TipTap with React bindings and official extensions for the v1 feature set.
- Export contract for the shared component:
- `RichTextEditor` — controlled `value` / `onChange` (HTML string), optional `editable`, toolbar config
- `RichTextContent` — read-only renderer with sanitization and overflow-safe CSS
- `RichTextChecklist` behavior — interactive task items on read surfaces (see below)
- Journal (task 86) imports from `src/components/rich-text/` without forking.
### Storage: HTML in `notes.body`
- `notes.body` remains a `text` column; content is HTML after first save from the new editor.
- **Sanitize** all HTML before `dangerouslySetInnerHTML` (DOMPurify or equivalent) on every read surface: editor preview, index cards, dashboard widget, share viewer.
- Allowlist tags/attributes matching TipTap output (headings, lists, task lists, tables, links, images, code, blockquote, etc.).
- API `/api/v1/notes` continues to accept/return `body` as a string (now HTML). Document in OpenAPI.
### Lazy migration for legacy notes
- Helper `normalizeNoteBody(body: string): string` — if body does not start with `<` (heuristic) or fails HTML detection, convert plain text to `<p>` paragraphs (preserve line breaks).
- Run on read paths (editor load, index excerpt, widget, share) until user saves, which persists HTML.
- No one-shot migration script required for v1.
### Images and attachments
- TipTap image + file extensions call existing upload route (`POST /api/uploads`).
- Stored URLs embedded in HTML (`<img src="…">`, attachment links as `<a href="…">`).
- Upload auth: session or household bearer token (same as other API calls).
### Interactive checklists (read surfaces)
- TipTap TaskList extension for authoring.
- On index, widget, and share surfaces: render checklists with tappable checkboxes.
- Toggle calls a server action or `PATCH /api/v1/notes/:id` that updates only the task-item `checked` state inside the HTML (parse → flip `data-checked` / class → save). Activity log optional.
- Share links with write capability may allow toggle if share token grants write (reuse existing share write path where applicable).
### Mobile overflow
- Fix as part of `RichTextContent`: `overflow-wrap: anywhere`, `max-width: 100%`, table horizontal scroll container, no fixed-width embeds. Closes Gitea #5.
## Consequences
To be filled when research completes.
### Positive
- One editor stack for notes + journal; wife-friendly WYSIWYG.
- HTML storage is simple to render and matches TipTap's native export.
- Lazy migration avoids a risky bulk conversion; existing notes keep working.
- Upload reuse avoids new storage infrastructure.
### Trade-offs
- Full TipTap extension set increases bundle size — acceptable for an editor route; lazy-load editor chunk on notes/journal pages.
- HTML in DB is less readable than markdown for debugging; mitigated by Drizzle Studio and API still returning strings.
- Interactive checklists on read surfaces require HTML-aware toggle logic (not trivial CRUD) — budget extra implementation time in task 85.
- Sanitization is security-critical; must test XSS vectors and keep allowlist in sync with TipTap extensions.
### Out of scope (ADR)
- Journal module UI (task 86) — consumes shared component after 85 lands.
- Collaborative editing, comments, version history.
## References
- Task brief: `docs/tasks/85-rich-text-notes.md`
- Gitea: [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7) epic, [#8](https://gitea.ginnoir.com/ginnoir/famapp/issues/8) research child
- Current notes editor: `src/modules/notes/components/note-editor.tsx`
- Uploads: `src/app/api/uploads/route.ts`
+117 -5
View File
@@ -1,16 +1,128 @@
# 0005 — Journal / mood tracking research
# 0005 — Journal / mood tracking
Date: 2026-07-03
Status: proposed
Status: accepted (2026-07-04)
## Context
Journal module (task 86) needs mood multi-select, charts over time, and insights. Research existing libraries/components for mood tracking or journaling and record build-vs-adopt. Gitea: [#20](https://gitea.ginnoir.com/ginnoir/famapp/issues/20) (epic [#19](https://gitea.ginnoir.com/ginnoir/famapp/issues/19)).
Journal module (task 86) needs per-user entries, mood multi-select, charts over time, insights, and `/api/v1/journal` endpoints. ADR research included [Giftyaning/Mood-Tracker](https://github.com/Giftyaning/Mood-Tracker) (Gitea [#20](https://gitea.ginnoir.com/ginnoir/famapp/issues/20), epic [#19](https://gitea.ginnoir.com/ginnoir/famapp/issues/19)).
## Build-vs-adopt: Mood-Tracker
**Decision: do not adopt the repo.**
| Factor | Mood-Tracker | famapp need |
| ---------- | -------------------------- | ------------------------------------------- |
| License | None | Must have clear reuse rights |
| Stack | Vite SPA, JS, localStorage | Next.js 15, TypeScript, Postgres, Authentik |
| Mood model | Single-select, 5 presets | Multi-select fixed catalog |
| Fields | Title + plain text | Optional title + TipTap HTML, stress, pills |
| API / auth | None | `/api/v1/` + session + bearer token |
| Code shape | One 672-line `App.jsx` | `src/modules/journal/` manifest pattern |
**Borrow (reimplement in TypeScript):** mood→numeric scoring, day streak, week-over-week trend, insight card layout. Not copied source.
## What we decided (2026-07-04)
| # | Topic | Choice |
| --- | -------------- | --------------------------------------------------------------------------------------------------- |
| 1 | Charts | **Build natively + Recharts** for mood/stress trends and correlation views |
| 2 | Moods | **Fixed catalog** (~812 presets with emoji + color); **multi-select** |
| 3 | Entries | **Multiple per day** allowed |
| 4 | Stress | Optional **110** slider |
| 5 | Insights v1 | **Core + correlations** — streak, top mood, avg stress; stress↔mood and pills↔avg-mood simple stats |
| 6 | Index calendar | **Month grid** with dots on days that have entries; tap day → filtered list |
| 7 | Entry shape | **Optional title** + rich-text body (shared `RichTextEditor`) |
| 8 | Chart points | **All entries** as individual points; x-axis uses **datetime** (not date-only rollup) |
## Decision
To be filled when research completes.
### Module: `src/modules/journal/`
Standard module layout: `schema.ts`, `manifest.tsx`, `server/`, `components/`, app routes under `/journal`.
### Data model
Table `journal_entries`:
- `household_id` — household scope for DB consistency
- `user_id`**per-user privacy**; all reads/writes filter `user_id = current user`
- `recorded_at` timestamptz — required date+time (defaults to now on create)
- `title` text nullable — optional
- `body` text — HTML from shared rich-text editor (ADR 0004)
- `moods` jsonb — array of catalog mood ids (multi-select)
- `stress` smallint nullable — 110
- `pills_taken` boolean nullable
No household-shared journal in v1. Wife cannot see Matt's entries and vice versa.
### Mood catalog
Fixed in code (`src/modules/journal/mood-catalog.ts`): ids, label, emoji, color, numeric score for analytics. Users pick from catalog only (no custom tags in v1).
### UI surfaces
| Route | Purpose |
| ------------------------------- | -------------------------------------------------------------------------------------- |
| `/journal` | Recent 510 entries, browse all link, month calendar, links to mood tracker + insights |
| `/journal/new`, `/journal/[id]` | Create/edit entry (date/time required; rest optional) |
| `/journal/mood` | Recharts mood/stress over time (all entry points) |
| `/journal/insights` | Streak, top mood, correlations |
Rich text: `RichTextEditor` / `RichTextContent` from `src/components/rich-text/`. No share viewer in v1.
### Insights v1
- Entry streak (consecutive days with ≥1 entry)
- Most common mood (selected period)
- Average stress (7d / 30d)
- Week-over-week mood trend (score-based)
- **Correlations:** simple aggregates — avg mood score on days pills taken vs not; stress vs mood score scatter or grouped averages
No ML / sentiment analysis in v1.
### API
Additive on ADR 0006 foundation:
- `GET/POST /api/v1/journal/entries`
- `GET/PATCH/DELETE /api/v1/journal/entries/:id`
Bearer token and session auth. Responses scoped to **token owner's user id** for journal (household token acts as owner user for API mutations per existing pattern, but journal entries are always stored with the authenticated user's id).
Document in `docs/api/openapi.yaml`.
### Testing
- Playwright: `tests/e2e/journal.spec.ts` — create minimal entry → list → detail → mood tracker loads
- Unit tests for mood scoring / streak helpers where non-trivial
## Consequences
To be filled when research completes.
### Positive
- Recharts gives proper time-series without maintaining custom chart code for long trends.
- Fixed mood catalog keeps insights comparable across entries.
- Per-user privacy matches personal journal expectations.
- Reuses rich-text stack from task 85.
### Trade-offs
- All-entry chart points mean busy days show multiple dots; datetime x-axis required.
- Fixed catalog may miss nuance — body text carries freeform detail.
- Correlation stats are descriptive only (no causation); keep copy honest in UI.
- Recharts adds bundle weight on `/journal/mood` and `/journal/insights` only (route-level code split).
### Out of scope (ADR)
- Household-shared journals
- LLM agent tools for journal (task 88)
- Custom user-defined mood tags
- Share links for journal entries
## References
- Task brief: `docs/tasks/86-journal-module.md`
- Rich text: `docs/decisions/0004-rich-text-editor.md`
- API: `docs/decisions/0006-api-llm-agent.md`
- Mood-Tracker research: https://github.com/Giftyaning/Mood-Tracker
+65 -4
View File
@@ -1,16 +1,77 @@
# 0006 — API auth and LLM agent architecture
Date: 2026-07-03
Status: proposed
Status: accepted (2026-07-04)
## Context
P1 needs a documented API with token auth coexisting with OIDC session auth, plus a provider-agnostic OpenAI-compatible agent that tool-calls the API. Decide MCP vs direct tools. Gitea: [#14](https://gitea.ginnoir.com/ginnoir/famapp/issues/14) (epic [#13](https://gitea.ginnoir.com/ginnoir/famapp/issues/13)). Tasks 87 then 88.
P1 needs a documented HTTP API with token auth coexisting with OIDC session auth, plus a provider-agnostic OpenAI-compatible agent that tool-calls the API. Tasks 87 (API surface) and 88 (agent chat) depend on these choices. Gitea: [#14](https://gitea.ginnoir.com/ginnoir/famapp/issues/14) (epic [#13](https://gitea.ginnoir.com/ginnoir/famapp/issues/13)).
Existing auth: Authentik OIDC via Auth.js, database sessions, household-scoped data via `getCurrentSession()`. Mutations today are server actions; a handful of route handlers exist under `/api/` for uploads, SSE, and auth.
## What we decided (2026-07-04)
| # | Topic | Choice |
| --- | ------------------ | --------------------------------------------------------------------------------------------------- |
| 1 | API token model | **One shared household token** (not per-user) |
| 1b | Token management | **Household owner only** — create, revoke, view last-used |
| 2 | API shape | **REST JSON under `/api/v1/`** — additive versioning |
| 3 | LLM provider | **Env-configured OpenAI-compatible endpoint** (`LLM_BASE_URL`, optional `LLM_API_KEY`, `LLM_MODEL`) |
| 4 | Agent → API wiring | **Direct tools** (JSON-schema tools → HTTP calls). **No MCP server** in v1 |
## Decision
To be filled when research completes.
### Token auth (coexists with OIDC session)
- Add a single **household-scoped API token** stored hashed in the database (`household_api_tokens` or equivalent).
- Non-browser clients send `Authorization: Bearer <token>`.
- `/api/v1/*` route handlers accept **either** a valid Auth.js session cookie **or** a valid household bearer token. Both resolve to the same household scope and permission checks.
- **OIDC / browser login is unchanged.** Middleware continues to guard pages; API routes perform their own auth (session or bearer).
- Token lifecycle UI on `/settings` (owner only): create (show raw token once), revoke, last-used timestamp.
- Activity log for API mutations may attribute `actorId` from session when present; bearer-token calls use `actorId = null` (same pattern as share-link anonymous mutations).
### API shape
- **REST** resources under `/api/v1/` with JSON request/response bodies.
- **Additive versioning**: new modules (journal, etc.) add routes under `/api/v1/` without breaking existing clients. No v2 until a breaking change is unavoidable.
- Task 87 covers: calendars, events, lists, list items, notes, garden (plants + containers), bangs. Dashboards deferred unless needed.
- **OpenAPI** spec committed as living docs (`docs/api/openapi.yaml` or generated from route definitions).
- Handlers call the same module server queries/actions as the web app — no parallel business logic.
### LLM provider (task 88)
- Famapp does **not** host a model. The agent client points at an external **OpenAI-compatible** HTTP API configured via environment variables.
- Compatible with Ollama, vLLM, LiteLLM proxy, or any `/v1/chat/completions`-style endpoint on the homelab.
- CI uses a **mock/stub provider** — no live LLM calls in tests.
### Agent tool-calling (task 88)
- **Direct tools**: the in-app agent defines tool schemas (name, description, parameters) that map to `/api/v1/` HTTP methods. The agent loop calls the API with the household bearer token.
- **No MCP server** in v1. Revisit if famapp needs to be a tool provider for Cursor or other MCP clients later.
## Consequences
To be filled when research completes.
### Positive
- One token to configure for scripts, wife's automations, and the agent — simple for a two-user household.
- REST + OpenAPI gives predictable integration and straightforward tool schema generation.
- Session and bearer auth share household scope; web app behavior unchanged.
- Direct tools avoid MCP transport, extra processes, and protocol overhead.
### Trade-offs
- Shared token: revoking it disables all non-browser clients at once; no per-person revocation.
- Bearer calls lack a user `actorId` in activity log unless we add optional token metadata later.
- Direct tools couple agent definitions to famapp's HTTP API; external MCP consumers would need a separate effort.
### Implementation notes (task 87)
- Schema: `household_api_tokens` with `household_id`, `token_hash`, `name` (e.g. "default"), `last_used_at`, `created_by` (owner), `revoked_at`.
- Middleware: exempt `/api/v1/` from session redirect; handlers enforce auth.
- Settings: owner-only card for token create/revoke.
- Tests: Vitest for bearer auth resolution, 401/403 paths, and representative CRUD handlers.
## References
- Task briefs: `docs/tasks/87-api-surface.md`, `docs/tasks/88-llm-agent-chat.md`
- Design: `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`
+3 -3
View File
@@ -26,7 +26,7 @@ What this costs us, what it buys us.
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
- [0004 — Rich-text editor library and storage format](0004-rich-text-editor.md) (proposed)
- [0005 — Journal / mood tracking research](0005-journal-research.md) (proposed)
- [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (proposed)
- [0004 — Rich-text editor library and storage format](0004-rich-text-editor.md) (accepted 2026-07-04)
- [0005 — Journal / mood tracking](0005-journal-research.md) (accepted 2026-07-04)
- [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (accepted 2026-07-04)
<!-- END AUTO-GENERATED -->
+48 -47
View File
@@ -2,55 +2,56 @@
Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`).
| Design | Gitea | Title | URL |
| --- | --- | --- | --- |
| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 |
| 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 |
| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 |
| 4 | 4 | Bangs are not editable (add edit and delete) | https://gitea.ginnoir.com/ginnoir/famapp/issues/4 |
| 5 | 5 | Notes overflow horizontally on mobile | https://gitea.ginnoir.com/ginnoir/famapp/issues/5 |
| 6 | 6 | Missing back navigation on detail pages (audit + shared affordance) | https://gitea.ginnoir.com/ginnoir/famapp/issues/6 |
| 7 | 28 | Calendar reminders overhaul | https://gitea.ginnoir.com/ginnoir/famapp/issues/28 |
| 8 | 29 | Lists index: inline task add + list property edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/29 |
| 9 | 30 | Comments on lists and tasks | https://gitea.ginnoir.com/ginnoir/famapp/issues/30 |
| 10 | 7 | Notes editor overhaul (shared rich-text component) | https://gitea.ginnoir.com/ginnoir/famapp/issues/7 |
| 10.1 | 8 | Research ADR: rich-text editor library and storage format | https://gitea.ginnoir.com/ginnoir/famapp/issues/8 |
| 10.2 | 9 | Shared rich-text editor component | https://gitea.ginnoir.com/ginnoir/famapp/issues/9 |
| 10.3 | 10 | Wire shared editor into notes create/edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/10 |
| 10.4 | 11 | Render note formatting on all notes surfaces | https://gitea.ginnoir.com/ginnoir/famapp/issues/11 |
| 10.5 | 12 | Fix notes mobile horizontal overflow | https://gitea.ginnoir.com/ginnoir/famapp/issues/12 |
| 11 | 31 | Bang stats dashboard widget | https://gitea.ginnoir.com/ginnoir/famapp/issues/31 |
| 12 | 35 | Appearance / theming options | https://gitea.ginnoir.com/ginnoir/famapp/issues/35 |
| 13 | 19 | Journal module | https://gitea.ginnoir.com/ginnoir/famapp/issues/19 |
| 13.1 | 20 | Research ADR: journal/mood tracking build-vs-adopt | https://gitea.ginnoir.com/ginnoir/famapp/issues/20 |
| 13.2 | 21 | Journal schema + CRUD | https://gitea.ginnoir.com/ginnoir/famapp/issues/21 |
| 13.3 | 22 | Journal index (recent, browse, entry calendar) | https://gitea.ginnoir.com/ginnoir/famapp/issues/22 |
| 13.4 | 23 | Journal entry detail and create | https://gitea.ginnoir.com/ginnoir/famapp/issues/23 |
| 13.5 | 24 | Journal mood tracker view | https://gitea.ginnoir.com/ginnoir/famapp/issues/24 |
| 13.6 | 25 | Journal insights/stats views | https://gitea.ginnoir.com/ginnoir/famapp/issues/25 |
| 13.7 | 26 | Journal E2E happy path | https://gitea.ginnoir.com/ginnoir/famapp/issues/26 |
| 13.8 | 27 | Journal API endpoints | https://gitea.ginnoir.com/ginnoir/famapp/issues/27 |
| 14 | 13 | API + LLM agent chat | https://gitea.ginnoir.com/ginnoir/famapp/issues/13 |
| 14.1 | 14 | Research ADR: API auth, surface shape, and LLM agent architecture | https://gitea.ginnoir.com/ginnoir/famapp/issues/14 |
| 14.2 | 15 | API surface + token auth for existing modules | https://gitea.ginnoir.com/ginnoir/famapp/issues/15 |
| 14.3 | 16 | LLM agent chat UI | https://gitea.ginnoir.com/ginnoir/famapp/issues/16 |
| 14.4 | 17 | Map agent tool-calling to API | https://gitea.ginnoir.com/ginnoir/famapp/issues/17 |
| 14.5 | 18 | Agent smoke tests with mock provider | https://gitea.ginnoir.com/ginnoir/famapp/issues/18 |
| 15 | 32 | Pets module | https://gitea.ginnoir.com/ginnoir/famapp/issues/32 |
| 16 | 33 | Shopping/pantry module | https://gitea.ginnoir.com/ginnoir/famapp/issues/33 |
| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 |
| 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 |
| 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 |
| Design | Gitea | Title | URL |
| ------ | ----- | ------------------------------------------------------------------- | -------------------------------------------------- |
| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 |
| 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 |
| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 |
| 4 | 4 | Bangs are not editable (add edit and delete) | https://gitea.ginnoir.com/ginnoir/famapp/issues/4 |
| 5 | 5 | Notes overflow horizontally on mobile | https://gitea.ginnoir.com/ginnoir/famapp/issues/5 |
| 6 | 6 | Missing back navigation on detail pages (audit + shared affordance) | https://gitea.ginnoir.com/ginnoir/famapp/issues/6 |
| 7 | 28 | Calendar reminders overhaul | https://gitea.ginnoir.com/ginnoir/famapp/issues/28 |
| 8 | 29 | Lists index: inline task add + list property edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/29 |
| 9 | 30 | Comments on lists and tasks | https://gitea.ginnoir.com/ginnoir/famapp/issues/30 |
| 10 | 7 | Notes editor overhaul (shared rich-text component) | https://gitea.ginnoir.com/ginnoir/famapp/issues/7 |
| 10.1 | 8 | Research ADR: rich-text editor library and storage format | https://gitea.ginnoir.com/ginnoir/famapp/issues/8 |
| 10.2 | 9 | Shared rich-text editor component | https://gitea.ginnoir.com/ginnoir/famapp/issues/9 |
| 10.3 | 10 | Wire shared editor into notes create/edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/10 |
| 10.4 | 11 | Render note formatting on all notes surfaces | https://gitea.ginnoir.com/ginnoir/famapp/issues/11 |
| 10.5 | 12 | Fix notes mobile horizontal overflow | https://gitea.ginnoir.com/ginnoir/famapp/issues/12 |
| 11 | 31 | Bang stats dashboard widget | https://gitea.ginnoir.com/ginnoir/famapp/issues/31 |
| 12 | 35 | Appearance / theming options | https://gitea.ginnoir.com/ginnoir/famapp/issues/35 |
| 13 | 19 | Journal module | https://gitea.ginnoir.com/ginnoir/famapp/issues/19 |
| 13.1 | 20 | Research ADR: journal/mood tracking build-vs-adopt | https://gitea.ginnoir.com/ginnoir/famapp/issues/20 |
| 13.2 | 21 | Journal schema + CRUD | https://gitea.ginnoir.com/ginnoir/famapp/issues/21 |
| 13.3 | 22 | Journal index (recent, browse, entry calendar) | https://gitea.ginnoir.com/ginnoir/famapp/issues/22 |
| 13.4 | 23 | Journal entry detail and create | https://gitea.ginnoir.com/ginnoir/famapp/issues/23 |
| 13.5 | 24 | Journal mood tracker view | https://gitea.ginnoir.com/ginnoir/famapp/issues/24 |
| 13.6 | 25 | Journal insights/stats views | https://gitea.ginnoir.com/ginnoir/famapp/issues/25 |
| 13.7 | 26 | Journal E2E happy path | https://gitea.ginnoir.com/ginnoir/famapp/issues/26 |
| 13.8 | 27 | Journal API endpoints | https://gitea.ginnoir.com/ginnoir/famapp/issues/27 |
| 14 | 13 | API + LLM agent chat | https://gitea.ginnoir.com/ginnoir/famapp/issues/13 |
| 14.1 | 14 | Research ADR: API auth, surface shape, and LLM agent architecture | https://gitea.ginnoir.com/ginnoir/famapp/issues/14 |
| 14.2 | 15 | API surface + token auth for existing modules | https://gitea.ginnoir.com/ginnoir/famapp/issues/15 |
| 14.3 | 16 | LLM agent chat UI | https://gitea.ginnoir.com/ginnoir/famapp/issues/16 |
| 14.4 | 17 | Map agent tool-calling to API | https://gitea.ginnoir.com/ginnoir/famapp/issues/17 |
| 14.5 | 18 | Agent smoke tests with mock provider | https://gitea.ginnoir.com/ginnoir/famapp/issues/18 |
| 15 | 32 | Pets module | https://gitea.ginnoir.com/ginnoir/famapp/issues/32 |
| 16 | 33 | Shopping/pantry module | https://gitea.ginnoir.com/ginnoir/famapp/issues/33 |
| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 |
| 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 |
| 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 |
| — | 38 | Revisit journal day-switch animations (smoother paper transition) | https://gitea.ginnoir.com/ginnoir/famapp/issues/38 |
## Dependencies (P1)
| Issue | Depends on |
| --- | --- |
| #5 (notes overflow) | #7 (rich-text epic) |
| #12 (mobile overflow child) | #8 (rich-text research ADR) |
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
| #16 (agent UI) | #15 (API surface) |
| #17 (tool-calling) | #15 (API surface) |
| #27 (journal API) | #15 (API surface) |
| Issue | Depends on |
| --------------------------- | -------------------------------------- |
| #5 (notes overflow) | #7 (rich-text epic) |
| #12 (mobile overflow child) | #8 (rich-text research ADR) |
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
| #16 (agent UI) | #15 (API surface) |
| #17 (tool-calling) | #15 (API surface) |
| #27 (journal API) | #15 (API surface) |
Native Gitea issue dependencies API 404s on this instance; dependencies are also noted as comments on the issues above.
+3 -3
View File
@@ -25,9 +25,9 @@ Edit mode currently shows empty placeholders; on save, content-heavy widgets (e.
## Acceptance criteria
- [ ] Edit mode shows real widget content for registered widgets.
- [ ] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
- [ ] E2E covers real content visible in edit mode.
- [x] Edit mode shows real widget content for registered widgets.
- [x] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
- [x] E2E covers real content visible in edit mode.
## Notes
+5 -5
View File
@@ -30,11 +30,11 @@ Journal (86) and notes need modern formatting and emoji. Notes currently overflo
## Acceptance criteria
- [ ] ADR 0004 accepted with library and storage format chosen.
- [ ] Shared editor component in tree.
- [ ] Notes create/edit use it.
- [ ] Formatting renders on all notes surfaces.
- [ ] Mobile overflow fixed; E2E green.
- [x] ADR 0004 accepted with library and storage format chosen.
- [x] Shared editor component in tree.
- [x] Notes create/edit use it.
- [x] Formatting renders on all notes surfaces.
- [x] Mobile overflow fixed; E2E green.
## Notes
+5 -5
View File
@@ -33,11 +33,11 @@ P1 personal journaling with mood/stress/pills tracking and stats, without blocki
## Acceptance criteria
- [ ] ADR 0005 accepted.
- [ ] Create entry with only date/time.
- [ ] Index, detail, mood tracker, and insights views work.
- [ ] Journal API endpoints documented and callable with token auth.
- [ ] E2E happy path green.
- [x] ADR 0005 accepted.
- [x] Create entry with only date/time.
- [x] Index, detail, mood tracker, and insights views work.
- [x] Journal API endpoints documented and callable with token auth.
- [x] E2E happy path green (`tests/e2e/journal.spec.ts` — run locally with dev DB).
## Notes
+4 -4
View File
@@ -28,10 +28,10 @@ Users should say "add milk to the shopping list" or "what's on the calendar Frid
## Acceptance criteria
- [ ] Chat UI sends prompts and shows responses.
- [ ] Tools call the API successfully in dev against the homelab endpoint.
- [ ] CI uses mock provider only.
- [ ] ADR 0006 records provider and MCP decisions.
- [x] Chat UI sends prompts and shows responses.
- [x] Tools call the API successfully in dev against the homelab endpoint.
- [x] CI uses mock provider only.
- [x] ADR 0006 records provider and MCP decisions.
## Notes
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE "household_api_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"name" text DEFAULT 'default' NOT NULL,
"created_by" uuid NOT NULL,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "household_api_tokens" ADD CONSTRAINT "household_api_tokens_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "household_api_tokens" ADD CONSTRAINT "household_api_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX "household_api_tokens_active_household_uq" ON "household_api_tokens" USING btree ("household_id") WHERE "revoked_at" IS NULL;
--> statement-breakpoint
CREATE INDEX "household_api_tokens_hash_idx" ON "household_api_tokens" USING btree ("token_hash");
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE "journal_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"recorded_at" timestamp with time zone NOT NULL,
"title" text,
"body" text DEFAULT '' NOT NULL,
"moods" jsonb DEFAULT '[]'::jsonb NOT NULL,
"stress" smallint,
"pills_taken" boolean,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "journal_entries" ADD CONSTRAINT "journal_entries_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "journal_entries" ADD CONSTRAINT "journal_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX "journal_entries_user_recorded_idx" ON "journal_entries" USING btree ("user_id","recorded_at");
--> statement-breakpoint
CREATE INDEX "journal_entries_household_idx" ON "journal_entries" USING btree ("household_id");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "assistant_enabled" boolean DEFAULT false NOT NULL;
+21
View File
@@ -134,6 +134,27 @@
"when": 1748995200000,
"tag": "0018_container_images",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1751664000000,
"tag": "0019_household_api_tokens",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1751750400000,
"tag": "0020_journal_entries",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1751754000000,
"tag": "0021_user_assistant_enabled",
"breakpoints": true
}
]
}
+15
View File
@@ -83,11 +83,25 @@
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/extension-placeholder": "^3.27.1",
"@tiptap/extension-table": "^3.27.1",
"@tiptap/extension-table-cell": "^3.27.1",
"@tiptap/extension-table-header": "^3.27.1",
"@tiptap/extension-table-row": "^3.27.1",
"@tiptap/extension-task-item": "^3.27.1",
"@tiptap/extension-task-list": "^3.27.1",
"@tiptap/extension-underline": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"isomorphic-dompurify": "^3.18.0",
"lucide-react": "^1.14.0",
"minio": "^8.0.7",
"next": "^15.5.15",
@@ -99,6 +113,7 @@
"react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3",
"react-resizable": "^3.1.3",
"recharts": "^3.9.2",
"server-only": "^0.0.1",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
+1171
View File
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantEnabled } from "@/lib/assistant-preference";
import { isLlmConfigured } from "@/lib/llm";
import { encodeSseEvent } from "@/modules/agent/server/progress";
import { runAgentChat } from "@/modules/agent/server/run";
const chatInput = z.object({
stream: z.boolean().optional(),
messages: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
content: z.string().trim().min(1).max(8000),
}),
)
.min(1)
.max(40),
});
export async function POST(request: Request) {
const auth = await resolveApiAuth(request);
if (!auth?.userId) {
return apiError("Unauthorized", 401);
}
const assistantEnabled = await getAssistantEnabled(auth.userId);
if (!assistantEnabled) {
return apiError("Assistant not enabled", 403);
}
let body: unknown;
try {
body = await request.json();
} catch {
return apiError("Invalid JSON body", 400);
}
const parsed = chatInput.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
if (parsed.data.stream) {
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: Parameters<typeof encodeSseEvent>[0]) => {
controller.enqueue(encoder.encode(encodeSseEvent(event)));
};
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
onProgress: send,
});
send({
type: "done",
message: {
role: "assistant",
content: result.message.content,
},
toolCalls: result.toolCalls,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
send({ type: "error", message });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
});
return apiJson({
...result,
configured: isLlmConfigured(),
provider: isLlmConfigured() ? "openai" : "mock",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
return apiError(message, 502);
}
}
+4 -1
View File
@@ -32,6 +32,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
}
const url = new URL(request.url);
const scope = url.searchParams.get("scope") === "notes" ? "notes" : "garden";
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
}
@@ -45,7 +48,7 @@ export async function POST(request: Request) {
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 8);
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
const key = `${scope}/${session.household.id}/${randomUUID()}.${safeExt}`;
try {
await ensureBucket();
+28
View File
@@ -0,0 +1,28 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteBangForScope, updateBangForScope } from "@/modules/bangs/server/actions";
import { updateBangInput } from "@/modules/bangs/server/schemas";
import { z } from "zod";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = updateBangInput.parse(body);
const bang = await updateBangForScope(scope, { id, ...parsed });
return apiJson({
id: bang.id,
occurredOn: bang.occurredOn,
recordedBy: bang.recordedBy,
createdAt: bang.createdAt.toISOString(),
});
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteBangForScope(scope, { id });
return apiJson({ ok: true });
});
}
+31
View File
@@ -0,0 +1,31 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { addBangForScope } from "@/modules/bangs/server/actions";
import { addBangInput } from "@/modules/bangs/server/schemas";
import { getBangStatsForScope } from "@/modules/bangs/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 10, 1), 100) : 10;
const stats = await getBangStatsForScope(scope.householdId, limit);
return apiJson(stats);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json().catch(() => ({}));
const parsed = addBangInput.parse(body);
const bang = await addBangForScope(scope, parsed);
return apiJson(
{
id: bang.id,
occurredOn: bang.occurredOn,
recordedBy: bang.recordedBy,
createdAt: bang.createdAt.toISOString(),
},
201,
);
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteCalendarForScope, updateCalendarForScope } from "@/modules/calendar/server/actions";
import { calendarUpdateInput } from "@/modules/calendar/server/schemas";
import { getCalendarForScope } from "@/modules/calendar/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const calendar = await getCalendarForScope(scope, id);
return apiJson(calendar);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = calendarUpdateInput.parse(body);
await updateCalendarForScope(scope, { id, ...parsed });
const calendar = await getCalendarForScope(scope, id);
return apiJson(calendar);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteCalendarForScope(scope, { id });
return apiJson({ ok: true });
});
}
+29
View File
@@ -0,0 +1,29 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createCalendarForScope } from "@/modules/calendar/server/actions";
import { calendarInput } from "@/modules/calendar/server/schemas";
import { listCalendarsForScope } from "@/modules/calendar/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const calendars = await listCalendarsForScope(scope);
return apiJson(calendars);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = calendarInput.parse(body);
const calendar = await createCalendarForScope(scope, parsed);
return apiJson(
{
id: calendar.id,
name: calendar.name,
color: calendar.color,
visibility: calendar.visibility as "private" | "household",
ownerId: calendar.ownerId,
},
201,
);
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteEventForScope, updateEventForScope } from "@/modules/calendar/server/actions";
import { eventUpdateInput } from "@/modules/calendar/server/schemas";
import { getEventForScope } from "@/modules/calendar/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const event = await getEventForScope(scope, id);
return apiJson(event);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = eventUpdateInput.parse(body);
await updateEventForScope(scope, { id, ...parsed });
const event = await getEventForScope(scope, id);
return apiJson(event);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteEventForScope(scope, { id });
return apiJson({ ok: true });
});
}
+37
View File
@@ -0,0 +1,37 @@
import { apiError, apiJson, withApiHandler } from "@/lib/api-handler";
import { createEventForScope } from "@/modules/calendar/server/actions";
import { eventInput } from "@/modules/calendar/server/schemas";
import { listEventsForScope } from "@/modules/calendar/server/queries";
function parseCalendarIds(value: string | null): "all" | string[] {
if (!value || value === "all") return "all";
return value
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
const calendarIds = parseCalendarIds(url.searchParams.get("calendarIds"));
if (!from || !to) {
return apiError("from and to query parameters are required", 400);
}
const events = await listEventsForScope(scope, { from, to, calendarIds });
return apiJson(events);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = eventInput.parse(body);
const event = await createEventForScope(scope, parsed);
return apiJson(event, 201);
});
}
@@ -0,0 +1,13 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { scheduleOnCalendarForScope } from "@/modules/garden/server/actions";
import { scheduleOnCalendarInput } from "@/modules/garden/server/schemas";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = scheduleOnCalendarInput.parse({ ...(body as object), scheduleId: id });
await scheduleOnCalendarForScope(scope, parsed);
return apiJson({ ok: true }, 201);
});
}
@@ -0,0 +1,26 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
deleteCareScheduleForScope,
toggleCareScheduleForScope,
} from "@/modules/garden/server/actions";
import { careScheduleToggleInput } from "@/modules/garden/server/schemas";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = careScheduleToggleInput.parse({ ...(body as object), id });
await toggleCareScheduleForScope(scope, parsed);
return apiJson({ ok: true });
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteCareScheduleForScope(scope, { id });
return apiJson({ ok: true });
});
}
@@ -0,0 +1,36 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteContainerForScope, updateContainerForScope } from "@/modules/garden/server/actions";
import { containerUpdateInput } from "@/modules/garden/server/schemas";
import { getContainerForScope } from "@/modules/garden/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const container = await getContainerForScope(scope.householdId, id);
if (!container) throw new Error("Container not found");
return apiJson(container);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = containerUpdateInput.parse(body);
await updateContainerForScope(scope, { id, ...parsed });
const container = await getContainerForScope(scope.householdId, id);
if (!container) throw new Error("Container not found");
return apiJson(container);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteContainerForScope(scope, { id });
return apiJson({ ok: true });
});
}
+21
View File
@@ -0,0 +1,21 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createContainerForScope } from "@/modules/garden/server/actions";
import { containerInput } from "@/modules/garden/server/schemas";
import { getContainerForScope, listContainersForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const containers = await listContainersForScope(scope.householdId);
return apiJson(containers);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = containerInput.parse(body);
const container = await createContainerForScope(scope, parsed);
const detail = await getContainerForScope(scope.householdId, container.id);
return apiJson(detail, 201);
});
}
@@ -0,0 +1,9 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { pushOverdueToTaskListForScope } from "@/modules/garden/server/actions";
export async function POST(request: Request) {
return withApiHandler(request, async (scope) => {
const result = await pushOverdueToTaskListForScope(scope);
return apiJson(result, 201);
});
}
@@ -0,0 +1,37 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { logCareForScope } from "@/modules/garden/server/actions";
import { careLogInput } from "@/modules/garden/server/schemas";
import { getCareLogsForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const url = new URL(req.url);
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? z.coerce.number().int().min(1).max(100).parse(limitRaw) : 20;
const logs = await getCareLogsForScope(scope.householdId, id, limit);
return apiJson(logs);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careLogInput.parse({ ...(body as object), plantId: id });
const log = await logCareForScope(scope, parsed);
return apiJson(
{
id: log.id,
careType: log.careType,
notes: log.notes,
performedAt: log.performedAt.toISOString(),
performedBy: log.performedBy,
},
201,
);
});
}
@@ -0,0 +1,35 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { upsertCareScheduleForScope } from "@/modules/garden/server/actions";
import { careScheduleInput } from "@/modules/garden/server/schemas";
import { getCareSchedulesForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const schedules = await getCareSchedulesForScope(scope.householdId, id);
return apiJson(schedules);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careScheduleInput.parse({ ...(body as object), plantId: id });
const schedule = await upsertCareScheduleForScope(scope, parsed);
return apiJson(
{
id: schedule.id,
careType: schedule.careType,
intervalDays: schedule.intervalDays,
lastPerformedAt: schedule.lastPerformedAt?.toISOString() ?? null,
nextDueAt: schedule.nextDueAt?.toISOString() ?? null,
enabled: schedule.enabled,
},
201,
);
});
}
@@ -0,0 +1,36 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deletePlantForScope, updatePlantForScope } from "@/modules/garden/server/actions";
import { plantUpdateInput } from "@/modules/garden/server/schemas";
import { getPlantForScope } from "@/modules/garden/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const plant = await getPlantForScope(scope.householdId, id);
if (!plant) throw new Error("Plant not found");
return apiJson(plant);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = plantUpdateInput.parse(body);
await updatePlantForScope(scope, { id, ...parsed });
const plant = await getPlantForScope(scope.householdId, id);
if (!plant) throw new Error("Plant not found");
return apiJson(plant);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deletePlantForScope(scope, { id });
return apiJson({ ok: true });
});
}
+23
View File
@@ -0,0 +1,23 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createPlantForScope } from "@/modules/garden/server/actions";
import { plantInput } from "@/modules/garden/server/schemas";
import { getPlantForScope, listPlantsForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const containerId = url.searchParams.get("containerId") ?? undefined;
const plants = await listPlantsForScope(scope.householdId, { containerId });
return apiJson(plants);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = plantInput.parse(body);
const plant = await createPlantForScope(scope, parsed);
const detail = await getPlantForScope(scope.householdId, plant.id);
return apiJson(detail, 201);
});
}
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
deleteJournalEntryForScope,
updateJournalEntryForScope,
} from "@/modules/journal/server/actions";
import { updateJournalEntryInput } from "@/modules/journal/server/schemas";
import { getJournalEntryForScope } from "@/modules/journal/server/queries";
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope) => {
const entry = await getJournalEntryForScope(scope, id);
return apiJson(entry);
});
}
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = updateJournalEntryInput.parse({ ...(body as object), id });
const entry = await updateJournalEntryForScope(scope, parsed);
return apiJson(entry);
});
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope) => {
await deleteJournalEntryForScope(scope, { id });
return apiJson({ ok: true });
});
}
+23
View File
@@ -0,0 +1,23 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createJournalEntryForScope } from "@/modules/journal/server/actions";
import { journalEntryInput } from "@/modules/journal/server/schemas";
import { listJournalEntriesForScope } from "@/modules/journal/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 20, 1), 100) : undefined;
const entries = await listJournalEntriesForScope(scope, limit ? { limit } : undefined);
return apiJson(entries);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = journalEntryInput.parse(body);
const entry = await createJournalEntryForScope(scope, parsed);
return apiJson(entry, 201);
});
}
@@ -0,0 +1,32 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteItemForScope, updateItemForScope } from "@/modules/lists/server/actions";
import { updateItemInput } from "@/modules/lists/server/schemas";
import { z } from "zod";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string; itemId: string }> },
) {
const { itemId } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(itemId);
const body: unknown = await req.json();
const parsed = updateItemInput.omit({ id: true }).parse(body);
const list = await updateItemForScope(scope, { id: itemId, ...parsed });
const item = list.items.find((entry) => entry.id === itemId);
if (!item) throw new Error("Item not found");
return apiJson(item);
});
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string; itemId: string }> },
) {
const { itemId } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(itemId);
await deleteItemForScope(scope, { id: itemId });
return apiJson({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { addItemForScope } from "@/modules/lists/server/actions";
import { itemInput } from "@/modules/lists/server/schemas";
import { listItemsForScope } from "@/modules/lists/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const items = await listItemsForScope(scope.householdId, id);
return apiJson(items);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = itemInput.omit({ listId: true }).parse(body);
const item = await addItemForScope(scope, { listId: id, ...parsed });
return apiJson(item, 201);
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteListForScope, updateListForScope } from "@/modules/lists/server/actions";
import { listUpdateInput } from "@/modules/lists/server/schemas";
import { getListForScope } from "@/modules/lists/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const list = await getListForScope(scope.householdId, id);
return apiJson(list);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = listUpdateInput.parse(body);
await updateListForScope(scope, { id, ...parsed });
const list = await getListForScope(scope.householdId, id);
return apiJson(list);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteListForScope(scope, { id });
return apiJson({ ok: true });
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createListForScope } from "@/modules/lists/server/actions";
import { listInput } from "@/modules/lists/server/schemas";
import { listListsForScope } from "@/modules/lists/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const type = url.searchParams.get("type") ?? undefined;
const lists = await listListsForScope(scope.householdId, type ? { type } : undefined);
return apiJson(lists);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = listInput.parse(body);
const list = await createListForScope(scope, parsed);
return apiJson(
{
id: list.id,
type: list.type,
name: list.name,
archived: list.archived,
openCount: 0,
doneCount: 0,
createdAt: list.createdAt.toISOString(),
},
201,
);
});
}
+32
View File
@@ -0,0 +1,32 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteNoteForScope, updateNoteForScope } from "@/modules/notes/server/actions";
import { updateNoteInput } from "@/modules/notes/server/schemas";
import { getNoteForScope } from "@/modules/notes/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const note = await getNoteForScope(scope.householdId, id);
return apiJson(note);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = updateNoteInput.omit({ id: true }).parse(body);
const note = await updateNoteForScope(scope, { id, ...parsed });
return apiJson(note);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteNoteForScope(scope, { id });
return apiJson({ ok: true });
});
}
+20
View File
@@ -0,0 +1,20 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createNoteForScope } from "@/modules/notes/server/actions";
import { noteInput } from "@/modules/notes/server/schemas";
import { listNotesForScope } from "@/modules/notes/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const notes = await listNotesForScope(scope.householdId);
return apiJson(notes);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = noteInput.parse(body);
const note = await createNoteForScope(scope, parsed);
return apiJson(note, 201);
});
}
+14
View File
@@ -0,0 +1,14 @@
import { readFile } from "fs/promises";
import path from "path";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) {
return withApiHandler(request, async () => {
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
const spec = await readFile(specPath, "utf8");
return new Response(spec, {
status: 200,
headers: { "Content-Type": "application/yaml; charset=utf-8" },
});
});
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { revokeShareLinkForScope } from "@/modules/_core/share-api";
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await revokeShareLinkForScope(scope, id);
return apiJson({ ok: true });
});
}
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
createShareLinkForScope,
listShareLinksForScope,
listShareableEntityTypes,
} from "@/modules/_core/share-api";
const createShareLinkInput = z.object({
entityType: z.string().trim().min(1),
entityId: z.string().uuid(),
expiresAt: z.string().datetime().nullable().optional(),
capabilities: z
.object({
read: z.boolean().optional(),
write: z.boolean().optional(),
})
.optional(),
});
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const entityType = url.searchParams.get("entityType") ?? undefined;
const entityId = url.searchParams.get("entityId") ?? undefined;
if (url.searchParams.get("entityTypes") === "true") {
return apiJson(listShareableEntityTypes());
}
const links = await listShareLinksForScope(scope, { entityType, entityId });
return apiJson(links);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = createShareLinkInput.parse(body);
const expiresAt = parsed.expiresAt ? new Date(parsed.expiresAt) : null;
const result = await createShareLinkForScope(scope, parsed.entityType, parsed.entityId, {
expiresAt,
capabilities: parsed.capabilities,
});
return apiJson(
{
url: result.url,
expiresAt: result.expiresAt?.toISOString() ?? null,
},
201,
);
});
}
+21 -3
View File
@@ -1,11 +1,13 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard";
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor";
import { DashboardWidgetContent } from "@/components/dashboard-widget-content";
import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab";
@@ -48,15 +50,31 @@ export default async function DashboardPage({
const dashboard = await getDashboardBySlug(slug);
if (!dashboard) notFound();
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const storedLayout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const draftLayout = isEditing ? await readEditorDraftLayout(dashboard.id) : null;
const layout = normalizeDashboardLayout(draftLayout ?? storedLayout);
const widgetMetas = getWidgetMetas();
if (isEditing) {
const ctx = { userId: user.id, householdId: household.id };
const widgetContents = Object.fromEntries(
layout.widgets.map((placement) => [
widgetContentKey(placement),
<DashboardWidgetContent
key={widgetContentKey(placement)}
placement={placement}
ctx={ctx}
/>,
]),
);
return (
<DashboardEditor
key={`${dashboard.id}:${JSON.stringify(layout.widgets)}`}
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
layout={layout}
widgetMetas={widgetMetas}
widgetContents={widgetContents}
/>
);
}
+32
View File
@@ -10,6 +10,7 @@ import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema";
import { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { clearEditorDraftLayout, writeEditorDraftLayout } from "@/lib/dashboard-editor-draft";
export type DashboardMeta = {
id: string;
@@ -156,9 +157,39 @@ export async function saveDashboardLayout(id: string, layout: DashboardLayout):
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/");
}
export async function syncEditorDraftLayout(id: string, layout: DashboardLayout): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
for (const placement of layout.widgets) {
const widget = getWidget(placement.widgetId);
if (!widget) continue;
widget.configSchema.parse(placement.config);
}
await writeEditorDraftLayout(id, layout);
}
export async function discardEditorDraftLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
await clearEditorDraftLayout(id);
}
export async function resetDashboardLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const layout = computeDefaultLayout();
@@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise<void> {
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/");
}
+4 -2
View File
@@ -1,7 +1,8 @@
import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { DetailBackLink } from "@/components/detail-back-link";
import { getShareLinksForEntity } from "@/modules/_core/share";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getContainer } from "@/modules/garden/server/queries";
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -12,6 +13,7 @@ export default async function ContainerPage({ params }: { params: Promise<{ id:
if (!container) notFound();
return (
<div className="page-content">
<DetailBackLink href="/garden" label="Garden" className="mb-4" />
<ContainerDetail container={container} shareLinks={shareLinks} />
</div>
);
+3 -15
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { DetailBackLink } from "@/components/detail-back-link";
import { createContainer } from "@/modules/garden/server/actions";
export default function NewContainerPage() {
@@ -14,21 +15,8 @@ export default function NewContainerPage() {
return (
<div className="page-content max-w-lg">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<div className="mb-1 flex flex-col gap-2">
<DetailBackLink href="/garden" label="Garden" />
<h1 className="page-title">New container</h1>
</div>
<form action={handleCreate} className="flex flex-col gap-4 mt-4">
+3 -1
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { getPlant, listContainers } from "@/modules/garden/server/queries";
import { DetailBackLink } from "@/components/detail-back-link";
import { PlantForm } from "@/modules/garden/components/plant-form";
import { getPlant, listContainers } from "@/modules/garden/server/queries";
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -9,6 +10,7 @@ export default async function EditPlantPage({ params }: { params: Promise<{ id:
return (
<div className="page-content max-w-xl">
<DetailBackLink href={`/garden/plants/${plant.id}`} label={plant.name} className="mb-3" />
<h1 className="page-title">Edit Plant</h1>
<PlantForm existingPlant={plant} containers={containers} />
</div>
+4 -2
View File
@@ -1,8 +1,9 @@
import { notFound } from "next/navigation";
import { DetailBackLink } from "@/components/detail-back-link";
import { getShareLinksForEntity } from "@/modules/_core/share";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -17,6 +18,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
return (
<div className="page-content">
<DetailBackLink href="/garden?tab=plants" label="Plants" className="mb-4" />
<PlantDetail
plant={plant}
careLogs={careLogs}
+4 -16
View File
@@ -1,5 +1,6 @@
import { listContainers } from "@/modules/garden/server/queries";
import { DetailBackLink } from "@/components/detail-back-link";
import { PlantForm } from "@/modules/garden/components/plant-form";
import { listContainers } from "@/modules/garden/server/queries";
export default async function NewPlantPage({
searchParams,
@@ -11,21 +12,8 @@ export default async function NewPlantPage({
return (
<div className="page-content max-w-xl">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<div className="mb-1 flex flex-col gap-2">
<DetailBackLink href="/garden?tab=plants" label="Plants" />
<h1 className="page-title">Add Plant</h1>
</div>
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
+139
View File
@@ -818,6 +818,86 @@
bottom: 18px;
}
/* ── Assistant chat bubble (opt-in per user) ─────────────────────── */
.assistant-bubble {
position: fixed;
right: 18px;
bottom: 24px;
z-index: 70;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
pointer-events: none;
}
:where(html[data-nav="bottom"]) .assistant-bubble {
bottom: calc(62px + max(env(safe-area-inset-bottom, 0px), 8px) + 12px);
}
:where(html[data-nav="fab"]) .assistant-bubble {
bottom: calc(18px + 52px + 12px);
}
.assistant-bubble > * {
pointer-events: auto;
}
.assistant-bubble-panel {
display: flex;
flex-direction: column;
width: min(360px, calc(100vw - 24px));
height: min(480px, calc(100vh - 140px));
border-radius: var(--r-md);
border: 0.5px solid var(--hair);
background: color-mix(in oklab, var(--card) 94%, transparent);
backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 12px 40px rgba(31, 27, 22, 0.16),
0 2px 8px rgba(31, 27, 22, 0.08);
padding: 12px;
overflow: hidden;
}
.assistant-bubble-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
flex-shrink: 0;
}
.assistant-bubble-close {
appearance: none;
border: 0;
background: transparent;
color: var(--ink-mute);
border-radius: 8px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.assistant-bubble-close:hover {
background: var(--shade);
color: var(--ink);
}
.assistant-bubble-trigger {
width: 52px;
height: 52px;
border-radius: 999px;
border: 0;
background: var(--ink);
color: var(--paper);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow:
0 4px 14px rgba(31, 27, 22, 0.22),
0 1px 2px rgba(31, 27, 22, 0.18);
}
.assistant-bubble-trigger:hover {
background: var(--ink-2);
}
/* Buttons (used inside .topbar etc; <Button> in shadcn comes from
button.tsx and uses these tokens via the theme bridge) */
.btn {
@@ -1430,6 +1510,65 @@ select {
animation: emojiFloat 1.8s ease-out both;
}
@keyframes journal-paper-out {
to {
opacity: 0;
transform: translateX(-28px) rotate(-1.2deg);
}
}
@keyframes journal-paper-in {
from {
opacity: 0;
transform: translateX(32px) rotate(0.8deg);
}
to {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
}
.journal-entries-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.journal-entries-stack[data-phase="exit"] .journal-entry-row {
animation: journal-paper-out 0.2s ease-in forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
opacity: 0;
animation: journal-paper-in 0.26s ease-out forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(1) {
animation-delay: 0ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(2) {
animation-delay: 45ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(3) {
animation-delay: 90ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(4) {
animation-delay: 135ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(5) {
animation-delay: 180ms;
}
@media (prefers-reduced-motion: reduce) {
.journal-entries-stack[data-phase="exit"] .journal-entry-row,
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
animation: none;
opacity: 1;
transform: none;
}
}
/* ── 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). */
+10
View File
@@ -0,0 +1,10 @@
import { notFound } from "next/navigation";
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
import { getJournalEntry } from "@/modules/journal/server/queries";
export default async function JournalEntryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const entry = await getJournalEntry(id).catch(() => null);
if (!entry) notFound();
return <JournalEntryEditor entry={entry} />;
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { DetailBackLink } from "@/components/detail-back-link";
import { InsightsView } from "@/modules/journal/components/insights-view";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalInsightsPage() {
const entries = await listJournalEntries({ limit: 500 });
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 min-w-0">
<DetailBackLink href="/journal" label="Journal" />
<div className="flex items-center justify-between gap-3">
<h2 className="serif text-[22px] tracking-tight">Insights</h2>
<Link href="/journal/mood" className="text-[13px] text-[var(--accent)] hover:underline">
Mood charts
</Link>
</div>
<InsightsView entries={entries} />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { DetailBackLink } from "@/components/detail-back-link";
import { MoodTrackerView } from "@/modules/journal/components/mood-tracker-view";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalMoodPage() {
const entries = await listJournalEntries({ limit: 500 });
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 min-w-0">
<DetailBackLink href="/journal" label="Journal" />
<div className="flex items-center justify-between gap-3">
<h2 className="serif text-[22px] tracking-tight">Mood tracker</h2>
<Link href="/journal/insights" className="text-[13px] text-[var(--accent)] hover:underline">
View insights
</Link>
</div>
<MoodTrackerView entries={entries} />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
export default function NewJournalEntryPage() {
return <JournalEntryEditor />;
}
+13
View File
@@ -0,0 +1,13 @@
import { Suspense } from "react";
import { JournalIndex } from "@/modules/journal/components/journal-index";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalPage() {
const entries = await listJournalEntries();
return (
<Suspense>
<JournalIndex entries={entries} />
</Suspense>
);
}
+8
View File
@@ -17,6 +17,8 @@ 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 { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
import { isLlmConfigured } from "@/lib/llm";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -99,6 +101,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
let assistantEnabled = false;
const session = await auth();
if (session?.user?.id) {
@@ -110,6 +113,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled,
})
.from(users)
.where(eq(users.id, session.user.id))
@@ -120,6 +124,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled;
}
userDashboards = await db
.select({
@@ -172,6 +177,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<CommandPalette />
<InstallPrompt />
<PwaRegister />
{signedIn && assistantEnabled && session?.user?.id ? (
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} />
) : null}
<AppToaster position="bottom-right" />
</QuickAddProvider>
</body>
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { signIn } from "@/lib/auth";
import { createDevSession } from "@/lib/dev-login";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
export async function signInWithSso() {
await signIn("authentik", { redirectTo: "/" });
}
export async function devLogin() {
if (!isDevLoginEnabled()) {
throw new Error("Dev login is not enabled");
}
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}
+6 -32
View File
@@ -1,10 +1,7 @@
import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { devLogin, signInWithSso } from "./actions";
export default function LoginPage() {
const devLoginEnabled = isDevLoginEnabled();
@@ -23,41 +20,18 @@ export default function LoginPage() {
</div>
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
<form
action={async () => {
"use server";
await signIn("authentik", { redirectTo: "/" });
}}
>
<form action={signInWithSso}>
<Button type="submit" size="lg" className="w-full">
Sign in with SSO
</Button>
</form>
{devLoginEnabled && (
<form
action={async () => {
"use server";
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
// trustHost, and proxy headers. Set both so the session is found
// regardless — this is dev-only code, correctness > elegance.
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}}
className="mt-3"
>
{devLoginEnabled ? (
<form action={devLogin} className="mt-3">
<Button type="submit" variant="outline" size="lg" className="w-full">
Dev login
</Button>
</form>
)}
) : null}
</div>
</main>
);
+20
View File
@@ -24,6 +24,7 @@ import type {
} from "@/modules/_core/themes";
import { getCurrentSession } from "@/lib/session";
import { revokeShareLink } from "@/modules/_core/share";
import { createHouseholdApiToken, revokeHouseholdApiToken } from "@/modules/_core/api-token";
export interface ThemePatch {
palette?: Palette;
@@ -88,3 +89,22 @@ export async function revokeShareLinkAction(formData: FormData): Promise<void> {
await revokeShareLink(id);
revalidatePath("/settings");
}
function requireOwner(role: "owner" | "member") {
if (role !== "owner") throw new Error("Only household owners can manage API tokens");
}
export async function createApiTokenAction(): Promise<{ token: string }> {
const { household, role, user } = await getCurrentSession();
requireOwner(role);
const result = await createHouseholdApiToken(household.id, user.id);
revalidatePath("/settings");
return result;
}
export async function revokeApiTokenAction(): Promise<void> {
const { household, role } = await getCurrentSession();
requireOwner(role);
await revokeHouseholdApiToken(household.id);
revalidatePath("/settings");
}
+14
View File
@@ -0,0 +1,14 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
export async function setAssistantEnabled(enabled: boolean): Promise<void> {
const { user } = await getCurrentSession();
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
revalidatePath("/settings");
revalidatePath("/", "layout");
}
+5 -3
View File
@@ -1,10 +1,11 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { DetailBackLink } from "@/components/detail-back-link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { renameHousehold } from "./actions";
export default async function HouseholdSettingsPage() {
@@ -18,6 +19,7 @@ export default async function HouseholdSettingsPage() {
return (
<div className="container max-w-2xl py-8 space-y-6">
<DetailBackLink href="/settings" label="Settings" />
<h1 className="text-2xl font-semibold">Household Settings</h1>
<Card>
+102 -53
View File
@@ -11,11 +11,25 @@ import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
import { ApiTokenSettings } from "@/components/api-token-settings";
import { AssistantOptIn } from "@/components/assistant-opt-in";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link";
import { NavIcon } from "@/components/nav-icon";
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react";
import {
Mail,
Globe,
History,
Sun,
Bell,
Pencil,
Lock,
Plus,
KeyRound,
MessageCircle,
} from "lucide-react";
const VALID_SECTIONS = new Set<SectionId>([
"household",
@@ -36,7 +50,7 @@ export default async function SettingsPage({
? (sp.s as SectionId)
: "household";
const { user, household } = await getCurrentSession();
const { user, household, role } = await getCurrentSession();
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
@@ -51,7 +65,7 @@ export default async function SettingsPage({
)}
{section === "calendars" && <CalendarsAndListsSection />}
{section === "appearance" && <AppearanceSection user={user} />}
{section === "data" && <DataSection />}
{section === "data" && <DataSection householdId={household.id} role={role} />}
</div>
</div>
);
@@ -287,64 +301,99 @@ function AppearanceSection({
themeDashLayout: string;
themeCalView: string;
themeNavStyle: string;
assistantEnabled: boolean;
};
}) {
return (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
<>
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<Sun className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<ThemePicker
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
initialMode={user.themeMode as "light" | "dark" | "system"}
initialFontPair={
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
}
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
initialCalView={user.themeCalView as "month" | "week" | "day"}
initialNavStyle={
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
}
signedIn
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Assistant</CardTitle>
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<AssistantOptIn enabled={user.assistantEnabled} />
</CardContent>
</Card>
</>
);
}
function DataSection() {
async function DataSection({
householdId,
role,
}: {
householdId: string;
role: "owner" | "member";
}) {
const tokenStatus = role === "owner" ? await getHouseholdApiTokenStatus(householdId) : null;
return (
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
<>
{role === "owner" && tokenStatus && (
<Card>
<CardHeader>
<CardTitle>API access</CardTitle>
<KeyRound className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent className="p-0">
<ApiTokenSettings status={tokenStatus} />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
</Card>
</Card>
</>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState, useTransition } from "react";
import { Check, Copy, KeyRound } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { createApiTokenAction, revokeApiTokenAction } from "@/app/settings/actions";
import type { HouseholdApiTokenStatus } from "@/modules/_core/api-token";
export function ApiTokenSettings({ status }: { status: HouseholdApiTokenStatus }) {
const [tokenStatus, setTokenStatus] = useState(status);
const [rawToken, setRawToken] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [isPending, startTransition] = useTransition();
function generateToken() {
startTransition(async () => {
try {
const result = await createApiTokenAction();
setRawToken(result.token);
setDialogOpen(true);
setTokenStatus({
hasActiveToken: true,
lastUsedAt: null,
createdAt: new Date(),
});
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to generate token");
}
});
}
function revokeToken() {
startTransition(async () => {
try {
await revokeApiTokenAction();
setTokenStatus({ hasActiveToken: false, lastUsedAt: null, createdAt: null });
toast.success("API token revoked");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to revoke token");
}
});
}
function copyToken() {
if (!rawToken) return;
navigator.clipboard.writeText(rawToken).then(() => {
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
});
}
return (
<>
<div className="set-row" style={{ borderBottom: "0" }}>
<KeyRound className="size-4 text-[var(--ink-soft)]" />
<div className="label flex-1">
<div className="t">API token</div>
<div className="d">
{tokenStatus.hasActiveToken ? (
<>
Active
{tokenStatus.createdAt && (
<> · created {tokenStatus.createdAt.toLocaleDateString()}</>
)}
{tokenStatus.lastUsedAt && (
<> · last used {tokenStatus.lastUsedAt.toLocaleString()}</>
)}
{!tokenStatus.lastUsedAt && <> · never used</>}
</>
) : (
<>No active token generate one for scripts and automations</>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
{tokenStatus.hasActiveToken ? (
<Button variant="destructive" size="sm" onClick={revokeToken} disabled={isPending}>
Revoke token
</Button>
) : null}
<Button variant="outline" size="sm" onClick={generateToken} disabled={isPending}>
{tokenStatus.hasActiveToken ? "Regenerate" : "Generate token"}
</Button>
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>API token created</DialogTitle>
<DialogDescription>
Copy this token now it will not be shown again. Use it as{" "}
<code className="text-xs">Authorization: Bearer &lt;token&gt;</code> on{" "}
<code className="text-xs">/api/v1/</code> requests.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input readOnly value={rawToken ?? ""} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={copyToken} aria-label="Copy token">
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { setAssistantEnabled } from "@/app/settings/assistant-actions";
import { Switch } from "@/components/ui/switch";
export function AssistantOptIn({ enabled }: { enabled: boolean }) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
function toggle(next: boolean) {
startTransition(async () => {
await setAssistantEnabled(next);
router.refresh();
});
}
return (
<label className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">AI assistant</div>
<p className="muted text-[12px] mt-0.5">
Off by default. Turn on to show a chat bubble in the bottom-right corner.
</p>
</div>
<Switch
checked={enabled}
disabled={isPending}
onCheckedChange={toggle}
aria-label="AI assistant"
/>
</label>
);
}
+40 -11
View File
@@ -3,15 +3,20 @@
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useState, useTransition, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { GridLayout } from "react-grid-layout";
import type { Layout } from "react-grid-layout";
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
import { computePresetLayoutFromMetas } from "@/lib/dashboard";
import { computePresetLayoutFromMetas, widgetContentKey } from "@/lib/dashboard";
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions";
import {
saveDashboardLayout,
resetDashboardLayout,
syncEditorDraftLayout,
discardEditorDraftLayout,
} from "@/app/d/actions";
import { Button } from "@/components/ui/button";
import { WidgetPicker } from "./widget-picker";
@@ -23,10 +28,12 @@ export function DashboardEditor({
dashboard,
layout: initialLayout,
widgetMetas,
widgetContents,
}: {
dashboard: { id: string; name: string; slug: string };
layout: DashboardLayout;
widgetMetas: SerializedWidgetMeta[];
widgetContents: Record<string, ReactNode>;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -56,8 +63,18 @@ export function DashboardEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.slug]);
function publishDraft(next: WidgetPlacement[]) {
startTransition(async () => {
await syncEditorDraftLayout(dashboard.id, { version: 1, widgets: next });
router.refresh();
});
}
function handleCancel() {
router.push(`/d/${dashboard.slug}`);
startTransition(async () => {
await discardEditorDraftLayout(dashboard.id);
router.push(`/d/${dashboard.slug}`);
});
}
function handleSave() {
@@ -87,26 +104,32 @@ export function DashboardEditor({
}
function removeWidget(index: number) {
setPlacements((current) => current.filter((_, i) => i !== index));
const next = placements.filter((_, i) => i !== index);
setPlacements(next);
setIsDirty(true);
publishDraft(next);
}
function addWidget(widgetId: string, config: unknown) {
const meta = widgetMetas.find((m) => m.id === widgetId);
if (!meta) return;
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
setPlacements((current) => [
...current,
const next = [
...placements,
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h },
]);
];
setPlacements(next);
setIsDirty(true);
setPickerOpen(false);
publishDraft(next);
}
function updateConfig(index: number, config: unknown) {
setPlacements((current) => current.map((p, i) => (i === index ? { ...p, config } : p)));
const next = placements.map((p, i) => (i === index ? { ...p, config } : p));
setPlacements(next);
setIsDirty(true);
setConfiguringIndex(null);
publishDraft(next);
}
function applyPreset(preset: PresetId) {
@@ -114,6 +137,7 @@ export function DashboardEditor({
setPlacements(next.widgets);
setIsDirty(true);
setPresetMenuOpen(false);
publishDraft(next.widgets);
}
const gridItems: Layout = placements.map((p, i) => ({
@@ -213,6 +237,7 @@ export function DashboardEditor({
>
{placements.map((placement, i) => {
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
const content = widgetContents[widgetContentKey(placement)];
return (
<div
key={placementKey(placement, i)}
@@ -240,8 +265,12 @@ export function DashboardEditor({
<Trash2 className="size-4" />
</button>
</div>
<div className="flex-1 flex items-center justify-center p-4">
<p className="text-xs text-muted-foreground">{meta?.description}</p>
<div className="flex-1 min-h-0 overflow-auto p-4">
{content ?? (
<p className="text-xs text-muted-foreground text-center">
{meta?.description ?? "Preview available after save"}
</p>
)}
</div>
</div>
);
+3 -3
View File
@@ -1,5 +1,3 @@
"use client";
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
@@ -18,7 +16,9 @@ export function DashboardGreeting({ firstName }: { firstName: string }) {
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
<p className="muted mt-1 text-[13px]" suppressHydrationWarning>
{today}
</p>
</div>
);
}
@@ -0,0 +1,32 @@
import { Suspense } from "react";
import type { WidgetPlacement } from "@/lib/dashboard";
import { getWidget } from "@/modules/_core";
import type { WidgetContext } from "@/modules/_core/module";
import { Skeleton } from "@/components/ui/skeleton";
export function DashboardWidgetContent({
placement,
ctx,
}: {
placement: WidgetPlacement;
ctx: WidgetContext;
}) {
const widget = getWidget(placement.widgetId);
if (!widget) {
return <p className="text-xs text-muted-foreground">Unknown widget</p>;
}
return (
<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>
}
>
{widget.render({ config: placement.config, ctx })}
</Suspense>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";
type DetailBackLinkProps = {
href: string;
label: string;
className?: string;
};
export function DetailBackLink({ href, label, className }: DetailBackLinkProps) {
return (
<Link
href={href}
aria-label={`Back to ${label}`}
className={cn(
"inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground",
className,
)}
>
<ArrowLeft aria-hidden="true" />
<span>{label}</span>
</Link>
);
}
+4
View File
@@ -1,5 +1,6 @@
import {
Bell,
BookHeart,
Calendar,
Sprout,
CalendarDays,
@@ -20,6 +21,7 @@ import {
Lock,
Mail,
Menu,
MessageCircle,
MoreHorizontal,
Pencil,
Phone,
@@ -45,6 +47,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
list: ListChecks,
"check-square": CheckSquare,
"file-text": FileText,
"book-heart": BookHeart,
note: FileText,
settings: Settings,
history: History,
@@ -72,6 +75,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
sprout: Sprout,
mail: Mail,
menu: Menu,
"message-circle": MessageCircle,
more: MoreHorizontal,
x: X,
"chevron-left": ChevronLeft,
+5
View File
@@ -6,6 +6,7 @@ import { CalendarCreateDialog } from "@/components/quick-add/dialogs/calendar-cr
import { CalendarEventCreateDialog } from "@/components/quick-add/dialogs/calendar-event-create-dialog";
import { ContainerCreateDialog } from "@/components/quick-add/dialogs/container-create-dialog";
import { GardenCareLogDialog } from "@/components/quick-add/dialogs/garden-care-log-dialog";
import { JournalEntryCreateDialog } from "@/components/quick-add/dialogs/journal-entry-create-dialog";
import { ListCreateDialog } from "@/components/quick-add/dialogs/list-create-dialog";
import { ListItemCreateDialog } from "@/components/quick-add/dialogs/list-item-create-dialog";
import { NoteCreateDialog } from "@/components/quick-add/dialogs/note-create-dialog";
@@ -29,6 +30,10 @@ export function QuickAddCreateHost() {
onOpenChange={onOpenChange}
/>
<NoteCreateDialog open={activeCreateKey === "notes.note"} onOpenChange={onOpenChange} />
<JournalEntryCreateDialog
open={activeCreateKey === "journal.entry"}
onOpenChange={onOpenChange}
/>
<ListItemCreateDialog
open={activeCreateKey === "lists.add-shopping"}
onOpenChange={onOpenChange}
@@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -20,8 +21,21 @@ import {
SelectValue,
} from "@/components/ui/select";
import { createEvent } from "@/modules/calendar/server/actions";
import { richTextToPlainText } from "@/components/rich-text";
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-24 rounded-lg border border-input px-3 py-2 text-sm text-muted-foreground animate-pulse">
Loading editor
</div>
),
},
);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -30,7 +44,7 @@ type Props = {
export function CalendarEventCreateDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-2xl">
{open ? <CalendarEventCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
@@ -71,7 +85,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
endAt: new Date(endAt),
allDay: false,
location: location || null,
notes: notes || null,
notes: richTextToPlainText(notes) ? notes : null,
remindMinutesBefore: remind ? 30 : null,
});
onDone();
@@ -146,13 +160,15 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-event-notes">Notes</Label>
<textarea
<RichTextEditor
id="qa-event-notes"
className="min-h-16 w-full 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"
aria-label="Notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={setNotes}
disabled={isPending}
placeholder="Add details…"
/>
</div>
<label className="flex items-center gap-2 text-sm">
@@ -0,0 +1,168 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { MoodPicker } from "@/modules/journal/components/mood-picker";
import { StressSlider } from "@/modules/journal/components/stress-slider";
import { createJournalEntry } from "@/modules/journal/server/actions";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-40 rounded-[var(--r-md)] border-[0.5px] px-3 py-2 text-sm muted animate-pulse">
Loading editor
</div>
),
},
);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function JournalEntryCreateDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
{open ? <JournalEntryCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
);
}
function JournalEntryCreateForm({ onDone }: { onDone: () => void }) {
const router = useRouter();
const [recordedAt, setRecordedAt] = useState(toLocalDateTimeValue(null));
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [moods, setMoods] = useState<string[]>([]);
const [stress, setStress] = useState(5);
const [trackStress, setTrackStress] = useState(true);
const [pillsTaken, setPillsTaken] = useState(false);
const [trackPills, setTrackPills] = useState(true);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await createJournalEntry({
recordedAt: new Date(recordedAt),
title: title.trim() || null,
body,
moods,
stress: trackStress ? stress : null,
pillsTaken: trackPills ? pillsTaken : null,
});
router.refresh();
onDone();
});
}
return (
<>
<DialogHeader>
<DialogTitle>New journal entry</DialogTitle>
</DialogHeader>
<form id="quick-add-journal-form" onSubmit={handleSubmit} className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="qa-journal-recorded">When</Label>
<Input
id="qa-journal-recorded"
type="datetime-local"
value={recordedAt}
onChange={(e) => setRecordedAt(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="qa-journal-title">Title</Label>
<Input
id="qa-journal-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="space-y-1.5">
<Label>Moods</Label>
<MoodPicker value={moods} onChange={setMoods} disabled={isPending} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="qa-journal-stress">Stress (110)</Label>
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Track</span>
<Switch checked={trackStress} onCheckedChange={setTrackStress} />
</div>
</div>
<StressSlider
id="qa-journal-stress"
value={stress}
onChange={setStress}
disabled={!trackStress || isPending}
/>
{!trackStress ? (
<div className="text-[12px] text-muted-foreground">Not tracked</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="qa-journal-pills">Pills today</Label>
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Track</span>
<Switch checked={trackPills} onCheckedChange={setTrackPills} />
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Switch
id="qa-journal-pills"
checked={pillsTaken}
disabled={!trackPills || isPending}
onCheckedChange={setPillsTaken}
/>
<span className="text-[13px]">{pillsTaken ? "Taken" : "Not taken"}</span>
</div>
</div>
</div>
<div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-journal-body">Reflection</Label>
<RichTextEditor
id="qa-journal-body"
aria-label="Reflection"
value={body}
onChange={setBody}
disabled={isPending}
placeholder="What happened today?"
/>
</div>
</form>
<DialogFooter>
<Button type="submit" form="quick-add-journal-form" disabled={isPending}>
Save entry
</Button>
</DialogFooter>
</>
);
}
function toLocalDateTimeValue(value: string | null) {
const date = value ? new Date(value) : new Date();
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
@@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -13,6 +14,18 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createNote } from "@/modules/notes/server/actions";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-32 rounded-lg border border-input px-3 py-2 text-sm text-muted-foreground animate-pulse">
Loading editor
</div>
),
},
);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -21,7 +34,7 @@ type Props = {
export function NoteCreateDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-2xl">
{open ? <NoteCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
@@ -62,14 +75,15 @@ function NoteCreateForm({ onDone }: { onDone: () => void }) {
required
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-note-body">Body</Label>
<textarea
<RichTextEditor
id="qa-note-body"
aria-label="Body"
className="min-h-32 w-full 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={body}
onChange={(e) => setBody(e.target.value)}
onChange={setBody}
disabled={isPending}
placeholder="Start writing…"
/>
</div>
<div className="space-y-1.5">
+24
View File
@@ -0,0 +1,24 @@
import { normalizeNoteBody } from "./normalize-body";
import { sanitizeRichTextHtml } from "./sanitize";
export function richTextToPlainText(body: string, maxLength?: number): string {
const normalized = normalizeNoteBody(body);
const safe = sanitizeRichTextHtml(normalized);
const text = safe
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<\/li>/gi, "\n")
.replace(/<\/h[1-6]>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/\n{3,}/g, "\n\n")
.trim();
if (maxLength === undefined) return text;
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trimEnd()}`;
}
+6
View File
@@ -0,0 +1,6 @@
export { RichTextContent } from "./rich-text-content";
export { RichTextEditor } from "./rich-text-editor";
export { richTextToPlainText } from "./body-text";
export { normalizeNoteBody } from "./normalize-body";
export { sanitizeRichTextHtml } from "./sanitize";
export { countTaskItems, toggleTaskItemInHtml } from "./toggle-checklist";
@@ -0,0 +1,36 @@
const HTML_LIKE = /^\s*</;
export function isLikelyHtml(body: string): boolean {
return HTML_LIKE.test(body);
}
export function normalizeNoteBody(body: string): string {
if (!body.trim()) return "";
if (isLikelyHtml(body)) return body;
const paragraphs = body
.replace(/\r\n/g, "\n")
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean);
if (paragraphs.length === 0) return "";
return paragraphs
.map((block) => {
const lines = block
.split("\n")
.map((line) => escapeHtml(line.trim()))
.filter(Boolean);
return `<p>${lines.join("<br>")}</p>`;
})
.join("");
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
@@ -0,0 +1,91 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useTransition } from "react";
import { normalizeNoteBody } from "./normalize-body";
import { sanitizeRichTextHtml } from "./sanitize";
import "./rich-text.css";
type ToggleHandler = (taskIndex: number, checked: boolean) => Promise<void>;
type Props = {
html: string;
interactiveChecklists?: boolean;
onToggleChecklist?: ToggleHandler;
className?: string;
clampLines?: number;
};
export function RichTextContent({
html,
interactiveChecklists = false,
onToggleChecklist,
className,
clampLines,
}: Props) {
const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null);
const [isPending, startTransition] = useTransition();
const safeHtml = useMemo(() => {
const normalized = normalizeNoteBody(html);
return sanitizeRichTextHtml(normalized);
}, [html]);
useEffect(() => {
const root = containerRef.current;
if (!root || !interactiveChecklists || !onToggleChecklist) return;
const items = [...root.querySelectorAll<HTMLLIElement>('li[data-type="taskItem"]')];
for (const [index, item] of items.entries()) {
const checked = item.getAttribute("data-checked") === "true";
let label = item.querySelector("label");
if (!label) {
label = document.createElement("label");
item.insertBefore(label, item.firstChild);
}
let input = label.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (!input) {
input = document.createElement("input");
input.type = "checkbox";
label.prepend(input);
}
input.checked = checked;
input.disabled = isPending;
input.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const nextChecked = !input!.checked;
startTransition(async () => {
await onToggleChecklist(index, nextChecked);
router.refresh();
});
};
}
}, [safeHtml, interactiveChecklists, onToggleChecklist, isPending, router]);
if (!safeHtml) {
return <p className="text-sm text-[var(--ink-mute)]">No body text.</p>;
}
return (
<div
ref={containerRef}
className={["rich-text-content", className].filter(Boolean).join(" ")}
style={
clampLines
? {
display: "-webkit-box",
WebkitLineClamp: clampLines,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}
: undefined
}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
);
}
@@ -0,0 +1,118 @@
"use client";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { Table } from "@tiptap/extension-table";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import Underline from "@tiptap/extension-underline";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { normalizeNoteBody } from "./normalize-body";
import { RichTextToolbar } from "./rich-text-toolbar";
import "./rich-text.css";
type Props = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
disabled?: boolean;
id?: string;
"aria-label"?: string;
};
async function uploadImage(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/uploads?scope=notes", { method: "POST", body: formData });
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error ?? "Image upload failed");
}
const payload = (await response.json()) as { url: string };
return payload.url;
}
export function RichTextEditor({
value,
onChange,
placeholder = "Start writing…",
disabled = false,
id,
"aria-label": ariaLabel,
}: Props) {
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const editor = useEditor({
immediatelyRender: false,
editable: !disabled,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
Image.configure({ inline: false, allowBase64: false }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Placeholder.configure({ placeholder }),
],
content: normalizeNoteBody(value),
onUpdate: ({ editor: current }) => {
onChangeRef.current(current.getHTML());
},
editorProps: {
attributes: {
...(id ? { id } : {}),
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
class: "rich-text-content",
},
},
});
useEffect(() => {
if (!editor) return;
const normalized = normalizeNoteBody(value);
if (editor.getHTML() === normalized) return;
editor.commands.setContent(normalized, { emitUpdate: false });
}, [editor, value]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!disabled);
}, [editor, disabled]);
async function handleImageUpload(file: File) {
if (!editor) return;
const url = await uploadImage(file);
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
}
return (
<div className="rich-text-editor grid gap-2">
<RichTextToolbar editor={editor} onImageUpload={handleImageUpload} disabled={disabled} />
<div
className="rounded-[var(--r-md)] border-[0.5px] bg-transparent px-3 py-2"
style={{ borderColor: "var(--hair-2)" }}
>
<EditorContent editor={editor} />
</div>
</div>
);
}
@@ -0,0 +1,222 @@
"use client";
import type { Editor } from "@tiptap/react";
import {
Bold,
Code,
Heading2,
ImagePlus,
Italic,
Link2,
List,
ListChecks,
ListOrdered,
Quote,
Redo2,
Smile,
Strikethrough,
Table2,
Underline,
Undo2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Toggle } from "@/components/ui/toggle";
const EMOJIS = ["😀", "😊", "❤️", "👍", "🎉", "✅", "⭐", "🔥", "🍕", "🏠", "📅", "🛒"];
type Props = {
editor: Editor | null;
onImageUpload: (file: File) => Promise<void>;
disabled?: boolean;
};
export function RichTextToolbar({ editor, onImageUpload, disabled }: Props) {
if (!editor) return null;
const currentEditor = editor;
function setLink() {
const previous = currentEditor.getAttributes("link").href as string | undefined;
const url = window.prompt("Link URL", previous ?? "https://");
if (url === null) return;
if (url === "") {
currentEditor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
currentEditor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}
function insertTable() {
currentEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}
function pickImage() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = () => {
const file = input.files?.[0];
if (file) void onImageUpload(file);
};
input.click();
}
return (
<div className="rich-text-toolbar">
<Toggle
size="sm"
pressed={currentEditor.isActive("bold")}
onPressedChange={() => currentEditor.chain().focus().toggleBold().run()}
disabled={disabled}
aria-label="Bold"
>
<Bold className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("italic")}
onPressedChange={() => currentEditor.chain().focus().toggleItalic().run()}
disabled={disabled}
aria-label="Italic"
>
<Italic className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("underline")}
onPressedChange={() => currentEditor.chain().focus().toggleUnderline().run()}
disabled={disabled}
aria-label="Underline"
>
<Underline className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("strike")}
onPressedChange={() => currentEditor.chain().focus().toggleStrike().run()}
disabled={disabled}
aria-label="Strikethrough"
>
<Strikethrough className="size-3.5" />
</Toggle>
<Separator orientation="vertical" className="mx-0.5 h-6" />
<Toggle
size="sm"
pressed={currentEditor.isActive("heading", { level: 2 })}
onPressedChange={() => currentEditor.chain().focus().toggleHeading({ level: 2 }).run()}
disabled={disabled}
aria-label="Heading"
>
<Heading2 className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("bulletList")}
onPressedChange={() => currentEditor.chain().focus().toggleBulletList().run()}
disabled={disabled}
aria-label="Bullet list"
>
<List className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("orderedList")}
onPressedChange={() => currentEditor.chain().focus().toggleOrderedList().run()}
disabled={disabled}
aria-label="Numbered list"
>
<ListOrdered className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("taskList")}
onPressedChange={() => currentEditor.chain().focus().toggleTaskList().run()}
disabled={disabled}
aria-label="Checklist"
>
<ListChecks className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("blockquote")}
onPressedChange={() => currentEditor.chain().focus().toggleBlockquote().run()}
disabled={disabled}
aria-label="Blockquote"
>
<Quote className="size-3.5" />
</Toggle>
<Toggle
size="sm"
pressed={currentEditor.isActive("codeBlock")}
onPressedChange={() => currentEditor.chain().focus().toggleCodeBlock().run()}
disabled={disabled}
aria-label="Code block"
>
<Code className="size-3.5" />
</Toggle>
<Separator orientation="vertical" className="mx-0.5 h-6" />
<Button type="button" variant="ghost" size="sm" onClick={setLink} disabled={disabled}>
<Link2 className="size-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={pickImage} disabled={disabled}>
<ImagePlus className="size-3.5" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={insertTable} disabled={disabled}>
<Table2 className="size-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex h-8 items-center justify-center rounded-md px-2 text-sm hover:bg-[var(--shade)]"
disabled={disabled}
aria-label="Insert emoji"
>
<Smile className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="grid grid-cols-6 gap-1 p-2">
{EMOJIS.map((emoji) => (
<DropdownMenuItem
key={emoji}
className="justify-center px-2 text-lg"
onClick={() => currentEditor.chain().focus().insertContent(emoji).run()}
>
{emoji}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="mx-0.5 h-6" />
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => currentEditor.chain().focus().undo().run()}
disabled={disabled || !currentEditor.can().undo()}
>
<Undo2 className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => currentEditor.chain().focus().redo().run()}
disabled={disabled || !currentEditor.can().redo()}
>
<Redo2 className="size-3.5" />
</Button>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
.rich-text-content {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
text-wrap: pretty;
color: var(--ink-2);
font-family: var(--serif);
font-size: 15px;
line-height: 1.65;
}
.rich-text-content > *:first-child {
margin-top: 0;
}
.rich-text-content > *:last-child {
margin-bottom: 0;
}
.rich-text-content h1,
.rich-text-content h2,
.rich-text-content h3,
.rich-text-content h4 {
color: var(--ink);
font-weight: 600;
line-height: 1.25;
margin: 1.1em 0 0.45em;
}
.rich-text-content h1 {
font-size: 1.6rem;
}
.rich-text-content h2 {
font-size: 1.35rem;
}
.rich-text-content h3 {
font-size: 1.15rem;
}
.rich-text-content p {
margin: 0.65em 0;
}
.rich-text-content ul,
.rich-text-content ol {
margin: 0.65em 0;
padding-left: 1.4rem;
}
.rich-text-content blockquote {
border-left: 3px solid var(--hair-2);
color: var(--ink-mute);
margin: 0.8em 0;
padding-left: 0.9rem;
}
.rich-text-content pre {
background: var(--shade);
border-radius: var(--r-md);
font-family: var(--mono);
font-size: 0.85em;
margin: 0.8em 0;
max-width: 100%;
overflow-x: auto;
padding: 0.75rem 0.9rem;
}
.rich-text-content code {
font-family: var(--mono);
font-size: 0.9em;
}
.rich-text-content a {
color: var(--accent);
text-decoration: underline;
overflow-wrap: anywhere;
}
.rich-text-content img {
border-radius: var(--r-md);
display: block;
height: auto;
margin: 0.8em 0;
max-width: 100%;
}
.rich-text-content table {
border-collapse: collapse;
display: block;
margin: 0.8em 0;
max-width: 100%;
overflow-x: auto;
width: max-content;
}
.rich-text-content th,
.rich-text-content td {
border: 0.5px solid var(--hair);
min-width: 4rem;
padding: 0.4rem 0.55rem;
text-align: left;
}
.rich-text-content ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.rich-text-content li[data-type="taskItem"] {
align-items: flex-start;
display: flex;
gap: 0.45rem;
margin: 0.25rem 0;
}
.rich-text-content li[data-type="taskItem"] > label {
flex-shrink: 0;
margin-top: 0.2rem;
}
.rich-text-content li[data-type="taskItem"] > label input[type="checkbox"] {
accent-color: var(--accent);
cursor: pointer;
height: 1rem;
width: 1rem;
}
.rich-text-content li[data-type="taskItem"] > div {
flex: 1;
min-width: 0;
}
.rich-text-editor .ProseMirror {
min-height: 18rem;
max-width: 100%;
outline: none;
overflow-wrap: anywhere;
word-break: break-word;
}
.rich-text-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--ink-faint);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.rich-text-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
+64
View File
@@ -0,0 +1,64 @@
import DOMPurify from "isomorphic-dompurify";
const ALLOWED_TAGS = [
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"s",
"strike",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"span",
"div",
"label",
"input",
"hr",
];
const ALLOWED_ATTR = [
"href",
"target",
"rel",
"src",
"alt",
"title",
"class",
"data-type",
"data-checked",
"type",
"checked",
"disabled",
"colspan",
"rowspan",
];
export function sanitizeRichTextHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: true,
ADD_ATTR: ["target"],
});
}
@@ -0,0 +1,28 @@
const TASK_ITEM_RE = /<li([^>]*data-type="taskItem"[^>]*)>/gi;
export function toggleTaskItemInHtml(html: string, taskIndex: number, checked: boolean): string {
let index = 0;
return html.replace(TASK_ITEM_RE, (match, attrs: string) => {
const current = index;
index += 1;
if (current !== taskIndex) return match;
const checkedValue = checked ? "true" : "false";
let nextAttrs = attrs;
if (/data-checked="(?:true|false)"/i.test(nextAttrs)) {
nextAttrs = nextAttrs.replace(
/data-checked="(?:true|false)"/i,
`data-checked="${checkedValue}"`,
);
} else {
nextAttrs = `${nextAttrs} data-checked="${checkedValue}"`;
}
return `<li${nextAttrs}>`;
});
}
export function countTaskItems(html: string): number {
return [...html.matchAll(TASK_ITEM_RE)].length;
}
+5 -3
View File
@@ -103,10 +103,12 @@ export function WidgetPicker({
key={meta.id}
type="button"
onClick={() => selectWidget(meta.id)}
className="w-full text-left rounded-md px-3 py-2 hover:bg-accent transition-colors"
className="group w-full text-left rounded-md px-3 py-2 transition-colors hover:bg-[var(--shade)] focus-visible:bg-[var(--shade)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--hair-2)]"
>
<p className="text-sm font-medium">{meta.title}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
<p className="text-sm font-medium text-[var(--ink)]">{meta.title}</p>
<p className="text-xs text-[var(--ink-mute)] group-hover:text-[var(--ink-2)] group-focus-visible:text-[var(--ink-2)]">
{meta.description}
</p>
</button>
))}
</div>
+73
View File
@@ -0,0 +1,73 @@
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { resolveBearerToken } from "@/modules/_core/api-token";
import { householdMembers } from "@/modules/_core/schema";
export type ApiAuthContext = {
householdId: string;
userId: string | null;
role: "owner" | "member" | null;
};
function parseBearerToken(request: Request): string | null {
const header = request.headers.get("Authorization");
if (!header?.startsWith("Bearer ")) return null;
const token = header.slice("Bearer ".length).trim();
return token.length > 0 ? token : null;
}
async function resolveSessionAuth(): Promise<ApiAuthContext | null> {
let session;
try {
session = await auth();
} catch {
return null;
}
if (!session?.user?.id) return null;
const [row] = await db
.select({
householdId: householdMembers.householdId,
role: householdMembers.role,
})
.from(householdMembers)
.where(eq(householdMembers.userId, session.user.id))
.limit(1);
if (!row) return null;
return {
householdId: row.householdId,
userId: session.user.id,
role: row.role,
};
}
export async function resolveApiAuth(request: Request): Promise<ApiAuthContext | null> {
const sessionAuth = await resolveSessionAuth();
if (sessionAuth) return sessionAuth;
const rawToken = parseBearerToken(request);
if (!rawToken) return null;
const bearer = await resolveBearerToken(rawToken);
if (!bearer) return null;
return {
householdId: bearer.householdId,
userId: null,
role: null,
};
}
export async function requireApiAuth(request: Request): Promise<ApiAuthContext> {
const ctx = await resolveApiAuth(request);
if (!ctx) {
throw new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return ctx;
}
+49
View File
@@ -0,0 +1,49 @@
import { ZodError } from "zod";
import { requireApiAuth, type ApiAuthContext } from "@/lib/api-auth";
export function apiJson(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
export function apiError(message: string, status: number): Response {
return apiJson({ error: message }, status);
}
const NOT_FOUND_PATTERN = /not found/i;
function mapApiError(err: unknown): Response {
if (err instanceof ZodError) {
const message = err.issues[0]?.message ?? "Validation error";
return apiError(message, 400);
}
if (err instanceof Error) {
if (NOT_FOUND_PATTERN.test(err.message)) {
return apiError(err.message, 404);
}
if (err.message === "Forbidden") {
return apiError(err.message, 403);
}
return apiError(err.message, 500);
}
return apiError("Internal server error", 500);
}
export { mapApiError };
export async function withApiHandler(
request: Request,
handler: (scope: ApiAuthContext, request: Request) => Promise<Response>,
): Promise<Response> {
try {
const scope = await requireApiAuth(request);
return await handler(scope, request);
} catch (err) {
if (err instanceof Response) return err;
return mapApiError(err);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
export async function getAssistantEnabled(userId: string): Promise<boolean> {
const [row] = await db
.select({ assistantEnabled: users.assistantEnabled })
.from(users)
.where(eq(users.id, userId))
.limit(1);
return row?.assistantEnabled ?? false;
}
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { cookies } from "next/headers";
import { parseDashboardLayout, type DashboardLayout } from "./dashboard";
function draftCookieName(dashboardId: string) {
return `dashboard-editor-draft-${dashboardId}`;
}
export async function readEditorDraftLayout(dashboardId: string): Promise<DashboardLayout | null> {
const jar = await cookies();
const raw = jar.get(draftCookieName(dashboardId))?.value;
if (!raw) return null;
try {
return parseDashboardLayout(JSON.parse(raw));
} catch {
return null;
}
}
export async function writeEditorDraftLayout(dashboardId: string, layout: DashboardLayout) {
const jar = await cookies();
jar.set(draftCookieName(dashboardId), JSON.stringify(layout), {
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60,
path: "/",
});
}
export async function clearEditorDraftLayout(dashboardId: string) {
const jar = await cookies();
jar.delete(draftCookieName(dashboardId));
}
+30 -1
View File
@@ -1,8 +1,9 @@
import "server-only";
import { getRegistry } from "@/modules/_core";
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import type { DashboardLayout, PresetId } from "./dashboard";
import type { DashboardLayout, PresetId, WidgetPlacement } from "./dashboard";
import { computePresetLayoutFromMetas } from "./dashboard";
import { getWidget } from "@/modules/_core";
/** Build a layout matching one of the design's three dashboard arrangements
* using the live module registry. Server-only the registry is empty on
@@ -29,3 +30,31 @@ function computePresetLayout(preset: PresetId): DashboardLayout {
export function computeDefaultLayout(): DashboardLayout {
return computePresetLayout("classic");
}
export function normalizeDashboardLayout(layout: DashboardLayout): DashboardLayout {
return {
version: layout.version,
widgets: layout.widgets.map((placement) => normalizeWidgetPlacement(placement)),
};
}
function normalizeWidgetPlacement(placement: WidgetPlacement): WidgetPlacement {
const widget = getWidget(placement.widgetId);
if (!widget) return placement;
const merged =
placement.config != null &&
typeof placement.config === "object" &&
!Array.isArray(placement.config)
? {
...(widget.defaultConfig as Record<string, unknown>),
...(placement.config as Record<string, unknown>),
}
: widget.defaultConfig;
const parsed = widget.configSchema.safeParse(merged);
return {
...placement,
config: parsed.success ? parsed.data : widget.defaultConfig,
};
}
+4
View File
@@ -10,6 +10,10 @@ export type WidgetPlacement = {
h: number;
};
export function widgetContentKey(placement: Pick<WidgetPlacement, "widgetId" | "config">): string {
return `${placement.widgetId}::${JSON.stringify(placement.config)}`;
}
export type DashboardLayout = {
version: 1;
widgets: WidgetPlacement[];
+23 -1
View File
@@ -5,8 +5,29 @@ import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
import * as notesSchema from "@/modules/notes/schema";
import * as gardenSchema from "@/modules/garden/schema";
import * as journalSchema from "@/modules/journal/schema";
const client = postgres(process.env["DATABASE_URL"]!);
type PostgresClient = ReturnType<typeof postgres>;
const globalForDb = globalThis as typeof globalThis & {
__famappPostgres?: PostgresClient;
};
function createClient(): PostgresClient {
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL is not set");
return postgres(url, {
max: process.env.NODE_ENV === "production" ? 10 : 1,
idle_timeout: 20,
connect_timeout: 10,
});
}
const client = globalForDb.__famappPostgres ?? createClient();
if (process.env.NODE_ENV !== "production") {
globalForDb.__famappPostgres = client;
}
const schema = {
...coreSchema,
@@ -14,6 +35,7 @@ const schema = {
...listsSchema,
...notesSchema,
...gardenSchema,
...journalSchema,
};
export const db = drizzle(client, { schema });
+30
View File
@@ -0,0 +1,30 @@
export type LlmProviderKind = "mock" | "openai";
export type LlmConfig = {
provider: LlmProviderKind;
baseUrl: string | null;
apiKey: string | null;
model: string;
};
export function getLlmConfig(): LlmConfig {
const providerEnv = process.env.LLM_PROVIDER?.trim().toLowerCase();
const baseUrl = process.env.LLM_BASE_URL?.trim() || null;
const apiKey = process.env.LLM_API_KEY?.trim() || null;
const model = process.env.LLM_MODEL?.trim() || "llama3.2";
if (providerEnv === "mock") {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
if (!baseUrl) {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
return { provider: "openai", baseUrl, apiKey, model };
}
export function isLlmConfigured(): boolean {
const config = getLlmConfig();
return config.provider === "openai" && Boolean(config.baseUrl);
}
+29
View File
@@ -0,0 +1,29 @@
import { createMockLlmClient } from "./mock";
import { createOpenAiCompatibleClient } from "./openai-compatible";
import { getLlmConfig } from "./config";
import type { LlmClient } from "./types";
export type {
AgentToolDefinition,
ChatCompletionRequest,
ChatCompletionResult,
ChatMessage,
LlmClient,
} from "./types";
export { getLlmConfig, isLlmConfigured } from "./config";
export { createMockLlmClient } from "./mock";
export function createLlmClient(override?: LlmClient): LlmClient {
if (override) return override;
const config = getLlmConfig();
if (config.provider === "mock" || !config.baseUrl) {
return createMockLlmClient();
}
return createOpenAiCompatibleClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
model: config.model,
});
}
+94
View File
@@ -0,0 +1,94 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
function lastUserText(messages: ChatCompletionRequest["messages"]): string {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role === "user" && message.content) {
return message.content.toLowerCase();
}
}
return "";
}
function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
return messages.some((message) => message.role === "tool");
}
export function createMockLlmClient(): LlmClient {
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const userText = lastUserText(request.messages);
if (hadToolResults(request.messages)) {
return {
message: {
role: "assistant",
content: "Done — I updated your household data using the API.",
},
finishReason: "stop",
};
}
if (userText.includes("milk") && request.tools?.length) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_add_item",
type: "function",
function: {
name: "add_list_item",
arguments: JSON.stringify({
listType: "shopping",
text: "milk",
}),
},
},
],
},
finishReason: "tool_calls",
};
}
if (userText.includes("calendar") && request.tools?.length) {
const now = new Date();
const from = new Date(now);
from.setDate(from.getDate() - 1);
const to = new Date(now);
to.setDate(to.getDate() + 7);
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_list_events",
type: "function",
function: {
name: "list_events",
arguments: JSON.stringify({
from: from.toISOString(),
to: to.toISOString(),
}),
},
},
],
},
finishReason: "tool_calls",
};
}
return {
message: {
role: "assistant",
content:
"I'm the famapp assistant (mock provider). Ask me to add items or check the calendar.",
},
finishReason: "stop",
};
},
};
}
+74
View File
@@ -0,0 +1,74 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
type OpenAiMessage = {
role: string;
content: string | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
name?: string;
};
export function createOpenAiCompatibleClient(options: {
baseUrl: string;
apiKey: string | null;
model: string;
}): LlmClient {
const completionsUrl = `${options.baseUrl.replace(/\/$/, "")}/chat/completions`;
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.apiKey) {
headers.Authorization = `Bearer ${options.apiKey}`;
}
const body = {
model: options.model,
messages: request.messages as OpenAiMessage[],
tools: request.tools,
tool_choice: request.tools?.length ? "auto" : undefined,
};
const response = await fetch(completionsUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`LLM request failed (${response.status}): ${detail.slice(0, 400)}`);
}
const payload = (await response.json()) as {
choices?: Array<{
finish_reason?: string | null;
message?: OpenAiMessage;
}>;
};
const choice = payload.choices?.[0];
const message = choice?.message;
if (!message) {
throw new Error("LLM response missing message");
}
return {
message: {
role: message.role as ChatCompletionResult["message"]["role"],
content: message.content,
tool_calls: message.tool_calls,
tool_call_id: message.tool_call_id,
name: message.name,
},
finishReason: choice.finish_reason ?? null,
};
},
};
}
+41
View File
@@ -0,0 +1,41 @@
export type ChatRole = "system" | "user" | "assistant" | "tool";
export type ChatToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type ChatMessage = {
role: ChatRole;
content: string | null;
tool_calls?: ChatToolCall[];
tool_call_id?: string;
name?: string;
};
export type AgentToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export type ChatCompletionRequest = {
messages: ChatMessage[];
tools?: AgentToolDefinition[];
};
export type ChatCompletionResult = {
message: ChatMessage;
finishReason: string | null;
};
export interface LlmClient {
chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult>;
}

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