Implement quick-add registry (task 21)

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 13:44:33 -05:00
co-authored by Claude Sonnet 4.6
parent b9d3cce73a
commit 1133fa8aac
17 changed files with 845 additions and 25 deletions
+1
View File
@@ -20,6 +20,7 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
- **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass.
- **20 — Dashboard composition (single-dashboard MVP)**. Added `default_dashboard_layout` jsonb column to `users` + migration `0007_uneven_living_lightning.sql`. Created `src/modules/_core/manifest.tsx` (`core.activity` placeholder widget) and registered it. Updated all three module manifests (calendar, lists, notes) with real async server component widget renders (data-fetching, empty states). Created `src/lib/dashboard.ts` (layout parsing + `computeDefaultLayout` greedy packer). Built `src/app/page.tsx` — 12-col CSS Grid, static `smColSpan` lookup for Tailwind class safety, per-widget `<Suspense>` for parallel loading, graceful skip for unknown widget IDs. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
- **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
## Next up
+11 -2
View File
@@ -22,5 +22,14 @@ A single global `+` button on the dashboard (and a `cmd+k` palette anywhere) tha
## Acceptance criteria
- [ ] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
- [ ] Keyboard navigation works in the palette (arrows + enter + escape).
- [x] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
- [x] Keyboard navigation works in the palette (arrows + enter + escape).
## Implementation notes
- `QuickAddAction` gained a `url: string` field; `action` made optional (future inline modals).
- `SerializedQuickAddItem` strips the non-serializable `action` fn so it can cross the server→client RSC boundary as props to `QuickAddProvider`.
- `getQuickAdds()` in the registry returns one `SerializedQuickAddItem` per registered quick-add, enriched with `moduleId` / `moduleName` for grouping.
- Root layout calls `getQuickAdds()` and passes the result to `<QuickAddProvider>` which wraps the whole app. `<QuickAddSheet>` and `<CommandPalette>` live inside the provider and read from context.
- `cmdk` installed for the command palette; keyboard shortcut (cmd/ctrl+k) wired via `useEffect` in the provider.
- `.claude/**` added to ESLint ignores to prevent stale worktree `.next` build artifacts from failing lint.
+1
View File
@@ -8,6 +8,7 @@ export default tseslint.config(
ignores: [
"node_modules/**",
".next/**",
".claude/**",
"dist/**",
"drizzle/**",
],
+1
View File
@@ -50,6 +50,7 @@
"@fullcalendar/timegrid": "^6.1.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.14.0",
"next": "^15.5.15",
+484
View File
@@ -35,6 +35,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(postgres@3.4.9)
@@ -1218,6 +1221,199 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.15':
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.7':
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.4':
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.1':
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -1567,6 +1763,10 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -1722,6 +1922,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
@@ -1870,6 +2076,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
diff@8.0.4:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
@@ -2364,6 +2573,10 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-own-enumerable-keys@1.0.0:
resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==}
engines: {node: '>=14.16'}
@@ -3247,6 +3460,36 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-remove-scroll@2.7.2:
resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react@19.2.5:
resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==}
engines: {node: '>=0.10.0'}
@@ -3653,6 +3896,26 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -4616,6 +4879,165 @@ snapshots:
dependencies:
playwright: 1.59.1
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
react: 19.2.5
optionalDependencies:
'@types/react': 19.2.14
'@rtsao/scc@1.1.0': {}
'@sec-ant/readable-stream@0.4.1': {}
@@ -4923,6 +5345,10 @@ snapshots:
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2:
@@ -5107,6 +5533,18 @@ snapshots:
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
code-block-writer@13.0.3: {}
color-convert@2.0.1:
@@ -5218,6 +5656,8 @@ snapshots:
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
diff@8.0.4: {}
doctrine@2.1.0:
@@ -5885,6 +6325,8 @@ snapshots:
hasown: 2.0.3
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
get-own-enumerable-keys@1.0.0: {}
get-proto@1.0.1:
@@ -6678,6 +7120,33 @@ snapshots:
react-is@16.13.1: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
dependencies:
react: 19.2.5
react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5):
dependencies:
react: 19.2.5
react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5)
react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5)
use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
get-nonce: 1.0.1
react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
react@19.2.5: {}
recast@0.23.11:
@@ -7239,6 +7708,21 @@ snapshots:
dependencies:
punycode: 2.3.1
use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5):
dependencies:
detect-node-es: 1.1.0
react: 19.2.5
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.14
use-sync-external-store@1.6.0(react@19.2.5):
dependencies:
react: 19.2.5
+12 -2
View File
@@ -8,6 +8,10 @@ import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import { users } from "@/modules/_core/schema";
import { getQuickAdds } from "@/modules/_core";
import { QuickAddProvider } from "@/components/quick-add-provider";
import { QuickAddSheet } from "@/components/quick-add-sheet";
import { CommandPalette } from "@/components/command-palette";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
@@ -54,6 +58,8 @@ export default async function RootLayout({
// script will correct it before paint. We optimistically render light here.
const isDark = themeMode === "dark";
const quickAdds = getQuickAdds();
return (
<html
lang="en"
@@ -64,8 +70,12 @@ export default async function RootLayout({
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
</head>
<body className="min-h-screen">
<AppNav />
<main>{children}</main>
<QuickAddProvider actions={quickAdds}>
<AppNav />
<main>{children}</main>
<QuickAddSheet />
<CommandPalette />
</QuickAddProvider>
</body>
</html>
);
+2 -7
View File
@@ -6,6 +6,7 @@ import { getCurrentSession } from "@/lib/session";
import { getWidget } from "@/modules/_core";
import { users } from "@/modules/_core/schema";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QuickAddFab } from "@/components/quick-add-fab";
// Static lookup ensures Tailwind sees all sm:col-span-* classes at build time.
const smColSpan: Record<number, string> = {
@@ -43,13 +44,7 @@ export default async function DashboardPage() {
<div className="p-4 sm:p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
{/* Quick-add FAB — wired up in task 21 */}
<button
aria-label="Quick add"
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md transition-opacity hover:opacity-90"
>
<span className="text-xl leading-none">+</span>
</button>
<QuickAddFab />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef } from "react";
import { Command } from "cmdk";
import { useRouter } from "next/navigation";
import { useQuickAdd } from "./quick-add-provider";
export function CommandPalette() {
const { paletteOpen, closePalette, actions } = useQuickAdd();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (paletteOpen) {
// Let the DOM mount, then focus
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [paletteOpen]);
if (!paletteOpen) return null;
function handleSelect(url: string) {
closePalette();
router.push(url);
}
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-50 bg-black/50"
onClick={closePalette}
aria-hidden="true"
/>
{/* Palette modal */}
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
className="fixed left-1/2 top-1/4 z-50 w-full max-w-md -translate-x-1/2 rounded-xl bg-background shadow-2xl ring-1 ring-border"
>
<Command
className="flex flex-col overflow-hidden rounded-xl"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
closePalette();
}
}}
>
<div className="border-b px-3 py-2">
<Command.Input
ref={inputRef}
placeholder="Quick add…"
className="w-full bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<Command.List className="max-h-80 overflow-y-auto p-2">
<Command.Empty className="px-3 py-6 text-center text-sm text-muted-foreground">
No actions found.
</Command.Empty>
{groupByModule(actions).map(([moduleId, group]) => (
<Command.Group key={moduleId} heading={group.name}>
{group.items.map((action) => (
<Command.Item
key={action.id}
value={`${action.moduleName} ${action.label}`}
onSelect={() => handleSelect(action.url)}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<span className="text-base leading-none">{iconEmoji(action.icon)}</span>
{action.label}
</Command.Item>
))}
</Command.Group>
))}
</Command.List>
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
<kbd className="rounded border px-1 py-0.5 font-mono"></kbd> navigate ·{" "}
<kbd className="rounded border px-1 py-0.5 font-mono"></kbd> select ·{" "}
<kbd className="rounded border px-1 py-0.5 font-mono">esc</kbd> close
</div>
</Command>
</div>
</>
);
}
type GroupEntry = [string, { name: string; items: import("@/modules/_core").SerializedQuickAddItem[] }];
function groupByModule(
actions: import("@/modules/_core").SerializedQuickAddItem[],
): GroupEntry[] {
const map = new Map<string, { name: string; items: import("@/modules/_core").SerializedQuickAddItem[] }>();
for (const action of actions) {
if (!map.has(action.moduleId)) {
map.set(action.moduleId, { name: action.moduleName, items: [] });
}
map.get(action.moduleId)!.items.push(action);
}
return [...map.entries()];
}
function iconEmoji(icon?: string): string {
const map: Record<string, string> = {
"calendar-plus": "📅",
"calendar-days": "🗓️",
"shopping-cart": "🛒",
"list-checks": "✅",
"list-plus": "📋",
"file-plus": "📝",
};
return icon ? (map[icon] ?? "") : "";
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useQuickAdd } from "./quick-add-provider";
export function QuickAddFab() {
const { openSheet } = useQuickAdd();
return (
<button
aria-label="Quick add"
onClick={openSheet}
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md transition-opacity hover:opacity-90"
>
<span className="text-xl leading-none">+</span>
</button>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import type { SerializedQuickAddItem } from "@/modules/_core";
type QuickAddState = {
sheetOpen: boolean;
paletteOpen: boolean;
openSheet: () => void;
closeSheet: () => void;
openPalette: () => void;
closePalette: () => void;
actions: SerializedQuickAddItem[];
};
const QuickAddContext = createContext<QuickAddState | null>(null);
export function useQuickAdd() {
const ctx = useContext(QuickAddContext);
if (!ctx) throw new Error("useQuickAdd must be used inside QuickAddProvider");
return ctx;
}
export function QuickAddProvider({
children,
actions,
}: {
children: React.ReactNode;
actions: SerializedQuickAddItem[];
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const openSheet = useCallback(() => setSheetOpen(true), []);
const closeSheet = useCallback(() => setSheetOpen(false), []);
const openPalette = useCallback(() => setPaletteOpen(true), []);
const closePalette = useCallback(() => setPaletteOpen(false), []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setPaletteOpen((prev) => !prev);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
return (
<QuickAddContext.Provider
value={{ sheetOpen, paletteOpen, openSheet, closeSheet, openPalette, closePalette, actions }}
>
{children}
</QuickAddContext.Provider>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useQuickAdd } from "./quick-add-provider";
import type { SerializedQuickAddItem } from "@/modules/_core";
function groupByModule(actions: SerializedQuickAddItem[]): Map<string, { name: string; items: SerializedQuickAddItem[] }> {
const map = new Map<string, { name: string; items: SerializedQuickAddItem[] }>();
for (const action of actions) {
if (!map.has(action.moduleId)) {
map.set(action.moduleId, { name: action.moduleName, items: [] });
}
map.get(action.moduleId)!.items.push(action);
}
return map;
}
export function QuickAddSheet() {
const { sheetOpen, closeSheet, actions } = useQuickAdd();
const router = useRouter();
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!sheetOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") closeSheet();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [sheetOpen, closeSheet]);
if (!sheetOpen) return null;
const groups = groupByModule(actions);
function handleAction(url: string) {
closeSheet();
router.push(url);
}
return (
<>
{/* Backdrop */}
<div
ref={backdropRef}
className="fixed inset-0 z-40 bg-black/40"
onClick={closeSheet}
aria-hidden="true"
/>
{/* Sheet panel — bottom on mobile, right-anchored popover on sm+ */}
<div
role="dialog"
aria-modal="true"
aria-label="Quick add"
className="fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-background shadow-xl sm:bottom-auto sm:left-auto sm:right-6 sm:top-16 sm:w-72 sm:rounded-xl"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-semibold">Quick add</span>
<button
onClick={closeSheet}
aria-label="Close quick add"
className="rounded p-1 text-muted-foreground hover:bg-muted"
>
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2 sm:max-h-96">
{[...groups.entries()].map(([moduleId, group]) => (
<div key={moduleId} className="mb-2">
<p className="px-2 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group.name}
</p>
{group.items.map((action) => (
<button
key={action.id}
onClick={() => handleAction(action.url)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
>
<span className="text-base leading-none">{iconEmoji(action.icon)}</span>
{action.label}
</button>
))}
</div>
))}
</div>
</div>
</>
);
}
function iconEmoji(icon?: string): string {
const map: Record<string, string> = {
"calendar-plus": "📅",
"calendar-days": "🗓️",
"shopping-cart": "🛒",
"list-checks": "✅",
"list-plus": "📋",
"file-plus": "📝",
};
return icon ? (map[icon] ?? "") : "";
}
+2 -1
View File
@@ -9,4 +9,5 @@ export type {
SearchAdapter,
SearchResult,
} from "./module";
export { registerModule, getRegistry, getEntityType, getWidget } from "./registry";
export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry";
export type { QuickAddItem, SerializedQuickAddItem } from "./registry";
+3 -1
View File
@@ -45,7 +45,9 @@ export type QuickAddAction = {
id: string;
label: string;
icon?: string;
action: (ctx: WidgetContext) => void | Promise<void>;
/** Navigation target used by the FAB sheet and command palette. */
url: string;
action?: (ctx: WidgetContext) => void | Promise<void>;
};
export type EntityTypeRegistration = {
+26 -1
View File
@@ -1,4 +1,4 @@
import type { ModuleManifest, EntityTypeRegistration, DashboardWidget } from "./module";
import type { ModuleManifest, EntityTypeRegistration, DashboardWidget, QuickAddAction } from "./module";
const modules = new Map<string, ModuleManifest>();
const entityTypes = new Map<string, EntityTypeRegistration>();
@@ -29,3 +29,28 @@ export function getEntityType(type: string): EntityTypeRegistration | undefined
export function getWidget(id: string): DashboardWidget | undefined {
return widgets.get(id);
}
export type QuickAddItem = QuickAddAction & { moduleId: string; moduleName: string };
/** Serializable subset safe to pass from server components to client components. */
export type SerializedQuickAddItem = {
id: string;
label: string;
icon?: string;
url: string;
moduleId: string;
moduleName: string;
};
export function getQuickAdds(): SerializedQuickAddItem[] {
return [...modules.values()].flatMap((manifest) =>
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
id,
label,
icon,
url,
moduleId: manifest.id,
moduleName: manifest.name,
})),
);
}
+2 -2
View File
@@ -156,13 +156,13 @@ const manifest: ModuleManifest = {
id: "calendar.new-event",
label: "New event",
icon: "calendar-plus",
action: () => undefined,
url: "/calendar",
},
{
id: "calendar.new-calendar",
label: "New calendar",
icon: "calendar-days",
action: () => undefined,
url: "/calendar",
},
],
};
+3 -8
View File
@@ -1,6 +1,5 @@
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { z } from "zod";
import { addItemToDefaultList } from "./server/actions";
import { listLists, listWidgetItems, searchItems, searchLists } from "./server/queries";
const listIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
@@ -90,23 +89,19 @@ const manifest: ModuleManifest = {
id: "lists.add-shopping",
label: "Add to shopping",
icon: "shopping-cart",
action: async () => {
await addItemToDefaultList({ type: "shopping", text: "New item" });
},
url: "/lists",
},
{
id: "lists.add-task",
label: "Add to tasks",
icon: "list-checks",
action: async () => {
await addItemToDefaultList({ type: "task", text: "New task" });
},
url: "/lists",
},
{
id: "lists.new-list",
label: "New list",
icon: "list-plus",
action: () => undefined,
url: "/lists",
},
],
};
+1 -1
View File
@@ -67,7 +67,7 @@ const manifest: ModuleManifest = {
id: "notes.new-note",
label: "New note",
icon: "file-plus",
action: () => undefined,
url: "/notes/new",
},
],
};