Compare commits
24
Commits
02b6ec6adb
...
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d13d6a430 | ||
|
|
060a2f19a9 | ||
|
|
6183fb62c8 | ||
|
|
9e66c12eb7 | ||
|
|
e1c2a090fb | ||
|
|
7714b1187c | ||
|
|
a09747c314 | ||
|
|
4a924a4107 | ||
|
|
04ae809e07 | ||
|
|
8e2ddd6b72 | ||
|
|
a4be5d5061 | ||
|
|
67f67525ff | ||
|
|
fb67692a08 | ||
|
|
e8d13bede8 | ||
|
|
d4304b005c | ||
|
|
ea5d1d050c | ||
|
|
2c0c31540e | ||
|
|
5e711cfe0d | ||
|
|
d090200ec8 | ||
|
|
1a1c080d18 | ||
|
|
753f653b89 | ||
|
|
1a03763e1b | ||
|
|
700ee29f83 | ||
|
|
7eeb2f15bc |
@@ -31,6 +31,9 @@ NTFY_TOPIC=
|
|||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
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 (required for pnpm release — creates a GitHub Release)
|
||||||
GITHUB_TOKEN=
|
GITHUB_TOKEN=
|
||||||
|
|
||||||
@@ -43,3 +46,10 @@ MINIO_BUCKET=garden
|
|||||||
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
|
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
|
||||||
OPENPLANTBOOK_CLIENT_ID=
|
OPENPLANTBOOK_CLIENT_ID=
|
||||||
OPENPLANTBOOK_CLIENT_SECRET=
|
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
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ src/
|
|||||||
### Core primitives every module gets
|
### Core primitives every module gets
|
||||||
|
|
||||||
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
||||||
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed.
|
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
|
||||||
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
||||||
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
||||||
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
||||||
|
|||||||
@@ -1,5 +1,72 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.5.5](https://github.com/ginnoir/famapp/compare/v0.5.4...v0.5.5) (2026-07-05)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **agent:** add llm assistant chat with api tools (task 88) ([4a924a4](https://github.com/ginnoir/famapp/commit/4a924a41072a43484c009757a1644cd42309fc0e))
|
||||||
|
- api v1 garden bangs routes and openapi ([e8d13be](https://github.com/ginnoir/famapp/commit/e8d13bede81b5a778118420292e7f35e4e54788e))
|
||||||
|
- api v1 routes for calendar lists and notes ([d4304b0](https://github.com/ginnoir/famapp/commit/d4304b005cf86d6d3c14497afbb6e0973a2a8d54))
|
||||||
|
- bang edit and delete ([753f653](https://github.com/ginnoir/famapp/commit/753f653b89f244b263023dbe744e30c5eaf2893f))
|
||||||
|
- household api token auth foundation ([ea5d1d0](https://github.com/ginnoir/famapp/commit/ea5d1d050ca5e0aa8a532330448e2f2cdbda0c39))
|
||||||
|
- journal dashboard widgets, agent polish, and edit-mode live previews ([a09747c](https://github.com/ginnoir/famapp/commit/a09747c3142e089199bfd3839631c9d8573175f0))
|
||||||
|
- **journal:** add per-user mood journal module (task 86) ([04ae809](https://github.com/ginnoir/famapp/commit/04ae809e0710c3930b0056fbb1f869fc70223131))
|
||||||
|
- **notes:** rich-text editor with tiptap (task 85) ([a4be5d5](https://github.com/ginnoir/famapp/commit/a4be5d5061d41cb9b4ba37fc89adfb68c00466f7))
|
||||||
|
- p2 batch 28-31 reminders lists comments bang stats ([e1c2a09](https://github.com/ginnoir/famapp/commit/e1c2a090fb379dcd5d453a658a74a5085b473281)), closes [#28](https://github.com/ginnoir/famapp/issues/28) [#29](https://github.com/ginnoir/famapp/issues/29) [#30](https://github.com/ginnoir/famapp/issues/30)
|
||||||
|
- shared back navigation on detail pages ([d090200](https://github.com/ginnoir/famapp/commit/d090200ec8950d43fe1e1f71bf1248cf71759199))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- dashboard edit mode renders live widgets ([7eeb2f1](https://github.com/ginnoir/famapp/commit/7eeb2f15bcab4cfbb33ba1138449b74d01ff5953))
|
||||||
|
- dashboard edit previews, migration journal, and notes comments ([6183fb6](https://github.com/ginnoir/famapp/commit/6183fb62c88a51ff967da428edfbf1b8b6d70d84))
|
||||||
|
- garden container plant count ([700ee29](https://github.com/ginnoir/famapp/commit/700ee29f83dd8e6b3580168d5944672c3441a8da))
|
||||||
|
- quick add opens create ui ([68a573c](https://github.com/ginnoir/famapp/commit/68a573c4d656f4a2c25d5b15d4f7ff1849a76bdf))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- accept adr 0004 rich-text editor decisions ([67f6752](https://github.com/ginnoir/famapp/commit/67f67525ff7732868839e0e615078522b57e9ddf))
|
||||||
|
- accept adr 0005 journal module decisions ([8e2ddd6](https://github.com/ginnoir/famapp/commit/8e2ddd6b7216381e480a657980e5619d488d01c9))
|
||||||
|
- accept adr 0006 api and agent architecture ([2c0c315](https://github.com/ginnoir/famapp/commit/2c0c31540ed276582984e6dd45f37d7672e60b63))
|
||||||
|
- add backlog triage implementation plan ([310ba48](https://github.com/ginnoir/famapp/commit/310ba48e335409e108e8b4387acee234db784adb))
|
||||||
|
- add commit hash for p2 batch 28-31 in status ([9e66c12](https://github.com/ginnoir/famapp/commit/9e66c12eb7afc4dcb6e0203f0e32a5ce8d0200f1))
|
||||||
|
- add post-v0.1 backlog triage design ([72e868d](https://github.com/ginnoir/famapp/commit/72e868dda1f96dd132d3e1b6bfaa54ed1c5883ef))
|
||||||
|
- complete Phase 9 backlog triage (issues map, briefs, ADRs) ([76f6854](https://github.com/ginnoir/famapp/commit/76f68548b2609aa8e672dba563acf90a30ba09ed)), closes [1-#37](https://github.com/ginnoir/1-/issues/37)
|
||||||
|
- mark task 82 done in status ([1a03763](https://github.com/ginnoir/famapp/commit/1a03763e1bf992766dfd7891e3eb815534e92038))
|
||||||
|
- mark task 83 done in status ([1a1c080](https://github.com/ginnoir/famapp/commit/1a1c080d18e2c22838949dfed8e77a54fab1dcb5))
|
||||||
|
- mark task 84 done, bugs batch complete ([5e711cf](https://github.com/ginnoir/famapp/commit/5e711cfe0d7bd779114af19fe111375abe269953))
|
||||||
|
- mark task 87 done in status ([fb67692](https://github.com/ginnoir/famapp/commit/fb67692a08973f9e6e14f7c2ec336cad5bdaf777))
|
||||||
|
- record journal polish commit in status ([7714b11](https://github.com/ginnoir/famapp/commit/7714b1187c76d1d324573bc8819568751f991b09))
|
||||||
|
|
||||||
|
* fix: dashboard edit previews, migration journal, and notes comments (6183fb6)
|
||||||
|
* docs: add commit hash for p2 batch 28-31 in status (9e66c12)
|
||||||
|
* feat: p2 batch 28-31 reminders lists comments bang stats (e1c2a09)
|
||||||
|
* docs: record journal polish commit in status (7714b11)
|
||||||
|
* feat: journal dashboard widgets, agent polish, and edit-mode live previews (a09747c)
|
||||||
|
* feat(agent): add llm assistant chat with api tools (task 88) (4a924a4)
|
||||||
|
* feat(journal): add per-user mood journal module (task 86) (04ae809)
|
||||||
|
* docs: accept adr 0005 journal module decisions (8e2ddd6)
|
||||||
|
* feat(notes): rich-text editor with tiptap (task 85) (a4be5d5)
|
||||||
|
* docs: accept adr 0004 rich-text editor decisions (67f6752)
|
||||||
|
* docs: mark task 87 done in status (fb67692)
|
||||||
|
* feat: api v1 garden bangs routes and openapi (e8d13be)
|
||||||
|
* feat: api v1 routes for calendar lists and notes (d4304b0)
|
||||||
|
* feat: household api token auth foundation (ea5d1d0)
|
||||||
|
* docs: accept adr 0006 api and agent architecture (2c0c315)
|
||||||
|
* docs: mark task 84 done, bugs batch complete (5e711cf)
|
||||||
|
* feat: shared back navigation on detail pages (d090200)
|
||||||
|
* docs: mark task 83 done in status (1a1c080)
|
||||||
|
* feat: bang edit and delete (753f653)
|
||||||
|
* docs: mark task 82 done in status (1a03763)
|
||||||
|
* fix: garden container plant count (700ee29)
|
||||||
|
* fix: dashboard edit mode renders live widgets (7eeb2f1)
|
||||||
|
* test: cover all quick-add create dialogs in e2e (02b6ec6)
|
||||||
|
* fix: quick add opens create ui (68a573c)
|
||||||
|
* docs: complete Phase 9 backlog triage (issues map, briefs, ADRs) (76f6854)
|
||||||
|
* chore: ignore .worktrees for agent isolation (5dfc8b8)
|
||||||
|
* docs: add backlog triage implementation plan (310ba48)
|
||||||
|
* docs: add post-v0.1 backlog triage design (72e868d)
|
||||||
|
* chore: vendor react-best-practices agent skill for cursor agents (3d825fb)
|
||||||
|
|
||||||
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
|
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ src/
|
|||||||
### Core primitives every module gets
|
### Core primitives every module gets
|
||||||
|
|
||||||
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
|
||||||
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed.
|
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
|
||||||
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
|
||||||
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })` → `fam.ginnoir.com/s/<token>`. Generic.
|
||||||
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
|
||||||
|
|||||||
@@ -4,6 +4,35 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
|||||||
|
|
||||||
## Done
|
## 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** (commit `a09747c`, 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.
|
||||||
|
|
||||||
|
- **89 — Calendar reminders overhaul** (Gitea #28, commit `e1c2a09`). Multiple reminders per event via `syncCalendarEventReminders`; `ReminderPicker` with presets + custom offsets; migration `0022_multiple_reminders.sql` drops one-per-entity unique index, adds `offset_minutes` + `users.default_event_reminder_offsets`. Settings → Notifications default reminders editor. Create/edit/quick-add calendar flows updated; drag-reschedule resyncs existing offsets.
|
||||||
|
|
||||||
|
- **90 — Lists index inline add + edit** (Gitea #29, commit `e1c2a09`). `/lists` cards: inline task add row, pencil edit for name/type via `updateListProperties`.
|
||||||
|
|
||||||
|
- **91 — Comments on lists and tasks** (Gitea #30, commit `e1c2a09`). Generic `comments` table + `_core/comments.ts`; reusable `EntityComments` on list detail (list + per-item compact threads). Migration `0023_comments.sql`.
|
||||||
|
|
||||||
|
- **92 — Bang stats dashboard widget** (Gitea #31, commit `e1c2a09`). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown.
|
||||||
|
|
||||||
|
- **P2 batch follow-ups** (release v0.5.4). Migration journal entries for `0022`/`0023` (prod apply fix); dashboard edit previews keyed by placement index (`bangs.stats` live in edit mode); notes comments; comment revalidation on `/notes`.
|
||||||
|
|
||||||
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
- **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.
|
- **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.
|
- **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 +75,15 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
|
|||||||
|
|
||||||
**Batch order:**
|
**Batch order:**
|
||||||
|
|
||||||
1. Bugs: tasks 80–84 (quick-add, dashboard edit, garden count, bangs, back-nav)
|
1. ~~Bugs: tasks 80–84~~ — done
|
||||||
2. API foundation: task 87 (+ ADR 0006)
|
2. ~~API foundation: task 87 (+ ADR 0006)~~ — done
|
||||||
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow
|
3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done
|
||||||
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
|
4. ~~Journal: task 86 (+ ADR 0005), including journal API endpoints~~ — done
|
||||||
5. LLM agent chat: task 88
|
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 7–9, 11–12, 15–19.
|
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 7–9, 11–12, 15–19.
|
||||||
|
|
||||||
**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:** P2 batch #28–#31 complete (released v0.5.4). Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
|
||||||
|
|
||||||
## Development login/testing notes
|
## Development login/testing notes
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,16 +1,89 @@
|
|||||||
# 0004 — Rich-text editor library and storage format
|
# 0004 — Rich-text editor library and storage format
|
||||||
|
|
||||||
Date: 2026-07-03
|
Date: 2026-07-03
|
||||||
Status: proposed
|
Status: accepted (2026-07-04)
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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
|
## 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`
|
||||||
|
|||||||
@@ -1,16 +1,128 @@
|
|||||||
# 0005 — Journal / mood tracking research
|
# 0005 — Journal / mood tracking
|
||||||
|
|
||||||
Date: 2026-07-03
|
Date: 2026-07-03
|
||||||
Status: proposed
|
Status: accepted (2026-07-04)
|
||||||
|
|
||||||
## Context
|
## 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** (~8–12 presets with emoji + color); **multi-select** |
|
||||||
|
| 3 | Entries | **Multiple per day** allowed |
|
||||||
|
| 4 | Stress | Optional **1–10** 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
|
## 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 — 1–10
|
||||||
|
- `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 5–10 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
|
## 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
|
||||||
|
|||||||
@@ -1,16 +1,77 @@
|
|||||||
# 0006 — API auth and LLM agent architecture
|
# 0006 — API auth and LLM agent architecture
|
||||||
|
|
||||||
Date: 2026-07-03
|
Date: 2026-07-03
|
||||||
Status: proposed
|
Status: accepted (2026-07-04)
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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
|
## 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`
|
||||||
|
|||||||
@@ -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)
|
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
|
||||||
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.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)
|
- [0004 — Rich-text editor library and storage format](0004-rich-text-editor.md) (accepted 2026-07-04)
|
||||||
- [0005 — Journal / mood tracking research](0005-journal-research.md) (proposed)
|
- [0005 — Journal / mood tracking](0005-journal-research.md) (accepted 2026-07-04)
|
||||||
- [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (proposed)
|
- [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (accepted 2026-07-04)
|
||||||
<!-- END AUTO-GENERATED -->
|
<!-- END AUTO-GENERATED -->
|
||||||
|
|||||||
+48
-47
@@ -2,55 +2,56 @@
|
|||||||
|
|
||||||
Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`).
|
Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`).
|
||||||
|
|
||||||
| Design | Gitea | Title | URL |
|
| Design | Gitea | Title | URL |
|
||||||
| --- | --- | --- | --- |
|
| ------ | ----- | ------------------------------------------------------------------- | -------------------------------------------------- |
|
||||||
| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 | 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.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.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.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.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 |
|
| 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 |
|
| 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 |
|
| 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 | 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.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.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.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.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.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.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.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 |
|
| 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 | 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.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.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.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.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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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)
|
## Dependencies (P1)
|
||||||
|
|
||||||
| Issue | Depends on |
|
| Issue | Depends on |
|
||||||
| --- | --- |
|
| --------------------------- | -------------------------------------- |
|
||||||
| #5 (notes overflow) | #7 (rich-text epic) |
|
| #5 (notes overflow) | #7 (rich-text epic) |
|
||||||
| #12 (mobile overflow child) | #8 (rich-text research ADR) |
|
| #12 (mobile overflow child) | #8 (rich-text research ADR) |
|
||||||
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
|
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
|
||||||
| #16 (agent UI) | #15 (API surface) |
|
| #16 (agent UI) | #15 (API surface) |
|
||||||
| #17 (tool-calling) | #15 (API surface) |
|
| #17 (tool-calling) | #15 (API surface) |
|
||||||
| #27 (journal API) | #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.
|
Native Gitea issue dependencies API 404s on this instance; dependencies are also noted as comments on the issues above.
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ Edit mode currently shows empty placeholders; on save, content-heavy widgets (e.
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Edit mode shows real widget content for registered widgets.
|
- [x] Edit mode shows real widget content for registered widgets.
|
||||||
- [ ] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
|
- [x] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
|
||||||
- [ ] E2E covers real content visible in edit mode.
|
- [x] E2E covers real content visible in edit mode.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ Journal (86) and notes need modern formatting and emoji. Notes currently overflo
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] ADR 0004 accepted with library and storage format chosen.
|
- [x] ADR 0004 accepted with library and storage format chosen.
|
||||||
- [ ] Shared editor component in tree.
|
- [x] Shared editor component in tree.
|
||||||
- [ ] Notes create/edit use it.
|
- [x] Notes create/edit use it.
|
||||||
- [ ] Formatting renders on all notes surfaces.
|
- [x] Formatting renders on all notes surfaces.
|
||||||
- [ ] Mobile overflow fixed; E2E green.
|
- [x] Mobile overflow fixed; E2E green.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ P1 personal journaling with mood/stress/pills tracking and stats, without blocki
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] ADR 0005 accepted.
|
- [x] ADR 0005 accepted.
|
||||||
- [ ] Create entry with only date/time.
|
- [x] Create entry with only date/time.
|
||||||
- [ ] Index, detail, mood tracker, and insights views work.
|
- [x] Index, detail, mood tracker, and insights views work.
|
||||||
- [ ] Journal API endpoints documented and callable with token auth.
|
- [x] Journal API endpoints documented and callable with token auth.
|
||||||
- [ ] E2E happy path green.
|
- [x] E2E happy path green (`tests/e2e/journal.spec.ts` — run locally with dev DB).
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ Users should say "add milk to the shopping list" or "what's on the calendar Frid
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Chat UI sends prompts and shows responses.
|
- [x] Chat UI sends prompts and shows responses.
|
||||||
- [ ] Tools call the API successfully in dev against the homelab endpoint.
|
- [x] Tools call the API successfully in dev against the homelab endpoint.
|
||||||
- [ ] CI uses mock provider only.
|
- [x] CI uses mock provider only.
|
||||||
- [ ] ADR 0006 records provider and MCP decisions.
|
- [x] ADR 0006 records provider and MCP decisions.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 89 — Calendar reminders overhaul
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the single "Remind me 30 min before" checkbox with multiple reminders, presets, and custom offsets.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Store multiple reminder rows per calendar event in the generic `reminders` table (drop one-per-entity unique constraint).
|
||||||
|
- Offsets are minutes before event start; presets + custom value/unit in UI.
|
||||||
|
- Per-user default offsets live on `users.default_event_reminder_offsets` (jsonb, default `[30]`).
|
||||||
|
- Notes/garden keep single-reminder behavior via delete-then-insert in `scheduleReminder`.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 41 (reminders engine)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Migration: drop `reminders_entity_unique`, add `offset_minutes`, add user default column.
|
||||||
|
- `_core/reminders.ts`: `syncRemindersForEntity`, refactor `scheduleReminder`.
|
||||||
|
- Shared `ReminderPicker` component + offset helpers.
|
||||||
|
- Calendar create/edit + quick-add dialogs; sync reminders on create/update/delete.
|
||||||
|
- Optional: settings field for default reminder offsets.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Recurring reminders, snooze.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Users can add/remove multiple reminders on an event
|
||||||
|
- [x] Preset list and custom offset both work
|
||||||
|
- [x] Reminders fire via existing notification bus
|
||||||
|
- [x] Per-user default reminder preferences (stored + applied on new events)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#28](https://gitea.ginnoir.com/ginnoir/famapp/issues/28)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 90 — Lists index: inline task add + list property edit
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
From `/lists`, add tasks and edit list properties without opening the full list page.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Inline add input at the bottom of each list card (Enter to submit, same as detail page).
|
||||||
|
- Inline edit for list name and type via a small edit control on the card header.
|
||||||
|
- Reuse existing `addItem`, `renameList`, and extended `updateList` server actions.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 11 (lists module)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Extend `listUpdateInput` to allow `type` changes.
|
||||||
|
- Update `lists-index.tsx` with add-item row and list property editor.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Add an item to a list from the index
|
||||||
|
- [x] Edit list properties from the index
|
||||||
|
- [x] Changes persist and match detail-page behavior
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#29](https://gitea.ginnoir.com/ginnoir/famapp/issues/29)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 91 — Comments on lists and tasks
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Household members can comment on lists and individual list items using a reusable comment component.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Generic `comments` table in `_core` keyed by `(entity_type, entity_id)`.
|
||||||
|
- Reusable `EntityComments` client component in `src/components/comments/`.
|
||||||
|
- Wired on list detail: list-level thread + per-item expandable comments.
|
||||||
|
- Entity-generic design so notes/other modules can adopt later without schema changes.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 11 (lists module)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Migration: `comments` table.
|
||||||
|
- `_core/comments.ts`: list, add, delete (author-only).
|
||||||
|
- `EntityComments` UI.
|
||||||
|
- Lists detail page integration for `lists.list` and `lists.item`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Comments on notes/calendar (future adoption of same component).
|
||||||
|
- Rich-text comments (plain text only).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Comment on a list
|
||||||
|
- [x] Comment on a task/item
|
||||||
|
- [x] Household members see each other's comments
|
||||||
|
- [x] Component is entity-generic (documented in brief)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#30](https://gitea.ginnoir.com/ginnoir/famapp/issues/30)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 92 — Bang stats dashboard widget
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A second bangs dashboard widget showing monthly/yearly counts and average days between bangs.
|
||||||
|
|
||||||
|
## What we decided
|
||||||
|
|
||||||
|
- Widget id `bangs.stats`, separate from `bangs.counter`.
|
||||||
|
- Aggregates computed server-side from `bang_events.occurred_on`.
|
||||||
|
- Works alongside edit/delete from task 83.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- Bangs module, 83 (edit/delete)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `getBangAggregates` query.
|
||||||
|
- `BangStatsWidget` component.
|
||||||
|
- Manifest registration.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Widget registered and placeable on dashboards
|
||||||
|
- [x] Shows monthly and yearly counts
|
||||||
|
- [x] Shows average days between bangs
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Gitea: [ginnoir/famapp#31](https://gitea.ginnoir.com/ginnoir/famapp/issues/31)
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "assistant_enabled" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP INDEX IF EXISTS "reminders_entity_unique";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reminders" ADD COLUMN "offset_minutes" integer;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "reminders_entity_idx" ON "reminders" USING btree ("entity_type","entity_id");
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "default_event_reminder_offsets" jsonb DEFAULT '[30]'::jsonb NOT NULL;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"author_id" uuid NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_entity_idx" ON "comments" USING btree ("entity_type","entity_id","created_at");
|
||||||
@@ -134,6 +134,41 @@
|
|||||||
"when": 1748995200000,
|
"when": 1748995200000,
|
||||||
"tag": "0018_container_images",
|
"tag": "0018_container_images",
|
||||||
"breakpoints": true
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780392000000,
|
||||||
|
"tag": "0022_multiple_reminders",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780393000000,
|
||||||
|
"tag": "0023_comments",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+16
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "famapp",
|
"name": "famapp",
|
||||||
"version": "0.5.3",
|
"version": "0.5.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.3",
|
"packageManager": "pnpm@10.33.3",
|
||||||
@@ -83,11 +83,25 @@
|
|||||||
"@fullcalendar/list": "^6.1.20",
|
"@fullcalendar/list": "^6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.20",
|
"@fullcalendar/react": "^6.1.20",
|
||||||
"@fullcalendar/timegrid": "^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",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"isomorphic-dompurify": "^3.18.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
@@ -99,6 +113,7 @@
|
|||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-grid-layout": "^2.2.3",
|
"react-grid-layout": "^2.2.3",
|
||||||
"react-resizable": "^3.1.3",
|
"react-resizable": "^3.1.3",
|
||||||
|
"recharts": "^3.9.2",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
Generated
+1171
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
|
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/")) {
|
if (!file.type.startsWith("image/")) {
|
||||||
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
|
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, "")
|
.replace(/[^a-z0-9]/gi, "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
|
const key = `${scope}/${session.household.id}/${randomUUID()}.${safeExt}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureBucket();
|
await ensureBucket();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
||||||
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
import {
|
||||||
|
getDefaultEventReminderOffsets,
|
||||||
|
listCalendars,
|
||||||
|
listEvents,
|
||||||
|
} from "@/modules/calendar/server/queries";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import type { CalView } from "@/modules/_core/themes";
|
import type { CalView } from "@/modules/_core/themes";
|
||||||
|
|
||||||
@@ -10,10 +14,11 @@ export default async function CalendarPage() {
|
|||||||
const to = new Date(now);
|
const to = new Date(now);
|
||||||
to.setMonth(to.getMonth() + 10);
|
to.setMonth(to.getMonth() + 10);
|
||||||
|
|
||||||
const [{ user }, calendars, events] = await Promise.all([
|
const [{ user }, calendars, events, defaultReminderOffsets] = await Promise.all([
|
||||||
getCurrentSession(),
|
getCurrentSession(),
|
||||||
listCalendars(),
|
listCalendars(),
|
||||||
listEvents({ from, to, calendarIds: "all" }),
|
listEvents({ from, to, calendarIds: "all" }),
|
||||||
|
getDefaultEventReminderOffsets(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +26,7 @@ export default async function CalendarPage() {
|
|||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
events={events}
|
events={events}
|
||||||
defaultView={user.themeCalView as CalView}
|
defaultView={user.themeCalView as CalView}
|
||||||
|
defaultReminderOffsets={defaultReminderOffsets}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { parseDashboardLayout } from "@/lib/dashboard";
|
import { parseDashboardLayout, widgetContentIndexKey } from "@/lib/dashboard";
|
||||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
|
||||||
|
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
|
||||||
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||||
import { getDashboardBySlug } from "@/app/d/actions";
|
import { getDashboardBySlug } from "@/app/d/actions";
|
||||||
import { DashboardEditor } from "@/components/dashboard-editor";
|
import { DashboardEditor } from "@/components/dashboard-editor";
|
||||||
|
import { DashboardWidgetContent } from "@/components/dashboard-widget-content";
|
||||||
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
||||||
import { DashboardSwitcher } from "@/components/dashboard-switcher";
|
import { DashboardSwitcher } from "@/components/dashboard-switcher";
|
||||||
import { DashboardTab } from "@/components/dashboard-tab";
|
import { DashboardTab } from "@/components/dashboard-tab";
|
||||||
@@ -48,15 +50,31 @@ export default async function DashboardPage({
|
|||||||
const dashboard = await getDashboardBySlug(slug);
|
const dashboard = await getDashboardBySlug(slug);
|
||||||
if (!dashboard) notFound();
|
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();
|
const widgetMetas = getWidgetMetas();
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
|
const ctx = { userId: user.id, householdId: household.id };
|
||||||
|
const widgetContents = Object.fromEntries(
|
||||||
|
layout.widgets.map((placement, index) => [
|
||||||
|
widgetContentIndexKey(index),
|
||||||
|
<DashboardWidgetContent
|
||||||
|
key={widgetContentIndexKey(index)}
|
||||||
|
placement={placement}
|
||||||
|
ctx={ctx}
|
||||||
|
/>,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardEditor
|
<DashboardEditor
|
||||||
|
key={`${dashboard.id}:${JSON.stringify(layout.widgets)}`}
|
||||||
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
|
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
widgetMetas={widgetMetas}
|
widgetMetas={widgetMetas}
|
||||||
|
widgetContents={widgetContents}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getWidget } from "@/modules/_core";
|
|||||||
import { dashboards } from "@/modules/_core/schema";
|
import { dashboards } from "@/modules/_core/schema";
|
||||||
import { type DashboardLayout } from "@/lib/dashboard";
|
import { type DashboardLayout } from "@/lib/dashboard";
|
||||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||||
|
import { clearEditorDraftLayout, writeEditorDraftLayout } from "@/lib/dashboard-editor-draft";
|
||||||
|
|
||||||
export type DashboardMeta = {
|
export type DashboardMeta = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -156,9 +157,39 @@ export async function saveDashboardLayout(id: string, layout: DashboardLayout):
|
|||||||
.update(dashboards)
|
.update(dashboards)
|
||||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||||
|
await clearEditorDraftLayout(id);
|
||||||
revalidatePath("/");
|
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> {
|
export async function resetDashboardLayout(id: string): Promise<void> {
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
const layout = computeDefaultLayout();
|
const layout = computeDefaultLayout();
|
||||||
@@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise<void> {
|
|||||||
.update(dashboards)
|
.update(dashboards)
|
||||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||||
|
await clearEditorDraftLayout(id);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getContainer } from "@/modules/garden/server/queries";
|
import { DetailBackLink } from "@/components/detail-back-link";
|
||||||
import { ContainerDetail } from "@/modules/garden/components/container-detail";
|
|
||||||
import { getShareLinksForEntity } from "@/modules/_core/share";
|
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 }> }) {
|
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -12,6 +13,7 @@ export default async function ContainerPage({ params }: { params: Promise<{ id:
|
|||||||
if (!container) notFound();
|
if (!container) notFound();
|
||||||
return (
|
return (
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
|
<DetailBackLink href="/garden" label="Garden" className="mb-4" />
|
||||||
<ContainerDetail container={container} shareLinks={shareLinks} />
|
<ContainerDetail container={container} shareLinks={shareLinks} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { DetailBackLink } from "@/components/detail-back-link";
|
||||||
import { createContainer } from "@/modules/garden/server/actions";
|
import { createContainer } from "@/modules/garden/server/actions";
|
||||||
|
|
||||||
export default function NewContainerPage() {
|
export default function NewContainerPage() {
|
||||||
@@ -14,21 +15,8 @@ export default function NewContainerPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-content max-w-lg">
|
<div className="page-content max-w-lg">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="mb-1 flex flex-col gap-2">
|
||||||
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
|
<DetailBackLink href="/garden" label="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>
|
|
||||||
<h1 className="page-title">New container</h1>
|
<h1 className="page-title">New container</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action={handleCreate} className="flex flex-col gap-4 mt-4">
|
<form action={handleCreate} className="flex flex-col gap-4 mt-4">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
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 { 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 }> }) {
|
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -9,6 +10,7 @@ export default async function EditPlantPage({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-content max-w-xl">
|
<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>
|
<h1 className="page-title">Edit Plant</h1>
|
||||||
<PlantForm existingPlant={plant} containers={containers} />
|
<PlantForm existingPlant={plant} containers={containers} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { notFound } from "next/navigation";
|
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 { listCalendars } from "@/modules/garden/server/calendar-bridge";
|
||||||
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
|
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 }> }) {
|
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -17,6 +18,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
|
<DetailBackLink href="/garden?tab=plants" label="Plants" className="mb-4" />
|
||||||
<PlantDetail
|
<PlantDetail
|
||||||
plant={plant}
|
plant={plant}
|
||||||
careLogs={careLogs}
|
careLogs={careLogs}
|
||||||
|
|||||||
@@ -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 { PlantForm } from "@/modules/garden/components/plant-form";
|
||||||
|
import { listContainers } from "@/modules/garden/server/queries";
|
||||||
|
|
||||||
export default async function NewPlantPage({
|
export default async function NewPlantPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -11,21 +12,8 @@ export default async function NewPlantPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-content max-w-xl">
|
<div className="page-content max-w-xl">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="mb-1 flex flex-col gap-2">
|
||||||
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
|
<DetailBackLink href="/garden?tab=plants" label="Plants" />
|
||||||
<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>
|
|
||||||
<h1 className="page-title">Add Plant</h1>
|
<h1 className="page-title">Add Plant</h1>
|
||||||
</div>
|
</div>
|
||||||
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
|
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
|
||||||
|
|||||||
@@ -818,6 +818,86 @@
|
|||||||
bottom: 18px;
|
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
|
/* ── Buttons (used inside .topbar etc; <Button> in shadcn comes from
|
||||||
button.tsx and uses these tokens via the theme bridge) ───── */
|
button.tsx and uses these tokens via the theme bridge) ───── */
|
||||||
.btn {
|
.btn {
|
||||||
@@ -1430,6 +1510,65 @@ select {
|
|||||||
animation: emojiFloat 1.8s ease-out both;
|
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 ───────────────────────────── */
|
/* ── Calendar min-height CSS variable ───────────────────────────── */
|
||||||
/* Used by calendar-shell so the grid fills the viewport correctly */
|
/* Used by calendar-shell so the grid fills the viewport correctly */
|
||||||
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
|
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
|
||||||
|
|
||||||
|
export default function NewJournalEntryPage() {
|
||||||
|
return <JournalEntryEditor />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import { PwaRegister } from "@/components/pwa-register";
|
|||||||
import { InstallPrompt } from "@/components/install-prompt";
|
import { InstallPrompt } from "@/components/install-prompt";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { AppToaster } from "@/components/app-toaster";
|
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 { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
||||||
import type { Palette, ThemeMode, FontPair, Density, NavStyle } 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 navStyle: NavStyle = DEFAULT_THEME.navStyle;
|
||||||
let userDashboards: DashboardMeta[] = [];
|
let userDashboards: DashboardMeta[] = [];
|
||||||
let signedIn = false;
|
let signedIn = false;
|
||||||
|
let assistantEnabled = false;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
@@ -110,6 +113,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
themeFontPair: users.themeFontPair,
|
themeFontPair: users.themeFontPair,
|
||||||
themeDensity: users.themeDensity,
|
themeDensity: users.themeDensity,
|
||||||
themeNavStyle: users.themeNavStyle,
|
themeNavStyle: users.themeNavStyle,
|
||||||
|
assistantEnabled: users.assistantEnabled,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, session.user.id))
|
.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;
|
fontPair = row.themeFontPair as FontPair;
|
||||||
density = row.themeDensity as Density;
|
density = row.themeDensity as Density;
|
||||||
navStyle = row.themeNavStyle as NavStyle;
|
navStyle = row.themeNavStyle as NavStyle;
|
||||||
|
assistantEnabled = row.assistantEnabled;
|
||||||
}
|
}
|
||||||
userDashboards = await db
|
userDashboards = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -172,6 +177,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
|
{signedIn && assistantEnabled && session?.user?.id ? (
|
||||||
|
<AssistantBubble configured={isLlmConfigured()} userId={session.user.id} />
|
||||||
|
) : null}
|
||||||
<AppToaster position="bottom-right" />
|
<AppToaster position="bottom-right" />
|
||||||
</QuickAddProvider>
|
</QuickAddProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { ListDetail } from "@/modules/lists/components/list-detail";
|
import { ListDetail } from "@/modules/lists/components/list-detail";
|
||||||
import { getList } from "@/modules/lists/server/queries";
|
import { getList } from "@/modules/lists/server/queries";
|
||||||
|
|
||||||
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const list = await getList(id).catch(() => null);
|
const [{ user }, list] = await Promise.all([getCurrentSession(), getList(id).catch(() => null)]);
|
||||||
if (!list) notFound();
|
if (!list) notFound();
|
||||||
|
|
||||||
return <ListDetail initialList={list} />;
|
return <ListDetail initialList={list} currentUserId={user.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,10 +1,7 @@
|
|||||||
import { signIn } from "@/lib/auth";
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { BrandMark } from "@/components/brand-mark";
|
||||||
|
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||||
|
import { devLogin, signInWithSso } from "./actions";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const devLoginEnabled = isDevLoginEnabled();
|
const devLoginEnabled = isDevLoginEnabled();
|
||||||
@@ -23,41 +20,18 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
|
<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>
|
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
|
||||||
<form
|
<form action={signInWithSso}>
|
||||||
action={async () => {
|
|
||||||
"use server";
|
|
||||||
await signIn("authentik", { redirectTo: "/" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button type="submit" size="lg" className="w-full">
|
<Button type="submit" size="lg" className="w-full">
|
||||||
Sign in with SSO
|
Sign in with SSO
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{devLoginEnabled && (
|
{devLoginEnabled ? (
|
||||||
<form
|
<form action={devLogin} className="mt-3">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Button type="submit" variant="outline" size="lg" className="w-full">
|
<Button type="submit" variant="outline" size="lg" className="w-full">
|
||||||
Dev login
|
Dev login
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { NoteEditor } from "@/modules/notes/components/note-editor";
|
import { NoteEditor } from "@/modules/notes/components/note-editor";
|
||||||
import { getNote } from "@/modules/notes/server/queries";
|
import { getNote } from "@/modules/notes/server/queries";
|
||||||
|
|
||||||
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const note = await getNote(id).catch(() => null);
|
const [{ user }, note] = await Promise.all([getCurrentSession(), getNote(id).catch(() => null)]);
|
||||||
if (!note) notFound();
|
if (!note) notFound();
|
||||||
|
|
||||||
return <NoteEditor note={note} />;
|
return <NoteEditor note={note} currentUserId={user.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
} from "@/modules/_core/themes";
|
} from "@/modules/_core/themes";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { revokeShareLink } from "@/modules/_core/share";
|
import { revokeShareLink } from "@/modules/_core/share";
|
||||||
|
import { createHouseholdApiToken, revokeHouseholdApiToken } from "@/modules/_core/api-token";
|
||||||
|
|
||||||
export interface ThemePatch {
|
export interface ThemePatch {
|
||||||
palette?: Palette;
|
palette?: Palette;
|
||||||
@@ -88,3 +89,22 @@ export async function revokeShareLinkAction(formData: FormData): Promise<void> {
|
|||||||
await revokeShareLink(id);
|
await revokeShareLink(id);
|
||||||
revalidatePath("/settings");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { DetailBackLink } from "@/components/detail-back-link";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
|
||||||
import { householdMembers, users } from "@/modules/_core/schema";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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";
|
import { renameHousehold } from "./actions";
|
||||||
|
|
||||||
export default async function HouseholdSettingsPage() {
|
export default async function HouseholdSettingsPage() {
|
||||||
@@ -18,6 +19,7 @@ export default async function HouseholdSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-2xl py-8 space-y-6">
|
<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>
|
<h1 className="text-2xl font-semibold">Household Settings</h1>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
+123
-54
@@ -11,11 +11,26 @@ import { ResponsiveSidebar } from "@/components/settings-section";
|
|||||||
import type { SectionId } from "@/components/settings-section";
|
import type { SectionId } from "@/components/settings-section";
|
||||||
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||||
import { revokeShareLinkAction } from "./actions";
|
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 { DefaultEventRemindersSetting } from "@/components/default-event-reminders-setting";
|
||||||
import { listCalendars } from "@/modules/calendar/server/queries";
|
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||||
import { listLists } from "@/modules/lists/server/queries";
|
import { listLists } from "@/modules/lists/server/queries";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { NavIcon } from "@/components/nav-icon";
|
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>([
|
const VALID_SECTIONS = new Set<SectionId>([
|
||||||
"household",
|
"household",
|
||||||
@@ -36,7 +51,7 @@ export default async function SettingsPage({
|
|||||||
? (sp.s as SectionId)
|
? (sp.s as SectionId)
|
||||||
: "household";
|
: "household";
|
||||||
|
|
||||||
const { user, household } = await getCurrentSession();
|
const { user, household, role } = await getCurrentSession();
|
||||||
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
||||||
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
||||||
|
|
||||||
@@ -47,11 +62,16 @@ export default async function SettingsPage({
|
|||||||
{section === "household" && <HouseholdSection household={household} user={user} />}
|
{section === "household" && <HouseholdSection household={household} user={user} />}
|
||||||
{section === "sharing" && <SharingSection />}
|
{section === "sharing" && <SharingSection />}
|
||||||
{section === "notifications" && (
|
{section === "notifications" && (
|
||||||
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
<NotificationsSection
|
||||||
|
user={user}
|
||||||
|
defaultEventReminderOffsets={user.defaultEventReminderOffsets ?? [30]}
|
||||||
|
vapidKey={vapidKey}
|
||||||
|
ntfyConfigured={ntfyConfigured}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{section === "calendars" && <CalendarsAndListsSection />}
|
{section === "calendars" && <CalendarsAndListsSection />}
|
||||||
{section === "appearance" && <AppearanceSection user={user} />}
|
{section === "appearance" && <AppearanceSection user={user} />}
|
||||||
{section === "data" && <DataSection />}
|
{section === "data" && <DataSection householdId={household.id} role={role} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -171,10 +191,12 @@ async function SharingSection() {
|
|||||||
|
|
||||||
function NotificationsSection({
|
function NotificationsSection({
|
||||||
user,
|
user,
|
||||||
|
defaultEventReminderOffsets,
|
||||||
vapidKey,
|
vapidKey,
|
||||||
ntfyConfigured,
|
ntfyConfigured,
|
||||||
}: {
|
}: {
|
||||||
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
||||||
|
defaultEventReminderOffsets: number[];
|
||||||
vapidKey: string;
|
vapidKey: string;
|
||||||
ntfyConfigured: boolean;
|
ntfyConfigured: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -202,6 +224,18 @@ function NotificationsSection({
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Default event reminders</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DefaultEventRemindersSetting
|
||||||
|
key={defaultEventReminderOffsets.join(",")}
|
||||||
|
initialOffsets={defaultEventReminderOffsets}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -287,64 +321,99 @@ function AppearanceSection({
|
|||||||
themeDashLayout: string;
|
themeDashLayout: string;
|
||||||
themeCalView: string;
|
themeCalView: string;
|
||||||
themeNavStyle: string;
|
themeNavStyle: string;
|
||||||
|
assistantEnabled: boolean;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardHeader>
|
||||||
<Sun className="size-4 text-[var(--ink-mute)]" />
|
<CardTitle>Appearance</CardTitle>
|
||||||
</CardHeader>
|
<Sun className="size-4 text-[var(--ink-mute)]" />
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<ThemePicker
|
<CardContent>
|
||||||
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
|
<ThemePicker
|
||||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
|
||||||
initialFontPair={
|
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||||
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
|
initialFontPair={
|
||||||
}
|
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
|
||||||
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
|
}
|
||||||
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
|
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
|
||||||
initialCalView={user.themeCalView as "month" | "week" | "day"}
|
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
|
||||||
initialNavStyle={
|
initialCalView={user.themeCalView as "month" | "week" | "day"}
|
||||||
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
|
initialNavStyle={
|
||||||
}
|
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
|
||||||
signedIn
|
}
|
||||||
/>
|
signedIn
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
</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 (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader>
|
{role === "owner" && tokenStatus && (
|
||||||
<CardTitle>Data & backups</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<div>
|
<CardTitle>API access</CardTitle>
|
||||||
<div className="set-row">
|
<KeyRound className="size-4 text-[var(--ink-mute)]" />
|
||||||
<History className="size-4 text-[var(--ink-soft)]" />
|
</CardHeader>
|
||||||
<div className="label">
|
<CardContent className="p-0">
|
||||||
<div className="t">Auto-backup</div>
|
<ApiTokenSettings status={tokenStatus} />
|
||||||
<div className="d">Daily 03:00 → /var/backups/famapp/. Configured via host cron.</div>
|
</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>
|
</div>
|
||||||
<div className="set-row">
|
</Card>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
|
||||||
|
import { users } from "@/modules/_core/schema";
|
||||||
|
|
||||||
|
const offsetsSchema = z.array(z.number().int().min(0)).max(20);
|
||||||
|
|
||||||
|
export async function setDefaultEventReminderOffsets(offsets: number[]) {
|
||||||
|
const { user } = await getCurrentSession();
|
||||||
|
const parsed = normalizeReminderOffsets(offsetsSchema.parse(offsets));
|
||||||
|
|
||||||
|
await db.update(users).set({ defaultEventReminderOffsets: parsed }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
revalidatePath("/calendar");
|
||||||
|
revalidatePath("/settings");
|
||||||
|
}
|
||||||
@@ -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 <token></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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageSquare, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
currentUserId: string;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) {
|
||||||
|
const [open, setOpen] = useState(!compact);
|
||||||
|
const [comments, setComments] = useState<CommentDto[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
listComments(entityType, entityId)
|
||||||
|
.then(setComments)
|
||||||
|
.catch(() => setComments([]));
|
||||||
|
}, [entityType, entityId, open]);
|
||||||
|
|
||||||
|
function submitComment() {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const created = await addComment({ entityType, entityId, body });
|
||||||
|
setComments((current) => [...current, created]);
|
||||||
|
setDraft("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeComment(id: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteComment({ id });
|
||||||
|
setComments((current) => current.filter((comment) => comment.id !== id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={compact ? "mt-1" : "mt-3 border-t border-[var(--hair)] pt-3"}>
|
||||||
|
{compact ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
{open ? "Hide comments" : "Comments"}
|
||||||
|
{comments.length > 0 ? ` (${comments.length})` : ""}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="eyebrow mb-2">Comments</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-[var(--ink-mute)]">No comments yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<li
|
||||||
|
key={comment.id}
|
||||||
|
className="rounded-md border border-[var(--hair)] px-2.5 py-2 text-[13px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] text-[var(--ink-mute)]">
|
||||||
|
{comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 whitespace-pre-wrap break-words">{comment.body}</p>
|
||||||
|
</div>
|
||||||
|
{comment.authorId === currentUserId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Delete comment"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => removeComment(comment.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
placeholder="Add a comment…"
|
||||||
|
disabled={isPending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitComment();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!draft.trim() || isPending}
|
||||||
|
onClick={submitComment}
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommentTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,15 +3,20 @@
|
|||||||
import "react-grid-layout/css/styles.css";
|
import "react-grid-layout/css/styles.css";
|
||||||
import "react-resizable/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 { useRouter } from "next/navigation";
|
||||||
import { GridLayout } from "react-grid-layout";
|
import { GridLayout } from "react-grid-layout";
|
||||||
import type { Layout } from "react-grid-layout";
|
import type { Layout } from "react-grid-layout";
|
||||||
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
|
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
|
||||||
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
|
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
|
||||||
import { computePresetLayoutFromMetas } from "@/lib/dashboard";
|
import { computePresetLayoutFromMetas, widgetContentIndexKey } from "@/lib/dashboard";
|
||||||
import type { SerializedWidgetMeta } from "@/modules/_core/registry";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { WidgetPicker } from "./widget-picker";
|
import { WidgetPicker } from "./widget-picker";
|
||||||
|
|
||||||
@@ -23,10 +28,12 @@ export function DashboardEditor({
|
|||||||
dashboard,
|
dashboard,
|
||||||
layout: initialLayout,
|
layout: initialLayout,
|
||||||
widgetMetas,
|
widgetMetas,
|
||||||
|
widgetContents,
|
||||||
}: {
|
}: {
|
||||||
dashboard: { id: string; name: string; slug: string };
|
dashboard: { id: string; name: string; slug: string };
|
||||||
layout: DashboardLayout;
|
layout: DashboardLayout;
|
||||||
widgetMetas: SerializedWidgetMeta[];
|
widgetMetas: SerializedWidgetMeta[];
|
||||||
|
widgetContents: Record<string, ReactNode>;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -56,8 +63,18 @@ export function DashboardEditor({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dashboard.slug]);
|
}, [dashboard.slug]);
|
||||||
|
|
||||||
|
function publishDraft(next: WidgetPlacement[]) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await syncEditorDraftLayout(dashboard.id, { version: 1, widgets: next });
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
router.push(`/d/${dashboard.slug}`);
|
startTransition(async () => {
|
||||||
|
await discardEditorDraftLayout(dashboard.id);
|
||||||
|
router.push(`/d/${dashboard.slug}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
@@ -87,26 +104,42 @@ export function DashboardEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeWidget(index: number) {
|
function removeWidget(index: number) {
|
||||||
setPlacements((current) => current.filter((_, i) => i !== index));
|
const next = placements.filter((_, i) => i !== index);
|
||||||
|
setPlacements(next);
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
|
publishDraft(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWidget(widgetId: string, config: unknown) {
|
function addWidget(widgetId: string, config: unknown) {
|
||||||
const meta = widgetMetas.find((m) => m.id === widgetId);
|
const meta = widgetMetas.find((m) => m.id === widgetId);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
const resolvedConfig = resolveWidgetConfig(meta, config);
|
||||||
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
|
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
|
||||||
setPlacements((current) => [
|
const next = [
|
||||||
...current,
|
...placements,
|
||||||
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h },
|
{
|
||||||
]);
|
widgetId,
|
||||||
|
config: resolvedConfig,
|
||||||
|
x: 0,
|
||||||
|
y: maxY,
|
||||||
|
w: meta.defaultSize.w,
|
||||||
|
h: meta.defaultSize.h,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setPlacements(next);
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
|
publishDraft(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateConfig(index: number, config: unknown) {
|
function updateConfig(index: number, config: unknown) {
|
||||||
setPlacements((current) => current.map((p, i) => (i === index ? { ...p, config } : p)));
|
const meta = widgetMetas.find((m) => m.id === placements[index]?.widgetId);
|
||||||
|
const resolvedConfig = meta ? resolveWidgetConfig(meta, config) : config;
|
||||||
|
const next = placements.map((p, i) => (i === index ? { ...p, config: resolvedConfig } : p));
|
||||||
|
setPlacements(next);
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
setConfiguringIndex(null);
|
setConfiguringIndex(null);
|
||||||
|
publishDraft(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreset(preset: PresetId) {
|
function applyPreset(preset: PresetId) {
|
||||||
@@ -114,6 +147,7 @@ export function DashboardEditor({
|
|||||||
setPlacements(next.widgets);
|
setPlacements(next.widgets);
|
||||||
setIsDirty(true);
|
setIsDirty(true);
|
||||||
setPresetMenuOpen(false);
|
setPresetMenuOpen(false);
|
||||||
|
publishDraft(next.widgets);
|
||||||
}
|
}
|
||||||
|
|
||||||
const gridItems: Layout = placements.map((p, i) => ({
|
const gridItems: Layout = placements.map((p, i) => ({
|
||||||
@@ -213,6 +247,7 @@ export function DashboardEditor({
|
|||||||
>
|
>
|
||||||
{placements.map((placement, i) => {
|
{placements.map((placement, i) => {
|
||||||
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
|
const meta = widgetMetas.find((m) => m.id === placement.widgetId);
|
||||||
|
const content = widgetContents[widgetContentIndexKey(i)];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={placementKey(placement, i)}
|
key={placementKey(placement, i)}
|
||||||
@@ -240,8 +275,12 @@ export function DashboardEditor({
|
|||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center p-4">
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
<p className="text-xs text-muted-foreground">{meta?.description}</p>
|
{content ?? (
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
{meta?.description ?? "Preview available after save"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -279,3 +318,13 @@ export function DashboardEditor({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWidgetConfig(meta: SerializedWidgetMeta, config: unknown): unknown {
|
||||||
|
if (config != null && typeof config === "object" && !Array.isArray(config)) {
|
||||||
|
return {
|
||||||
|
...(meta.defaultConfig as Record<string, unknown>),
|
||||||
|
...(config as Record<string, unknown>),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return meta.defaultConfig;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
const greetingForHour = (h: number) =>
|
const greetingForHour = (h: number) =>
|
||||||
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
|
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}
|
{greeting}
|
||||||
{firstName && `, ${firstName}`}.
|
{firstName && `, ${firstName}`}.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="muted mt-1 text-[13px]">{today}</p>
|
<p className="muted mt-1 text-[13px]" suppressHydrationWarning>
|
||||||
|
{today}
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { setDefaultEventReminderOffsets } from "@/app/settings/reminder-actions";
|
||||||
|
|
||||||
|
export function DefaultEventRemindersSetting({ initialOffsets }: { initialOffsets: number[] }) {
|
||||||
|
const [saved, setSaved] = useState(initialOffsets);
|
||||||
|
const [offsets, setOffsets] = useState(initialOffsets);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
offsets.length !== saved.length || offsets.some((value, index) => value !== saved[index]);
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await setDefaultEventReminderOffsets(offsets);
|
||||||
|
setSaved(offsets);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="muted text-[13px]">
|
||||||
|
Applied when you create a new calendar event. You can still customize reminders per event.
|
||||||
|
</p>
|
||||||
|
<ReminderPicker offsets={offsets} onChange={setOffsets} disabled={isPending} />
|
||||||
|
<Button type="button" size="sm" disabled={!dirty || isPending} onClick={handleSave}>
|
||||||
|
{isPending ? "Saving…" : "Save defaults"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
BookHeart,
|
||||||
Calendar,
|
Calendar,
|
||||||
Sprout,
|
Sprout,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Mail,
|
Mail,
|
||||||
Menu,
|
Menu,
|
||||||
|
MessageCircle,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Phone,
|
Phone,
|
||||||
@@ -45,6 +47,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
|
|||||||
list: ListChecks,
|
list: ListChecks,
|
||||||
"check-square": CheckSquare,
|
"check-square": CheckSquare,
|
||||||
"file-text": FileText,
|
"file-text": FileText,
|
||||||
|
"book-heart": BookHeart,
|
||||||
note: FileText,
|
note: FileText,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
history: History,
|
history: History,
|
||||||
@@ -72,6 +75,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
|
|||||||
sprout: Sprout,
|
sprout: Sprout,
|
||||||
mail: Mail,
|
mail: Mail,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
|
"message-circle": MessageCircle,
|
||||||
more: MoreHorizontal,
|
more: MoreHorizontal,
|
||||||
x: X,
|
x: X,
|
||||||
"chevron-left": ChevronLeft,
|
"chevron-left": ChevronLeft,
|
||||||
|
|||||||
@@ -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 { CalendarEventCreateDialog } from "@/components/quick-add/dialogs/calendar-event-create-dialog";
|
||||||
import { ContainerCreateDialog } from "@/components/quick-add/dialogs/container-create-dialog";
|
import { ContainerCreateDialog } from "@/components/quick-add/dialogs/container-create-dialog";
|
||||||
import { GardenCareLogDialog } from "@/components/quick-add/dialogs/garden-care-log-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 { ListCreateDialog } from "@/components/quick-add/dialogs/list-create-dialog";
|
||||||
import { ListItemCreateDialog } from "@/components/quick-add/dialogs/list-item-create-dialog";
|
import { ListItemCreateDialog } from "@/components/quick-add/dialogs/list-item-create-dialog";
|
||||||
import { NoteCreateDialog } from "@/components/quick-add/dialogs/note-create-dialog";
|
import { NoteCreateDialog } from "@/components/quick-add/dialogs/note-create-dialog";
|
||||||
@@ -29,6 +30,10 @@ export function QuickAddCreateHost() {
|
|||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
/>
|
/>
|
||||||
<NoteCreateDialog open={activeCreateKey === "notes.note"} onOpenChange={onOpenChange} />
|
<NoteCreateDialog open={activeCreateKey === "notes.note"} onOpenChange={onOpenChange} />
|
||||||
|
<JournalEntryCreateDialog
|
||||||
|
open={activeCreateKey === "journal.entry"}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
<ListItemCreateDialog
|
<ListItemCreateDialog
|
||||||
open={activeCreateKey === "lists.add-shopping"}
|
open={activeCreateKey === "lists.add-shopping"}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
|
import { ReminderPicker } from "@/components/reminder-picker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,7 +22,24 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { createEvent } from "@/modules/calendar/server/actions";
|
import { createEvent } from "@/modules/calendar/server/actions";
|
||||||
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries";
|
import { richTextToPlainText } from "@/components/rich-text";
|
||||||
|
import {
|
||||||
|
getDefaultEventReminderOffsets,
|
||||||
|
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 = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -30,7 +49,7 @@ type Props = {
|
|||||||
export function CalendarEventCreateDialog({ open, onOpenChange }: Props) {
|
export function CalendarEventCreateDialog({ open, onOpenChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
{open ? <CalendarEventCreateForm onDone={() => onOpenChange(false)} /> : null}
|
{open ? <CalendarEventCreateForm onDone={() => onOpenChange(false)} /> : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -45,7 +64,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
||||||
const [location, setLocation] = useState("");
|
const [location, setLocation] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [remind, setRemind] = useState(true);
|
const [reminderOffsets, setReminderOffsets] = useState<number[]>([30]);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,6 +72,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
setCalendars(rows);
|
setCalendars(rows);
|
||||||
setCalendarId(rows[0]?.id ?? "");
|
setCalendarId(rows[0]?.id ?? "");
|
||||||
});
|
});
|
||||||
|
getDefaultEventReminderOffsets().then(setReminderOffsets);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calendarItems = useMemo(
|
const calendarItems = useMemo(
|
||||||
@@ -71,8 +91,8 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
endAt: new Date(endAt),
|
endAt: new Date(endAt),
|
||||||
allDay: false,
|
allDay: false,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
notes: notes || null,
|
notes: richTextToPlainText(notes) ? notes : null,
|
||||||
remindMinutesBefore: remind ? 30 : null,
|
reminderOffsets,
|
||||||
});
|
});
|
||||||
onDone();
|
onDone();
|
||||||
});
|
});
|
||||||
@@ -146,24 +166,22 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
onChange={(e) => setLocation(e.target.value)}
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5 min-w-0">
|
||||||
<Label htmlFor="qa-event-notes">Notes</Label>
|
<Label htmlFor="qa-event-notes">Notes</Label>
|
||||||
<textarea
|
<RichTextEditor
|
||||||
id="qa-event-notes"
|
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}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={setNotes}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Add details…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<ReminderPicker
|
||||||
<input
|
offsets={reminderOffsets}
|
||||||
type="checkbox"
|
onChange={setReminderOffsets}
|
||||||
className="size-4 cursor-pointer"
|
disabled={isPending}
|
||||||
checked={remind}
|
/>
|
||||||
onChange={(e) => setRemind(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Remind me 30 min before
|
|
||||||
</label>
|
|
||||||
</form>
|
</form>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
|
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
|
||||||
|
|||||||
@@ -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 (1–10)</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";
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,18 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { createNote } from "@/modules/notes/server/actions";
|
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 = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -21,7 +34,7 @@ type Props = {
|
|||||||
export function NoteCreateDialog({ open, onOpenChange }: Props) {
|
export function NoteCreateDialog({ open, onOpenChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
{open ? <NoteCreateForm onDone={() => onOpenChange(false)} /> : null}
|
{open ? <NoteCreateForm onDone={() => onOpenChange(false)} /> : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -62,14 +75,15 @@ function NoteCreateForm({ onDone }: { onDone: () => void }) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5 min-w-0">
|
||||||
<Label htmlFor="qa-note-body">Body</Label>
|
<Label htmlFor="qa-note-body">Body</Label>
|
||||||
<textarea
|
<RichTextEditor
|
||||||
id="qa-note-body"
|
id="qa-note-body"
|
||||||
aria-label="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}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={setBody}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Start writing…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
REMINDER_PRESETS,
|
||||||
|
customOffsetToMinutes,
|
||||||
|
formatReminderOffset,
|
||||||
|
normalizeReminderOffsets,
|
||||||
|
type ReminderOffsetUnit,
|
||||||
|
} from "@/lib/reminder-offsets";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
offsets: number[];
|
||||||
|
onChange: (offsets: number[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReminderPicker({ offsets, onChange, disabled }: Props) {
|
||||||
|
const [customValue, setCustomValue] = useState("1");
|
||||||
|
const [customUnit, setCustomUnit] = useState<ReminderOffsetUnit>("hours");
|
||||||
|
const normalized = useMemo(() => normalizeReminderOffsets(offsets), [offsets]);
|
||||||
|
|
||||||
|
function addOffset(minutes: number) {
|
||||||
|
onChange(normalizeReminderOffsets([...normalized, minutes]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOffset(minutes: number) {
|
||||||
|
onChange(normalized.filter((o) => o !== minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustom() {
|
||||||
|
const parsed = Number.parseInt(customValue, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||||
|
addOffset(customOffsetToMinutes(parsed, customUnit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const availablePresets = REMINDER_PRESETS.filter((p) => !normalized.includes(p.offsetMinutes));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Reminders</Label>
|
||||||
|
{normalized.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-[var(--ink-mute)]">No reminders set.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{normalized.map((offset) => (
|
||||||
|
<li
|
||||||
|
key={offset}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hair)] px-2.5 py-1.5 text-[13px]"
|
||||||
|
>
|
||||||
|
<span>{formatReminderOffset(offset)}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Remove ${formatReminderOffset(offset)}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeOffset(offset)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availablePresets.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{availablePresets.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.offsetMinutes}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => addOffset(preset.offsetMinutes)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[80px_120px_auto] sm:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-custom-value">Custom</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-custom-value"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={customValue}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="reminder-custom-unit">Unit</Label>
|
||||||
|
<Select
|
||||||
|
value={customUnit}
|
||||||
|
onValueChange={(value) => setCustomUnit(value as ReminderOffsetUnit)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="reminder-custom-unit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="minutes">Minutes</SelectItem>
|
||||||
|
<SelectItem value="hours">Hours</SelectItem>
|
||||||
|
<SelectItem value="days">Days</SelectItem>
|
||||||
|
<SelectItem value="weeks">Weeks</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" disabled={disabled} onClick={addCustom}>
|
||||||
|
Add custom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/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()}…`;
|
||||||
|
}
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user