Compare commits

...
356 Commits
Author SHA1 Message Date
github-actions[bot] cd93c18f92 chore: bump version to 0.26.0 2026-03-22 19:11:39 +00:00
Frederic CabassutandClaude Opus 4.6 172ab88b33 feat: custom tooltip action replacing native title attributes
Add a reusable `use:tooltip` Svelte action as a drop-in replacement for
native title attributes. Styled to match the retro parchment theme with
JetBrains Mono font, dark background, and 400ms hover delay.

Replace all title attributes in NoteEditor, NoteCard, and NotesView
with the custom tooltip. The action supports top/bottom positioning
and auto-clamps to viewport edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:09:32 +01:00
Frederic CabassutandClaude Opus 4.6 c82f720b02 fix: add cursor pointer to create crumb buttons
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:04:23 +01:00
Frederic CabassutandClaude Opus 4.6 548aa14d63 fix: add cursor pointer to create crumb button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:03:26 +01:00
Frederic CabassutandClaude Opus 4.6 c66217e6f1 feat: add checklist shortcut to create button
Add a checkbox icon next to the "Add a crumb..." button that opens a new
note directly in checklist mode, similar to Google Keep's quick-create
bar. The NoteEditor accepts a new initialChecklistMode prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:53:01 +01:00
Frederic CabassutandClaude Opus 4.6 6c9781fb24 fix: sharing indicator popover opens downward to avoid overflow clipping
The popover was opening upward (bottom-full) which got clipped by the
NoteCard's overflow-hidden. Changed to open downward (top-full) so it
stays within the card's visual area.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:50:48 +01:00
Frederic CabassutandClaude Opus 4.6 b44cd40e36 fix: align nested checkboxes in rich text task lists
Nested TipTap task lists (indented children) broke the flex layout
because the nested ul was treated as an inline flex child. Now the
li uses flex-wrap with align-items: flex-start, and nested task lists
take full width with left padding for indentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:49:10 +01:00
Frederic CabassutandClaude Opus 4.6 8b9f9dfee8 feat: add overflow menu with checklist actions and action bar cleanup
- Add vertical dots (⋮) overflow menu to NoteEditor bottom action bar
- New checklist actions: "Delete checked" and "Uncheck all"
- Move secondary actions to menu: checklist toggle, markdown toggle,
  version history, move to trash
- Promote archive to the action bar for quick access
- Keep in bar: color picker, image upload, share, archive, ⋮, close
- Menu renders as fixed-position portal to escape card overflow-hidden
- Click-outside handler closes the menu
- Update e2e tests to use overflow menu for checklist/markdown toggles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:47:02 +01:00
Frederic CabassutandClaude Opus 4.6 5a2a63e2bb fix: prevent 3-way merge from reverting sequential single-user edits
The server's 3-way content merge was incorrectly triggering on sequential
edits by the same user. When a user checked then unchecked a checklist
item, the merge saw local === base (both unchecked) and kept the server's
checked state, silently discarding the uncheck.

Now the merge only runs when there's an actual concurrent edit
(baseVersion < existing.version), meaning another user saved between the
client's load and this save. For single-user sequential edits, the
client's content is used directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:51:14 +01:00
Frederic CabassutandClaude Opus 4.6 6eb35e9df2 perf: add BuildX + GHA caching to CI docker validation build
CI only needs to verify the Dockerfile builds on amd64 — multi-arch
is handled by the release workflow with native ARM runners. Switching
from bare `docker build` to BuildX with GHA layer caching speeds up
subsequent builds significantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:28:48 +01:00
Frederic CabassutandClaude Opus 4.6 f1b42f115a perf: parallelize multi-arch Docker builds with native ARM runners
Replace sequential QEMU-emulated builds with parallel native builds
using a matrix strategy (ubuntu-latest for amd64, ubuntu-24.04-arm
for arm64) and manifest merging. This eliminates the ~5-10x QEMU
overhead and should cut Docker build time roughly in half.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:06:37 +01:00
github-actions[bot] 133217d552 chore: pin docker-compose to v0.25.0 [skip ci] 2026-03-21 23:03:17 +00:00
github-actions[bot] b06c1514d2 chore: bump version to 0.25.0 2026-03-21 22:52:14 +00:00
Frederic CabassutandClaude Opus 4.6 2267474cd5 fix: address PR review — a11y, checked detection, test fixes
- Remove unused flushSync import
- Fix removeItem single-item branch: emit change and trigger reactivity
- Remove misleading role="button" + tabindex from drag handle
- Use anchored regex for checked detection instead of line.includes('[x]')
  in NoteCard and public share page (prevents false positive on item text)
- Fix NoteCard preview E2E test: scope assertion to specific card, use
  unique title to avoid collision
- Restore arrow key navigation E2E test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 0124c5e7f6 fix: unchecked child repositions after its parent and siblings
When unchecking a child from the done section, it stayed at the end of
the items array (where done items live), appearing as the last active
item instead of under its parent. Now repositions after the last sibling
or parent in the array.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 ffcabad30e docs: add nested checklists to features and architecture docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 dee5129d59 test: add E2E tests for nested checklist interactions
7 new scenarios: Tab/Shift+Tab, parent cascade check, child done with
parent label, uncheck parent restores group, nesting persists on reopen,
Enter on child creates sibling, NoteCard preview indentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 087fe8c713 feat: NoteCard and public share page support nested checklist items
Update checklist preview parsers to detect 2-space child prefix and
render indented items with extra left padding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 2306883503 feat: nested checklist UI — indentation, parent labels, drag-to-indent
Checklist component changes:
- Import logic from checklist utility module
- Tab/Shift+Tab indent/outdent with sibling adoption
- Indented rendering (pl-8) for child items in active section
- Done section groups checked children under read-only parent labels
- Enter on child creates sibling at same indent level
- Backspace on parent orphans children to top-level
- Directional lock drag: horizontal = indent/outdent, vertical = reorder
  (uses window capture-phase listeners + pending indent applied after
  DnD finalize to avoid corrupting svelte-dnd-action internal state)
- flushSync for state updates from window event listeners

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 9fb8acc719 feat: checklist utility module with parentId and nesting logic
Extract checklist logic into testable utility module:
- ChecklistItem type with parentId field for single-level nesting
- parseChecklist/serializeChecklist with 2-space prefix for children
- toggleItemWithCascade: parent check/uncheck cascades to children
- indentItem/outdentItem with sibling adoption rules
- 21 unit tests covering parse, serialize, toggle, indent, outdent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:51:46 +01:00
Frederic CabassutandClaude Opus 4.6 8155bd4ff5 fix: address PR review — unique test titles and assertion between clicks
- Rename "Groceries" to "Focus Test List" to avoid title collision
- Add assertion (1 done) between consecutive checkbox clicks to prevent
  race condition where second click toggles the same item back

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:27:38 +01:00
Claude 6278f11184 fix: preserve local-only notes during loadNotes merge
mergeNotesByVersion only iterated incoming notes, dropping any
local-only notes (e.g. just created but not yet in the server/IDB
response). This caused note cards to disappear briefly after creation
when loadNotes raced with createNote.

Also rename E2E test note title to avoid name collision with existing
"Groceries" test.

https://claude.ai/code/session_01TdPpATUkmdY7DhBkmqrvWs
2026-03-21 20:27:38 +01:00
Claude bd3a14d518 chore: update openapi.json to match v0.24.0
https://claude.ai/code/session_01TdPpATUkmdY7DhBkmqrvWs
2026-03-21 20:27:38 +01:00
Claude ee017ab550 fix: prevent checklist state loss from sync race condition
Two issues caused checked items to revert after closing:

1. loadNotes() blindly overwrote the store with notes.set(), so a stale
   response from the periodic sync (every 30s) could replace a just-saved
   note with older server data. Now merges by version number, keeping the
   higher-versioned note.

2. Checklist component ignored external content prop changes (from sync or
   history restore). Added $effect that re-parses items when the content
   prop changes from an external source, using lastEmitted tracking to
   distinguish self-originated vs external updates.

https://claude.ai/code/session_01TdPpATUkmdY7DhBkmqrvWs
2026-03-21 20:27:38 +01:00
github-actions[bot] 65b7a9fe36 chore: pin docker-compose to v0.24.0 [skip ci] 2026-03-21 09:32:08 +00:00
github-actions[bot] a6cd5baadb chore: bump version to 0.24.0 2026-03-21 09:20:54 +00:00
Frederic CabassutandClaude Opus 4.6 f9d29a192a feat: arrow key navigation between checklist items
ArrowUp/ArrowDown move focus between active checklist inputs.
At list boundaries, focus stays on the current item (no wrap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:17:58 +01:00
Frederic CabassutandClaude Opus 4.6 dd100683c9 fix: rename test note to avoid name collision in CI
- "Shopping" matched existing "Shopping List" note from another test
- Renamed to "Groceries" to avoid strict mode violation
- Reworded comments: "interleaved" → "checked items before active ones"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:11:00 +01:00
Frederic CabassutandClaude Opus 4.6 e0eca4228b fix: checklist Enter key focuses new item on reopened notes
The addItem() focus logic used positional indexing (inputs[afterIndex+1])
but afterIndex came from the full items[] array while DOM inputs only
render active (unchecked) items. On reopened notes, checked items are
interleaved in the parsed content, causing the index offset to be wrong.

Fix: use data-item-id attribute for targeted querySelector lookup.
Test: reopened note with interleaved checked/unchecked items.

Introduced in e5688c9 (active/done split).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:11:00 +01:00
github-actions[bot] b33d3d3f72 chore: pin docker-compose to v0.23.0 [skip ci] 2026-03-21 07:59:33 +00:00
github-actions[bot] 264009570b chore: bump version to 0.23.0 2026-03-21 07:48:40 +00:00
Frederic CabassutandClaude Opus 4.6 4eb97b86f9 fix: address PR review comments
- Wrap readFile in try/catch, return 404 for missing attachment files
- Remove unused CollaboratorPopover import from NoteCard
- Make darkMode a proper $state rune for reactivity (add svelte-runes
  polyfill for Vitest so $state works in unit tests)
- Add error toasts for failed share link toggle (non-2xx responses)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 552c2b6265 fix: ensure dark mode E2E test creates a note before asserting colors
The "note cards use dark colors" test was conditional on notes existing,
making the assertion potentially vacuous. Now creates a note first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut 09c8e290a9 docs: add dark mode to features and architecture docs 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 7a27952713 fix: eliminate E2E dark mode test flakiness
- Use test.describe.serial to prevent parallel preference conflicts
- Set up waitForResponse listener BEFORE click (response may arrive
  before listener is registered)
- Add waitForLoadState('networkidle') after navigations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 7c19f4a30a chore: add .superpowers/ to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut e0aea4bc2b test: add E2E tests for dark mode toggle and persistence 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 5dfde02749 feat: use dark note card colors in all components
Switch NoteCard, NoteEditor, and ColorPicker from static NOTE_COLORS
lookups to getNoteColor(color, getIsDarkMode()), so inline background
styles react to the active theme. Also fix the theme preference button
type cast in settings/preferences to resolve the svelte-check error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut fa29321a62 feat: add theme toggle to preferences settings 2026-03-21 08:47:20 +01:00
Frederic Cabassut cdf242a692 feat: add reactive theme watcher in root layout 2026-03-21 08:47:20 +01:00
Frederic Cabassut d6638260a3 feat: add FOUC-prevention script for dark mode 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 b820ee3368 feat: add dark note card colors and getNoteColor helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 80d18478da feat: add theme utility with applyTheme and isDarkMode
Adds src/lib/utils/theme.svelte.ts with applyTheme() (sets data-theme
attribute and meta theme-color) and getIsDarkMode(). Uses a plain
variable instead of $state since Vitest's node environment does not
compile Svelte 5 runes in .svelte.ts files. Adds jsdom dev dependency
and $app/environment alias in vitest config to support DOM-level unit
tests; fixes preferences.test.ts to include the theme default added
in Task 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic Cabassut 35cd888547 feat: add theme preference type (system/light/dark) 2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Sonnet 4.6 73b07a3be6 feat: add dark theme CSS variables and grid-dot extraction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 57b9d89e2e docs: add dark mode implementation plan
10-task plan covering CSS variables, preferences, FOUC prevention,
reactive theme application, component updates, and testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 e9aafd4f92 docs: revise dark mode spec after review
Address reviewer feedback: null-check meta element, use var(--border-subtle)
for card-shadow, specify .svelte.ts for reactivity, add data-testid attrs,
clarify SSR guards, resolve (auth) layout via root layout, defer landing page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 9ea45b538c docs: add dark mode design spec
Design spec for issue #26 — dark parchment theme with system/light/dark
toggle in Settings > Preferences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 08:47:20 +01:00
Frederic CabassutandClaude Opus 4.6 9f78f130d3 docs: add grid screenshot to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:58:39 +01:00
github-actions[bot] b1d91490ee chore: update landing page screenshots [skip ci] 2026-03-20 21:57:22 +00:00
Frederic CabassutandClaude Opus 4.6 dfe791ea44 fix(screenshots): force exit after capture to kill orphaned server
pnpm spawns vite as a grandchild process that SIGTERM doesn't reach,
leaving the CI job hanging after screenshots are captured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:55:32 +01:00
Frederic CabassutandClaude Opus 4.6 54f0bb847c fix(screenshots): use HTTP polling for server readiness in CI
Replace stdout-based detection with HTTP polling (fetch + retry).
Stdout detection is unreliable in CI where pnpm buffers output.
Increased timeout to 60s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:49:11 +01:00
Frederic CabassutandClaude Opus 4.6 2d15e31bc4 chore: fix stale comment about pinned note
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 9c1d533dad chore(screenshots): remove unused pixel-art generator
No longer needed after removing image attachments from seed data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 79c6eeb4dd fix(screenshots): final content tweaks and regenerated screenshots
- Remove title from self-host manifesto note (content-only card)
- Regenerated all 9 screenshots on rebased main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 b50ee51f82 fix(screenshots): improve seed data content and remove attachments
- Remove image attachments and attachments screenshot
- Remove pinned note for cleaner hero grid
- Enrich note content with markdown (headings, bold, italic, blockquotes, lists)
- Replace "Ancient Flute" with "Coding Playlists" (ties to Navidrome)
- Shorten titles, remove content that repeats title
- Remove hashtags from checklist notes (rendered as items)
- Use bretzel persona "Salzig Bretzel" for collaborator
- Update services: Crumbs, 42, Dokploy, Outline, Navidrome, RetroAssembly, Umami

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 17f066d9b1 chore(screenshots): add generated landing page screenshots
10 screenshots captured via automated pipeline:
- 7 desktop (1280x800): grid, editor, checklist, sharing, history, attachments, API
- 3 mobile (390x844): grid, editor, checklist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 d38cb88624 feat(website): update landing page with new screenshot references
Replace 3 desktop + 3 mobile screenshots with 7 desktop + 3 mobile.
New screenshots: checklist, sharing, history, attachments, API/MCP.
Remove obsolete screenshot-tags.png and screenshot-mobile-sidebar.png.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 f28c5671c1 ci: add manual workflow to regenerate landing page screenshots
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 7fdfa305bd feat(screenshots): add main orchestrator and fix capture selectors
- Use @playwright/test instead of playwright (matches devDependencies)
- Use card-specific locators to avoid substring content matches
- Close history panel before editor to avoid overlay blocking
- Add Origin header for multipart upload CSRF compliance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 65eb7936ca feat(screenshots): add desktop and mobile capture logic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 cdac3186b8 feat(screenshots): add API-based seed data with bretzel universe content
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 0d6fb8d1da feat(screenshots): add constants and pixel-art bretzel generator
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:32:16 +01:00
Frederic CabassutandClaude Opus 4.6 92d0581186 fix: address PR review — sanitize attachments, SSR safety, a11y
- Strip server paths from public attachment responses (new PublicAttachment type)
- Use $page.url.origin instead of window.location.origin (SSR-safe)
- Fix toggle switch left-4.5 → left-[1.125rem] (valid Tailwind)
- Add aria-label to SharingIndicator button for screen readers
- Add opportunistic pruning to IP rate limiter to prevent unbounded growth
- Reduce shared attachment cache to 1 hour (respect share revocation)
- Update OpenAPI schema with PublicAttachment type
- Add unit test asserting server paths are not leaked

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 29eb77f9a2 fix: polish public sharing page and landing page
- Link "POWERED BY CRUMBS" to crumbs.bretzel.app
- Add dedicated error page for (share) group without "Back to notes"
- Update landing page Note Sharing card to mention public links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 1dfdbc12fe docs: add public sharing to features, architecture, and API docs
- FEATURES.md: add public link sharing feature details
- ARCHITECTURE.md: add shared_notes table to schema section
- openapi.yaml: add share create/revoke, public note, and public
  attachment endpoints with SharedNoteData schema
- Regenerate API.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 971963e0cc test: add unit and e2e tests for public note sharing
Unit tests:
- shares-service: create, idempotent create, revoke, get, batch fetch
- ip-rate-limit: under/over limit, IP independence, window reset

E2E tests:
- Create public share link via UI
- Public page renders without auth
- Revoke share returns 404
- Trashed note share returns 404
- Globe icon on shared card
- Copy link to clipboard

Also fix sharing-indicator test-id in existing sharing.spec.ts
and add shared_notes table to test-helpers and db/index.ts init.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 8a453e3b98 feat: add Globe icon for public shares, update NoteCard and NoteEditor
- Create SharingIndicator component with Globe/Users icon priority
- NoteCard shows Globe icon (gold) for public shares, Users for collabs
- NoteEditor share button reflects public link state
- Share token propagated through store on create/revoke

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 8be07091bd feat: add public link sharing section to ShareDialog
Add toggle switch, URL display, and copy button for public share
links above the existing collaborators section. Gold border when
active, retro-styled toggle with hard shadows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 5d2ad8b5e7 feat: add public shared note page with SSR rendering
- (share) layout group with minimal centered layout
- Server-side load function fetches shared note by token
- Read-only page renders markdown, checklists, and images
- Rate limited via IP-based limiter
- Retro parchment styling with "POWERED BY CRUMBS" footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 e18af0cc43 feat: add share API endpoints and in-memory rate limiting
- POST/DELETE /api/notes/[id]/share for creating/revoking public links
- GET /api/shared/[token] for fetching shared note data (no auth)
- GET /api/shared/[token]/attachment/[attachmentId] for public images
- Add /s/ and /api/shared to PUBLIC_PATHS in auth middleware
- Add IP-based rate limiter (60 req/min) for public endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 2798307ed1 feat: add shares service layer with batch fetch and hydration
Create shares-service.ts with fetchSharesForNotes, createShare,
revokeShare, getShareByNoteId, and getSharedNote. Integrate share
tokens into hydrateNotes pipeline alongside tags, attachments,
and collaborators.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 8da2de7703 feat: add shared_notes schema and NoteShare types
Add sharedNotes table with noteId primary key, unique token, and
cascade delete. Add NoteShare and SharedNoteData interfaces.
Extend Note type with optional shareToken field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:06:41 +01:00
Frederic CabassutandClaude Opus 4.6 f6dee3e2a9 fix: cap note card height to prevent tall checklist cards stretching rows
Add max-h-[17rem] overflow-hidden on note cards so checklist notes
don't force text notes in the same grid row to stretch with
truncated content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:56:48 +01:00
Frederic CabassutandClaude Opus 4.6 e7ab60d3cc docs: add design spec for public note sharing (#7)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:03:44 +01:00
Frederic CabassutandClaude Opus 4.6 050550e19a docs: add screenshot automation design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:02:20 +01:00
Frederic CabassutandClaude Opus 4.6 9b3d368eff fix: remove unknown/unknown platform entries from Docker manifests
Add provenance: false to docker/build-push-action to prevent BuildKit
from generating SLSA attestation entries that appear as unknown/unknown
in the image manifest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:24:23 +01:00
github-actions[bot] d462620a5e chore: pin docker-compose to v0.22.3 [skip ci] 2026-03-18 09:48:09 +00:00
github-actions[bot] 6ffc0f683f chore: bump version to 0.22.3 2026-03-18 09:37:31 +00:00
Frederic CabassutandClaude Opus 4.6 b80661237e fix: add linux/arm64 support to Docker image builds
Add QEMU emulation and multi-platform build config so ARM64 users
can pull the image without "no matching manifest" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:29:57 +01:00
github-actions[bot] 155bb15f1c chore: pin docker-compose to v0.22.2 [skip ci] 2026-03-17 22:44:48 +00:00
github-actions[bot] 4058b99680 chore: bump version to 0.22.2 2026-03-17 22:40:08 +00:00
Fred Cabassut f94ad8e479 Revert "fix: set ghcr.io package visibility to public after Docker push"
This reverts commit 49cc7e0317.
2026-03-17 23:37:16 +01:00
github-actions[bot] f4fbb05bfc chore: bump version to 0.22.1 2026-03-17 22:13:20 +00:00
Claude f2825aef59 fix: set ghcr.io package visibility to public after Docker push
Users pulling the image without authentication get "unauthorized" because
ghcr.io packages default to private under an organization. Add a step in
both release workflows to explicitly set the container package to public.

https://claude.ai/code/session_01QovQeW26KfEEmU1KdFfCPU
2026-03-17 23:12:59 +01:00
Frederic CabassutandClaude Opus 4.6 1d3e7bc159 fix: use npx tsx for openapi generation in build script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:58:42 +01:00
github-actions[bot] b52ff822dc chore: pin docker-compose to v0.22.0 [skip ci] 2026-03-12 23:05:13 +00:00
github-actions[bot] ba73ccde1c chore: bump version to 0.22.0 2026-03-12 23:00:57 +00:00
Frederic CabassutandClaude Opus 4.6 6973b0c24c feat: auto-generate discoverable OpenAPI spec from route tree
Adds a script that scans src/routes/api/ for exported HTTP methods and
generates an OpenAPI 3.1 spec at /api/openapi.json. Runs automatically
before each build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:59:52 +01:00
Frederic CabassutandClaude Opus 4.6 ed2cdaac6a chore: update about page wording to "bretzel.app universe"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:53:40 +01:00
github-actions[bot] 0a96faa5e3 chore: pin docker-compose to v0.21.9 [skip ci] 2026-03-12 22:31:07 +00:00
github-actions[bot] fb6ff18a16 chore: bump version to 0.21.9 2026-03-12 22:26:57 +00:00
Frederic CabassutandClaude Opus 4.6 de7780486f fix: replace emoji PWA icons with pixel-art pretzel from favicon.svg
The PWA manifest icons were a generic pretzel emoji on a dark background,
not matching the app's pixel-art pretzel favicon. Regenerated from the
favicon.svg source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:26:43 +01:00
github-actions[bot] e079a1e783 chore: pin docker-compose to v0.21.8 [skip ci] 2026-03-12 22:16:28 +00:00
github-actions[bot] 0329ee3052 chore: bump version to 0.21.8 2026-03-12 22:12:11 +00:00
Frederic CabassutandClaude Opus 4.6 f7652f2de3 fix: use absolute paths for service worker registration
SvelteKit sets Vite's base to './' for client builds, causing the PWA
plugin to generate relative SW paths (./sw.js). On sub-routes like
/settings or /tag/foo, this resolves to /settings/sw.js which returns
HTML, breaking SW registration. Force scope and buildBase to '/'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:11:56 +01:00
github-actions[bot] 46a17232c4 chore: pin docker-compose to v0.21.7 [skip ci] 2026-03-12 21:16:06 +00:00
github-actions[bot] 1a7e214675 chore: bump version to 0.21.7 2026-03-12 21:11:35 +00:00
Frederic CabassutandClaude Opus 4.6 84e99a3497 fix: remove maskable icon purpose to prevent double-cropping
The maskable purpose caused Android to crop the already-round icon
into its adaptive shape, resulting in a square-inside-circle appearance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:11:08 +01:00
github-actions[bot] d1c48e7799 chore: pin docker-compose to v0.21.6 [skip ci] 2026-03-12 20:13:13 +00:00
github-actions[bot] a30af4423c chore: bump version to 0.21.6 2026-03-12 20:08:34 +00:00
Frederic CabassutandClaude Opus 4.6 deca26931e fix: block service worker in e2e tests to prevent response interception
Active service workers change the fetch lifecycle enough that Playwright's
waitForResponse() misses SW-mediated responses, causing preferences tests
to timeout. Block SW registration during e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:08:20 +01:00
github-actions[bot] 335b01f60e chore: bump version to 0.21.5 2026-03-12 20:01:20 +00:00
Frederic CabassutandClaude Opus 4.6 ba1ddd4489 fix: restrict SW API caching to GET requests to avoid intercepting mutations
Workbox route matching intercepts all HTTP methods, which caused PUT/POST
requests (e.g. preferences updates) to be handled by the SW strategy
handler instead of passing through directly. Restrict API and attachment
cache rules to GET-only using function-based urlPattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:00:56 +01:00
github-actions[bot] 541f910564 chore: bump version to 0.21.4 2026-03-12 19:56:04 +00:00
Frederic CabassutandClaude Opus 4.6 27e59989b5 fix: register PWA service worker via virtual:pwa-register for offline support
@vite-pwa/sveltekit does not auto-inject SW registration into SSR HTML.
Add explicit registration via virtual:pwa-register in root layout onMount,
inject webmanifest link via virtual:pwa-info, disable SvelteKit's built-in
SW registration to avoid conflicts, and add required dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:53:38 +01:00
github-actions[bot] c949b40a20 chore: pin docker-compose to v0.21.3 [skip ci] 2026-03-12 19:24:11 +00:00
github-actions[bot] bbfcf72e74 chore: bump version to 0.21.3 2026-03-12 19:19:56 +00:00
copilot-swe-agent[bot]andfredicious d02af48b50 fix: snapshot pre-update state to enable 3-way merge on concurrent PATCH
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-12 20:19:10 +01:00
Claude 57a05bbee0 fix: resolve lost updates in multi-user collaboration sync
Concurrent edits to shared notes (e.g., one user checks a shopping list
item while another adds an item) were lost because the sync system used
whole-document Last-Write-Wins. This replaces it with a 3-way line-level
content merge using node-diff3, so changes to different lines are
preserved from both users.

Key changes:
- Add node-diff3 for 3-way line-level content merging
- Remove the timestamp gate in processSyncPush that rejected entire
  offline sync pushes when another user had updated the note
- Add content merge to both the sync push path and the PATCH endpoint,
  using version snapshots as the merge base
- Send baseVersion from the client so the server can find the correct
  common ancestor for 3-way merge
- Protect pending local fields during client pull merge by reading
  the sync queue and preserving queued-but-unpushed changes
- Add comprehensive tests for content merge, server sync, and CRDT merge

https://claude.ai/code/session_01FNpVBBjM6vKzfR8y9NM9Ci
2026-03-12 20:19:10 +01:00
Frederic CabassutandClaude Opus 4.6 0965da70b4 fix: enable offline page caching in service worker for PWA refresh support
Navigation requests had no caching rule, so refreshing offline showed the
browser's default "you are offline" page. Add NetworkFirst runtime caching
for navigations (3s timeout), cache Google Fonts for offline use, remove
conflicting static webmanifest in favor of the plugin-generated one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:16:23 +01:00
Claude 8475ec807e fix: make checklist checkboxes read-only in note card list view
Checking items directly from the card view caused glitches. Checkboxes
in the list preview are now disabled — users open the note to interact.

https://claude.ai/code/session_01HoLKBDNrHWHUVgTUbhSLE4
2026-03-12 18:56:16 +01:00
Frederic CabassutandClaude Opus 4.6 e240f57352 fix: resolve dependabot vulnerabilities — override hono, remove stale package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:49:57 +01:00
Frederic CabassutandClaude Opus 4.6 2c16ed5870 fix: delete all user-related records before removing user to prevent FK constraint error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:15:47 +01:00
Frederic CabassutandClaude Opus 4.6 6d503bbc8c docs: add SMTP config to README, document recent features, expose all env vars in docker-compose
- Add SMTP configuration example to README install section
- Add note version history, sharing, and email notifications to README feature list
- Expand FEATURES.md with all 9 email notification types and version history section
- Add note sharing, version history, and email alerts feature cards to landing page
- Add all OAuth/SSO env vars to docker-compose.yml with empty defaults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:49:55 +01:00
github-actions[bot] d2e4a0a4b4 chore: pin docker-compose to v0.21.2 [skip ci] 2026-03-12 09:32:50 +00:00
github-actions[bot] 30ec9856c2 chore: bump version to 0.21.2 2026-03-12 09:28:39 +00:00
Frederic CabassutandClaude Opus 4.6 c24b0f7d86 fix: prevent ghost click tap-through on mobile editor toolbar
On mobile, tapping a note card could trigger action buttons in the
editor that rendered at the same coordinates (ghost click). Suppress
pointer events on both toolbars for 150ms after mount — matching the
pop-in animation — so the synthetic click passes harmlessly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:10:47 +01:00
github-actions[bot] 00c9fd43e8 chore: pin docker-compose to v0.21.1 [skip ci] 2026-03-12 08:49:35 +00:00
github-actions[bot] f27489463c chore: bump version to 0.21.1 2026-03-12 08:45:06 +00:00
Frederic CabassutandClaude Opus 4.6 990639d804 fix: resolve flaky offline sync e2e test in CI
Playwright's setOffline(false) doesn't reliably emit the browser's
'online' DOM event in headless/CI environments. Manually dispatch it
as a safety net so the sync client always triggers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:31:11 +01:00
Claude 8108e62cf1 fix: remove stopPropagation on editor card that blocked toolbar dropdown close
The editor card's onpointerdown stopPropagation prevented the
FormattingToolbar's document-level pointerdown listener from
detecting clicks outside the dropdown. Since both overlay handlers
already use e.target === e.currentTarget to ignore bubbled events,
no stopPropagation is needed on the card.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
Claude b0a3a1fcb8 fix: use pointer events for backdrop close instead of timeout guard
Pointer events are never synthetically re-dispatched from touch
(unlike mouse events), so the tap that opened the editor cannot
ghost-click the overlay. This eliminates the mobile first-load bug
without relying on an arbitrary delay.

Both pointerdown and pointerup must target the overlay itself to
trigger close, which also handles the text-selection-outside-editor
edge case.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
Claude c411975bb9 fix: use timestamp guard instead of rAF for mobile backdrop click
On first page load on mobile, the synthetic mousedown from a tap can
arrive ~300ms after the touch event — well after a single
requestAnimationFrame (~16ms). Replace the rAF-based overlayReady
guard with a 400ms timestamp check from mount time to reliably
ignore stale synthetic events on slow first loads.

https://claude.ai/code/session_018DYwSysqysMmaRi5XkqTpo
2026-03-12 09:30:47 +01:00
github-actions[bot] e08a4883f1 chore: pin docker-compose to v0.21.0 [skip ci] 2026-03-11 20:22:43 +00:00
github-actions[bot] 0cf34e631b chore: bump version to 0.21.0 2026-03-11 20:18:05 +00:00
copilot-swe-agent[bot]andfredicious 9f8fe85c44 fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 5ac7de7f78 chore: exclude package-lock.json from git (project uses pnpm)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 5f6149e282 feat: add email notifications for user, account, and collaboration events
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:17:28 +01:00
copilot-swe-agent[bot]andfredicious 633727b58e fix: only create version snapshot when content actually changes
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 21:02:00 +01:00
github-actions[bot] 3ab7c1fb53 chore: pin docker-compose to v0.20.0 [skip ci] 2026-03-11 19:45:32 +00:00
github-actions[bot] d8c5a41fed chore: bump version to 0.20.0 2026-03-11 19:41:24 +00:00
Claude 0b1416908e feat: add email notifications when a note is shared
Send an email to collaborators when they are added to a shared note.
Uses nodemailer with optional SMTP configuration (auto-enabled when
SMTP_HOST is set). Users can opt out via the notifyOnShare preference.

https://claude.ai/code/session_01P4HSMr7QvKw9TMNikUJD9e
2026-03-11 20:33:48 +01:00
copilot-swe-agent[bot]andfredicious 07ea482bad fix: address code review feedback on NoteHistory component
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
copilot-swe-agent[bot]andfredicious 0b34654736 feat: add note version history and restore (#note-history)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
2026-03-11 20:28:36 +01:00
Claude 8ed915bc69 fix: add vertical padding to checklist add-item button
https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
Claude 917aef1865 fix: increase checklist item padding and fix text overflow in editor
Add more vertical padding (py-1 → py-1.5) to checklist items for better
readability. Add min-w-0 to the text input so long text is constrained
within the flex container instead of overflowing out of the note.

https://claude.ai/code/session_0163L1u9MfL1C6Rv3yAw8RKs
2026-03-11 20:20:23 +01:00
github-actions[bot] e8eec85d57 chore: pin docker-compose to v0.19.3 [skip ci] 2026-03-11 19:00:59 +00:00
github-actions[bot] b36326e65a chore: bump version to 0.19.3 2026-03-11 18:56:19 +00:00
Claude 26eff09d7d fix: resolve race condition in offline sync e2e test
Set up waitForResponse before calling setOffline(false) so the sync
response listener is registered before the online event fires.

https://claude.ai/code/session_014yTnHzjkCJLBcYcPTSLVkE
2026-03-11 19:53:47 +01:00
Claude 53aa32e07f fix: improve checklist note card text wrapping, spacing, and padding
- Replace `truncate` with `break-words min-w-0` so long checklist text
  wraps visibly instead of being clipped
- Increase vertical spacing between items (space-y-1 → space-y-2)
- Add bottom margin (mb-6) to prevent action buttons overlapping content
- Align checkboxes to top of multi-line items (items-start + mt-0.5)

https://claude.ai/code/session_01UVNA5wucVEjRQzwRxMhTZ9
2026-03-11 19:43:13 +01:00
Claude 51bfda3aec fix: prevent modal backdrop from catching the originating tap on mobile
On mobile, tapping a NoteCard to open the editor produces synthetic mouse
events (mousedown → mouseup → click) at the original touch coordinates.
If those coordinates land on the overlay backdrop rather than the editor
card, the mousedown sets mousedownOnOverlay = true and the click
immediately closes the editor — causing a brief flicker.

Guard against this by deferring overlay readiness to the next animation
frame after mount, so any synthetic events from the originating tap are
ignored.

https://claude.ai/code/session_011C8bfgShjDSmDr3rUCb2gw
2026-03-11 19:24:06 +01:00
github-actions[bot] 617c121221 chore: pin docker-compose to v0.19.2 [skip ci] 2026-03-10 22:47:34 +00:00
github-actions[bot] 5a9c6d2a36 chore: bump version to 0.19.2 2026-03-10 22:43:26 +00:00
Frederic CabassutandClaude Opus 4.6 7c2dad47cc ci: use Playwright container image instead of downloading browsers
Run E2E tests inside mcr.microsoft.com/playwright container which has
Chromium pre-installed, eliminating the ~30s browser download on each run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 370b0bc815 fix: eliminate e2e test flakiness in settings preference and API key tests
Three sources of flakiness fixed:

1. **Preferences sync race** — page.goto() triggers a full reload which
   re-runs initPreferences() + syncPreferencesFromServer(). On slow CI,
   the async server sync can return stale data from parallel test workers
   that share the same user's server-side preferences. Fixed with
   gotoWithStablePrefs() which blocks the one GET /api/preferences sync
   request, so initPreferences() falls back to localStorage (always correct).

2. **localStorage not reset** — resetPreferences() only reset the server.
   After a full page reload, loadFromLocalStorage() could return stale
   values from previous test runs. Now resets both server and localStorage.

3. **API key name collision** — parallel test runs created keys with the
   same name ("Settings Test Key"), causing Playwright strict-mode
   violations when multiple elements matched. Fixed with Date.now()
   unique suffixes.

Verified: 167/167 settings tests pass (20x repeat), 173/173 full suite (5x).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
Frederic CabassutandClaude Opus 4.6 fc391d675c docs: add E2E BDD testing best practices and improve test suite
- Add docs/TESTING.md with 10 BDD testing best practices
- Add createNote() helper to fixtures to DRY up state setup
- Add createChecklistNote() helper in checklist tests
- Rename scenario titles to describe outcomes, not actions
- Replace imperative Given steps with createNote() helper
- Fix fragile conditional in tag filtering test (silent pass)
- Remove implementation details from Gherkin comments
- Improve Given/When/Then rhythm consistency across all tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:39:29 +01:00
github-actions[bot] 2995479b1d chore: pin docker-compose to v0.19.0 [skip ci] 2026-03-10 16:07:11 +00:00
github-actions[bot] 924493c8bd chore: bump version to 0.19.1 2026-03-10 15:16:11 +00:00
Frederic Cabassut c3c41db915 fix: isolate preference e2e tests with API reset before each test
Each preference test now resets to default preferences via API at the
start, ensuring parallel tests don't pollute each other's state.
Also await PUT response before reload in persist test to prevent
race condition.
2026-03-10 16:13:12 +01:00
github-actions[bot] 76431b7bc3 chore: bump version to 0.19.0 2026-03-10 14:55:19 +00:00
Frederic CabassutandClaude Opus 4.6 871d17e55f feat: add About settings page with version and project info
Add /settings/about page showing version, project description,
Bretzel ecosystem info, open source details, and tech stack.
Move version display from sidebar to About page and align
Settings nav item styling with other sidebar items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:54:40 +01:00
Frederic Cabassut fa60c65337 fix: prevent e2e test pollution from preferences shared state
Settings tests set defaultNoteMode to 'markdown' without resetting,
causing parallel sharing test to timeout when TiptapEditor wasn't
rendered (textarea shown instead). Two fixes:
- Reset preferences in settings tests after changing them
- Create note via API in sharing test (tests sharing, not note creation)
2026-03-10 15:54:40 +01:00
github-actions[bot] 0a6bb051bc chore: bump version to 0.18.0 2026-03-10 14:11:18 +00:00
Frederic CabassutandClaude Opus 4.6 02a6a3e6c6 fix: redirect /settings to /settings/preferences
Move Profile page to /settings/profile and add a 302 redirect from
/settings to /settings/preferences so the first settings tab is
Preferences, not Profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:10:31 +01:00
Frederic CabassutandClaude Opus 4.6 93a632e78a feat: add user preferences system
Implement 4 user preferences (default note mode, default note color,
hide footer, sidebar default state) with offline-first localStorage
backed by server-side key-value storage.

- Add user_preferences table (EAV pattern with unique user_id+key index)
- Create server service with get/upsert operations
- Add GET/PUT /api/preferences endpoints
- Build Svelte 5 runes-based client store (.svelte.ts)
- Wire preferences into app layout (footer toggle, sidebar state)
- Wire default note mode/color into NoteEditor for new notes
- Add Preferences tab as first item in Settings navigation
- Add 7 unit tests and 4 E2E tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:07:19 +01:00
Frederic CabassutandClaude Opus 4.6 664db9eb8d chore: remove plan docs from repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:54:20 +01:00
Frederic CabassutandClaude Opus 4.6 e6cfda471a docs: add user preferences implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:33:44 +01:00
Frederic CabassutandClaude Opus 4.6 9de57da79d docs: add user preferences design document
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:25:54 +01:00
Frederic CabassutandClaude Opus 4.6 8d5f27cd12 fix: prevent note editor from closing when text selection ends outside overlay
Track mousedown origin so click-outside-to-close only fires when both
mousedown and mouseup occur on the overlay backdrop, not when dragging a
text selection from inside the editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:16:51 +01:00
Frederic CabassutandClaude Opus 4.6 ad6068b3ad test: consolidate E2E tests — remove duplicates, fix timeouts, parallelize sharing
- Remove duplicate bold formatting test from notes-crud.spec.ts
- Trim formatting.spec.ts from 9 to 5 tests (remove italic, inline code,
  toolbar bold, toolbar visibility — same mechanism as bold shortcut)
- Remove hardcoded waitForTimeout(500) from search.spec.ts (Playwright
  assertions already retry with built-in timeouts)
- Restructure sharing.spec.ts: 9 serial tests → 5 parallel self-contained
  tests using collabPage fixture and API-based setup
- Add collabPage fixture to helpers/fixtures.ts for sharing tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
Frederic CabassutandClaude Opus 4.6 e1484b0af9 test: add 52 unit tests for notes-service, api-utils, rate-limit, tags, collaborators
- notes-service.test.ts (24 tests): listNotes, getNote, createNote, updateNote,
  deleteNote, searchNotes, reorderNotes with owner/collaborator patterns
- api-utils.test.ts (10 tests): canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId
- rate-limit.test.ts (8 tests): checkRateLimit, recordLoginAttempt, clearOldAttempts
- tags.test.ts (6 tests): syncNoteTags, fetchTagsForNotes
- collaborators.test.ts (4 tests): fetchCollaboratorsForNotes, addCollaborator, removeCollaborator

Also fixes test-helpers.ts missing thumbnail_path/featured columns
and rate-limit.ts raw SQL Date binding issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
Frederic CabassutandClaude Opus 4.6 8795a69b48 refactor: add mandatory db parameter to all service functions for testability
Make db a required first parameter on all service functions (notes-service,
api-utils, tags, collaborators, attachments, rate-limit, sync/server).
This enables unit testing with in-memory databases via createTestDb().

Also removes the NODE_ENV=test bypass from rate-limit and adds optional
now parameter for time control in tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:14:09 +01:00
github-actions[bot] 79f1e383c3 chore: pin docker-compose to v0.17.0 [skip ci] 2026-03-10 08:57:49 +00:00
github-actions[bot] 1809020501 chore: bump version to 0.17.0 2026-03-10 08:53:40 +00:00
Frederic CabassutandClaude Opus 4.6 bd4016c998 feat: add coverage summary to GitHub Actions job summary
Write a markdown coverage table to $GITHUB_STEP_SUMMARY for both unit
and E2E test jobs. Uses a small bash script that parses json-summary
output from the coverage reporters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:08 +01:00
Frederic CabassutandClaude Opus 4.6 4ef5dbf20b feat: add code coverage with artifact upload for unit and E2E tests
Add V8 coverage for unit tests via @vitest/coverage-v8 and Istanbul
instrumentation for E2E tests via vite-plugin-istanbul. Coverage data
is collected from browser pages after each Playwright test and merged
with nyc. CI uploads both coverage reports as GitHub Actions artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:08 +01:00
Frederic CabassutandClaude Opus 4.6 00c748a17b chore: set e2e retries to 0 — fix flaky tests, don't mask them
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:50:33 +01:00
Frederic CabassutandClaude Opus 4.6 f63cb6db1d perf: speed up e2e tests in CI
- Pre-build before Playwright instead of building inside webServer
- Reduce retries from 2 to 1 (fix flaky tests instead of masking them)
- Set CI workers to 4 for more parallelism
- Reduce artifact retention from 30 to 7 days

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:46:22 +01:00
github-actions[bot] 329061fe7e chore: pin docker-compose to v0.16.1 [skip ci] 2026-03-10 06:40:49 +00:00
github-actions[bot] c638dce753 chore: bump version to 0.16.1 2026-03-10 06:36:44 +00:00
Frederic CabassutandClaude Opus 4.6 7918c1ecc2 fix: update e2e test for renamed API settings tab
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:36:03 +01:00
github-actions[bot] 5a32563e3b chore: bump version to 0.16.0 2026-03-09 23:11:31 +00:00
Frederic CabassutandClaude Opus 4.6 1eb10b41bb chore: rename Client Configuration to MCP Client Configuration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
Frederic CabassutandClaude Opus 4.6 d6eb6d3ad2 feat: extend API key auth to all API endpoints
Bearer token authentication now works on all /api/* routes, not just
/api/mcp. If a Bearer token is present it's validated first; otherwise
session cookie auth is used as fallback. Updates settings UI to reflect
the broader scope (tab renamed to "API", REST API curl example added)
and updates the landing page feature card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:10:45 +01:00
github-actions[bot] f1e18ab968 chore: pin docker-compose to v0.15.0 [skip ci] 2026-03-09 23:02:47 +00:00
github-actions[bot] 707b80ad19 chore: bump version to 0.15.0 2026-03-09 22:57:53 +00:00
Frederic CabassutandClaude Opus 4.6 b42dedfd53 feat: add task list button to rich text editor toolbar
Register TaskList and TaskItem TipTap extensions, add "Task list" option
to the List dropdown in the formatting toolbar, and consolidate task list
CSS between the editor and markdown-it preview into shared styles in
app.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:57:29 +01:00
github-actions[bot] fcbdbd58f8 chore: pin docker-compose to v0.14.0 [skip ci] 2026-03-09 22:42:53 +00:00
github-actions[bot] 103c24c975 chore: bump version to 0.14.0 2026-03-09 22:38:22 +00:00
Frederic CabassutandClaude Opus 4.6 2c9ba1f03d fix: toolbar buttons now reactively show active formatting state
All isActive() and getAttributes() calls in the template were not
reading the tick prop, so Svelte 5 never re-evaluated them when the
editor state changed. Extracted isActive() and getAttrs() helpers
that consume tick to establish the reactive dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:37:38 +01:00
Frederic CabassutandClaude Opus 4.6 a069a2df2b refactor: make toolbar wrap instead of scroll, fix overflow
Use flex-wrap instead of overflow-x-auto for the formatting toolbar —
scrolling clips absolutely-positioned dropdown popovers (CSS forces
both axes to auto). All buttons are now always visible, wrapping to a
second line on narrow screens. Remove the More dropdown entirely since
all items are top-level. Add overflow-hidden to the editor card to
prevent horizontal overflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:30:21 +01:00
Frederic CabassutandClaude Opus 4.6 2e302463fb refactor: promote all formatting buttons to top-level on desktop
Move undo/redo to the start of the toolbar, promote inline code,
blockquote, code block, and divider to top-level desktop buttons.
The More dropdown is now mobile-only since all items are directly
accessible on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:24:08 +01:00
Frederic CabassutandClaude Opus 4.6 00b2d54eac refactor: expand toolbar on desktop with alignment dropdown
- Widen editor modal on desktop (max-w-xl → md:max-w-2xl)
- Extract text alignment from More into its own dropdown with contextual
  icon reflecting the active alignment
- Promote undo/redo to top-level buttons on desktop (hidden on mobile,
  still available in More dropdown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:22:01 +01:00
Frederic CabassutandClaude Opus 4.6 dbe040322c feat: add row/column management to table toolbar
Replace single "Insert table" button with a Table dropdown that shows
contextual row/column operations when the cursor is inside a table:
add row, add column, delete row, delete column, delete table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:19:14 +01:00
Frederic CabassutandClaude Opus 4.6 c1e2cf3c8b feat: add table support to TipTap editor
Install @tiptap/extension-table packages, register Table/TableRow/
TableHeader/TableCell extensions, add Table button to the More dropdown
(inserts a 3×3 grid), and style tables to match the parchment theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:15:17 +01:00
Frederic CabassutandClaude Opus 4.6 3df2630fc2 docs: remove tables from FEATURES.md — not implemented
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:05:27 +01:00
github-actions[bot] f45406a76b chore: pin docker-compose to v0.13.1 [skip ci] 2026-03-09 21:47:39 +00:00
github-actions[bot] 435b5a74ff chore: bump version to 0.13.1 2026-03-09 21:43:19 +00:00
Frederic CabassutandClaude Opus 4.6 8b07e41132 fix: anchor collaborator popover to right edge to prevent overflow
The popover is inside a top-right positioned indicator, so anchoring
to right-0 keeps it on screen on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:39:32 +01:00
github-actions[bot] 5b32a46ca8 chore: pin docker-compose to v0.13.0 [skip ci] 2026-03-09 21:36:34 +00:00
github-actions[bot] 35c4206d75 chore: bump version to 0.13.0 2026-03-09 21:32:25 +00:00
Frederic CabassutandClaude Opus 4.6 da14b7678b feat: pressing Enter in note title moves cursor to content
Prevents default newline and focuses the content area (textarea or
TipTap editor) when pressing Enter in the title input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:29:44 +01:00
Frederic CabassutandClaude Opus 4.6 b3b24b2012 fix: remove tag chips from note cards in list view
Tags are still visible as hashtags in the note content and usable
via the tag filter bar. Reduces card clutter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:28:47 +01:00
Frederic CabassutandClaude Opus 4.6 e3568670eb fix: increase touch drag delay to 400ms to prevent scroll conflicts
The default 80ms delay was too short, causing drag to activate
during normal scrolling on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:15:05 +01:00
github-actions[bot] 904c1f5385 chore: pin docker-compose to v0.12.1 [skip ci] 2026-03-09 21:11:35 +00:00
github-actions[bot] 93b6ffee2a chore: bump version to 0.12.1 2026-03-09 21:07:31 +00:00
Frederic CabassutandClaude Opus 4.6 e70e404061 test: update sorting tests for hybrid sort order
Remove tests for sort selector UI (removed). Replace with a single
test verifying most recently updated note appears first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:06:39 +01:00
github-actions[bot] 1f44aaaa95 chore: bump version to 0.12.0 2026-03-09 20:58:34 +00:00
Frederic CabassutandClaude Opus 4.6 82988bebc2 fix: remove focus outline from note cards during drag
Add outline-none to both the NoteCard article and the NoteGrid
wrapper div to prevent browser focus outlines when dragging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 10f15c0cde refactor: remove drag handles, use long-press to reorder notes
Replace drag handle zones with dndzone for whole-card dragging via
long-press on both desktop and mobile. Removes GripHorizontal icon
and dragHandle imports entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 e2d436deaf refactor: replace sort selector with hybrid sort order
Remove the sort mode selector UI. Notes now sort by custom order
first (drag to reorder), falling back to most-recently-updated for
ties. Drag handles are always available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 3303f96b0a fix: stack tag filter and sort buttons vertically on mobile
Tags get full width for horizontal scrolling, sort buttons sit
below aligned to the right. Side by side on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 e7aff8c1ae fix: hide scrollbar on tag filter horizontal scroll
Use scrollbar-width: none and ::-webkit-scrollbar to visually hide
the scrollbar while keeping swipe-to-scroll functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 02f1a8d67d fix: prevent tag chips from wrapping text across lines
Add whitespace-nowrap to TagChip so long tags stay on a single line
within the horizontally scrollable filter bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 a61e655f2b fix: reload notes when navigating to archive or trash views
Call loadNotes(filter) when the filter prop changes so the store
gets populated with the correct note subset for each view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 5dca88ebe1 feat: click sync indicator to trigger manual sync
Change sync indicator from div to button that calls sync() on click,
with hover feedback and updated tooltip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 b5033a49a9 fix: make tag filter horizontally scrollable on mobile
Keep tags on a single line with horizontal scroll on small screens,
wrap normally on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 c824514821 feat: add floating action button for creating notes on mobile
Show a fixed FAB at bottom-right on mobile instead of the inline
button that scrolls out of view. Desktop keeps the inline button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 fe7f101557 fix: prevent settings page elements from overflowing on mobile
Stack MCP key form, API key items, user list rows, and delete
account confirmation vertically on small screens using
flex-col/sm:flex-row. Add truncate on user names/emails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
Frederic CabassutandClaude Opus 4.6 e55cbdf5c9 fix: prevent drag handle from overlapping note card title
Position drag handle as a centered horizontal grip at the top edge
of the card, out of the way of title and content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:56:42 +01:00
github-actions[bot] 7816c66d4c chore: pin docker-compose to v0.11.1 [skip ci] 2026-03-09 20:17:06 +00:00
github-actions[bot] 7f2c7410c6 chore: bump version to 0.11.1 2026-03-09 20:12:19 +00:00
Frederic CabassutandClaude Opus 4.6 da7feea8e5 fix: sync endpoint returns per-user state overlay for shared notes
getChangesSince was returning the owner's pinned/archived/sortOrder
for shared notes instead of the collaborator's per-user values from
noteUserState. Also filtered trashed notes from collaborator sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:06:04 +01:00
github-actions[bot] d7b9cc44a9 chore: pin docker-compose to v0.11.0 [skip ci] 2026-03-09 11:18:27 +00:00
github-actions[bot] 27391bb179 chore: bump version to 0.11.0 2026-03-09 11:12:31 +00:00
Frederic CabassutandClaude Opus 4.6 a66256495e docs: add collaboration to FEATURES, ARCHITECTURE, and OpenAPI spec
- Add note sharing section to FEATURES.md
- Document collaboration access control model in ARCHITECTURE.md
- Add collaborators and user search endpoints to openapi.yaml
- Regenerate API.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 51981e0863 test: add e2e tests for note sharing workflow
Serial test suite covering: owner shares note, collaborator sees it,
collaborator edits content, independent pin state, leave action shown
for non-owners, collaborator leaves and note disappears.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 9390d9851f test: add unit tests for collaboration schema tables
Test noteCollaborators and noteUserState tables: CRUD, unique constraints,
defaults, and independent per-user state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 1811548207 feat: add sharing UI components and integrate into NoteCard/NoteEditor
- Create ShareDialog with user search autocomplete, collaborator list, and remove
- Create CollaboratorPopover for hover/click indicator on shared notes
- Add collaborator indicator to NoteCard (always visible Users icon)
- Replace trash with Leave action for non-owner shared notes
- Add share button to NoteEditor toolbar (UserPlus → Users when shared)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 7ef4e00c4f feat: widen notes service and sync for shared notes
- Refactor notes-service to include shared notes in list/get/search/reorder
- Route per-user fields (pinned/archived/sortOrder) to noteUserState for collaborators
- Update API routes to delegate to service layer with access control
- Update sync server to handle shared note updates bidirectionally
- Add leaveNote() to client store
- Refactor search and notes API routes to use service layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 a612a16b81 feat: add note collaboration schema, access control, and API routes
Add noteCollaborators and noteUserState tables for sharing notes between
users. Implement access control helpers (canAccessNote, requireNoteAccess,
requireNoteOwnership) and collaborators CRUD service. Create API endpoints
for managing collaborators and searching users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:12:01 +01:00
Frederic CabassutandClaude Opus 4.6 a1ae3a0069 docs: remove Google Keep references from website and docs
Replace brand-specific "Google Keep alternative" copy with generic
privacy-focused messaging across meta tags, hero subtitle, README,
and feature docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:08:42 +01:00
Frederic CabassutandClaude Opus 4.6 6c20699e2b fix: use apple-touch-icon as og:image for link previews
Platforms like WhatsApp require an explicit og:image to display
anything in link previews. Point it to the existing apple-touch-icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:04:34 +01:00
Frederic CabassutandClaude Opus 4.6 8afa4bd66d fix: remove OG image and let apple-touch-icon handle link previews
The apple-touch-icon already provides the pretzel logo for link
previews (like the app does). Remove the redundant OG image and switch
twitter:card to "summary" (icon-sized) instead of "summary_large_image".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:03:08 +01:00
Frederic CabassutandClaude Opus 4.6 687eb1c52d fix: simplify OG image to just the pretzel logo on parchment
Title and description are already shown in the link preview text,
so the image doesn't need to repeat them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:02:15 +01:00
Frederic CabassutandClaude Opus 4.6 52fb97c347 fix: add apple-touch-icon to website for link preview icon
Messaging apps (WhatsApp, iMessage, etc.) use the apple-touch-icon to
display a site icon in link previews. Copy the existing icon from the
app and add the link tag to the landing page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:58:19 +01:00
Frederic CabassutandClaude Opus 4.6 2ef6830b26 feat: add OG image and Twitter Card meta tags for link previews
The og-image.png was referenced in meta tags but never created. Generate
a 1200x630 parchment-themed OG image and add missing twitter:title,
twitter:description, and twitter:image meta tags for proper social
media link previews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:54:47 +01:00
github-actions[bot] 56bbd07114 chore: pin docker-compose to v0.10.1 [skip ci] 2026-03-09 10:52:33 +00:00
github-actions[bot] 9c2028e187 chore: bump version to 0.10.1 2026-03-09 10:48:47 +00:00
Frederic CabassutandClaude Opus 4.6 cb9857324a fix: migrate password_hash column to nullable for OAuth-only users
Existing databases created from the original migration had password_hash
as NOT NULL. This adds an auto-migration that recreates the users table
with a nullable password_hash column, enabling OAuth-only user creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:47:48 +01:00
Frederic CabassutandClaude Opus 4.6 e90c9c041c docs: use Authentik OIDC example in README instead of Google OAuth
Self-hosted auth provider is more relevant for a self-hosted app.
Also adds AUTH.md to the documentation table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:42:25 +01:00
github-actions[bot] d9b3bd9ff6 chore: pin docker-compose to v0.10.0 [skip ci] 2026-03-09 10:42:14 +00:00
github-actions[bot] 8ec278c49a chore: bump version to 0.10.0 2026-03-09 10:37:11 +00:00
Frederic CabassutandClaude Opus 4.6 4a280b5932 feat: allow creating OAuth-only users without a password
Admin can now toggle "OAuth-only (no password)" when creating a user.
These users have no password hash and authProvider set to 'none', so
they can only log in via an OAuth/SSO provider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:12 +01:00
Frederic CabassutandClaude Opus 4.6 7297dc0616 docs: add dedicated AUTH.md and deduplicate auth info across docs
Create AUTH.md as the canonical reference for authentication: password
auth, OAuth/SSO setup, provider-specific guides (Authentik, Keycloak,
Okta/Auth0), env vars, callback URLs, security notes, and
troubleshooting. Fixes incorrect env var names and callback URL patterns
that had drifted in DEPLOYMENT.md. Other docs now cross-link to AUTH.md
instead of duplicating content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:12 +01:00
github-actions[bot] 4e2e0353df chore: pin docker-compose to v0.9.1 [skip ci] 2026-03-09 10:04:53 +00:00
github-actions[bot] fc294f7090 chore: bump version to 0.9.1 2026-03-09 10:00:54 +00:00
Frederic CabassutandClaude Opus 4.6 29a3988e2c fix: use correct MCP server type in settings page config
Change "type": "streamable-http" to "type": "http" to match Claude
Code's actual MCP schema. Also clarify that the config targets
.claude/settings.json specifically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:47:25 +01:00
Frederic CabassutandClaude Opus 4.6 6957bdc64b perf: lazy-load zxcvbn password library to reduce initial bundle size
- Dynamic import zxcvbn (820KB) in PasswordStrengthMeter so it only
  loads when user starts typing a password
- Raise chunkSizeWarningLimit to 850KB for the lazy-loaded chunk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:42:27 +01:00
Frederic CabassutandClaude Opus 4.6 e09c2a9c02 fix: resolve all svelte-check warnings (a11y + state_referenced_locally)
- Add svelte-ignore for intentional state_referenced_locally patterns
- Wrap clickable images in button elements for keyboard accessibility
- Add proper ARIA roles (dialog, textbox) with tabindex and focus management
- Fix lightbox Escape key handling to not close parent editor overlay

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:37:07 +01:00
github-actions[bot] 1f0bbd570e chore: pin docker-compose to v0.9.0 [skip ci] 2026-03-09 01:36:11 +00:00
github-actions[bot] a98efc0024 chore: bump version to 0.9.0 2026-03-09 01:32:03 +00:00
Frederic CabassutandClaude Opus 4.6 8966fcddf6 fix: resolve type errors in notes-service, MCP server, and test helpers
- Add missing userId arg to syncNoteTags() calls in notes-service
- Add seedUser option to createTestDb() for test setup
- Add explicit type annotations to MCP server.tool() callbacks (zod v4)
- Fix ambiguous getByText selector in settings e2e test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:31:18 +01:00
Frederic CabassutandClaude Opus 4.6 908bcf4d96 docs: update CLAUDE.md to reflect multi-user auth and current project structure
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:56 +01:00
Frederic CabassutandClaude Opus 4.6 b3f25457fe fix: clarify multi-user feature description on landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:11 +01:00
Frederic CabassutandClaude Opus 4.6 cfa1693d86 feat: add MCP, multi-user, and OAuth features to landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:11 +01:00
Frederic CabassutandClaude Opus 4.6 629afa1c8d docs: add OAuth/auth docs and install instructions in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:20:07 +01:00
Frederic CabassutandClaude Opus 4.6 27b167edb1 feat: admin can revoke user sessions (force logout)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:20:03 +01:00
Frederic CabassutandClaude Opus 4.6 9d67e7b9d1 feat: URL-based routing for archive, trash, and tags
Add /archive, /trash, /tag/[name] routes with proper <a> links
in the sidebar. Tags visible regardless of current filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:55 +01:00
Frederic CabassutandClaude Opus 4.6 4d5ea35204 feat: add logout button and delete account to settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:47 +01:00
Frederic CabassutandClaude Opus 4.6 7255169da8 refactor: remove user avatar dropdown from header
Logout and Settings access moved to sidebar and settings page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:19:43 +01:00
Frederic CabassutandClaude Opus 4.6 fe88b15a0d docs: add MCP server and API key documentation
- Update FEATURES.md, ARCHITECTURE.md, DEPLOYMENT.md with MCP sections
- Add MCP endpoints to OpenAPI spec and regenerate API.md
- Add rebase-before-PR best practice to CLAUDE.md git conventions
- Update README with MCP/API feature mention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 f327e8dcb2 test: add E2E tests for MCP endpoint and Settings page
- MCP tests: initialize, create/get note, search, auth rejection
- Settings tests: create key, revoke key, sidebar nav, subpage nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 d4ca635346 feat: add Settings subpages with API key management UI
- Settings layout with sidebar nav (Profile, MCP Server, Users)
- Profile page: display name, email, password, sessions
- MCP page: API key CRUD, JSON config with copy button, LLM prompt
- Config auto-populates with actual API key when one is created
- Remove duplicate Settings link from sidebar
- Users page integrated into settings nav (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 e1426c1ef6 feat: add MCP server with 14 tools and shared notes service
- Shared notes-service.ts with userId-scoped queries for DRY
- McpServer with tools: list/get/create/update/trash/restore/
  archive/unarchive/delete notes, search, list tags, pin,
  reorder, upload image
- Streamable HTTP endpoint at POST /api/mcp
- Session management with GET/DELETE handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 a82ba8f61d feat: add API key CRUD and Bearer token auth for MCP
- API key generation with SHA-256 hashing and crumbs_ prefix
- User-scoped key validation, listing, and deletion
- Bearer token auth in hooks.server.ts for /api/mcp paths
- Populates event.locals.user from API key for downstream scoping
- 13 unit tests covering CRUD, validation, and user isolation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 32a3504568 feat: add API keys schema and MCP SDK dependency
- Add `api_keys` table with userId foreign key to schema
- Add `@modelcontextprotocol/sdk` and `zod` dependencies
- Add auto-create SQL and test helpers for api_keys table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:03:04 +01:00
Frederic CabassutandClaude Opus 4.6 b3d40999c9 fix: enforce note ownership across all API endpoints
- Add userId check to /api/notes/reorder (was completely unprotected)
- Scope syncNoteTags by userId to prevent cross-user tag pollution
- Verify attachmentId belongs to noteId in attachment operations (IDOR fix)
- Remove DEFAULT 0 from userId columns so missing userId fails loudly
- Update test helpers and test data to provide required userId

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:50:12 +01:00
github-actions[bot] d45bc6fa21 chore: pin docker-compose to v0.8.1 [skip ci] 2026-03-09 00:33:34 +00:00
github-actions[bot] 719b32ad16 chore: bump version to 0.8.1 2026-03-09 00:29:30 +00:00
Frederic CabassutandClaude Opus 4.6 522fdbfd13 style: restyle settings pages to match parchment design system
- Move settings routes into (app) layout group for header/sidebar
- Replace all gray/white/dark-mode Tailwind classes with CSS variables
- Use rounded-sm, card-shadow, and parchment color tokens throughout
- Fix PasswordStrengthMeter to use themed colors
- Fix page titles from "KeepNotes" to "Crumbs"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:26:15 +01:00
Frederic CabassutandClaude Opus 4.6 e4d1cbdb09 fix: remove minlength attribute to fix e2e setup validation test
The HTML minlength="8" attribute triggered native browser validation
before the JS handler could run, preventing the custom error-message
element from appearing. JS already validates password length.

Also install missing zxcvbn and arctic dependencies from phase2 merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:23:11 +01:00
Frederic CabassutandClaude Opus 4.6 92b9deb6f0 docs: add mobile hover actions best practice to CLAUDE.md
Document the opacity-based pattern for hover actions that ensures
they're always visible on touch devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:59 +01:00
Frederic CabassutandClaude Opus 4.6 41505a2ebb fix: move drag handle to top-left to avoid overlap with pin icon
The drag handle was positioned top-right, overlapping with the pin
bookmark icon on pinned notes. Move it to top-left instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:46 +01:00
Frederic CabassutandClaude Opus 4.6 dfd726732b fix: increase focusable text area on mobile for rich text editor
Make the Tiptap editor fill the full wrapper height using flexbox so
tapping anywhere in the content area focuses the editor, not just the
small text area at the top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:34 +01:00
Frederic CabassutandClaude Opus 4.6 22e6b21ede fix: improve image actions for mobile and always show delete/star
- Use opacity-based visibility (max-md:opacity-100) for image hover
  actions so they're always accessible on touch devices
- Always show ImageUpload with delete/star when attachments exist,
  not just when in image upload mode
- Replace drag-and-drop zone with compact "Add images" button on mobile
- Add showDropZone prop to ImageUpload for conditional drop zone display
- Clean up unused lightbox state from NoteEditor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:16:08 +01:00
github-actions[bot] 423c2d59e9 chore: bump version to 0.8.0 2026-03-09 00:11:02 +00:00
Frederic CabassutandClaude Opus 4.6 2c588c6026 chore: regenerate lockfile after rebase onto main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 3b74a879a2 fix(test): proper e2e test isolation with project dependencies
Add globalSetup to clean test DB before each run. Split Playwright into
two projects: auth-setup (serial, runs first) and app (parallel, depends
on auth-setup). Skip rate limiting in test environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 b02ff65813 fix(test): resolve strict mode violations in e2e organization tests
Use .first() for pin-btn and tag filter assertions to handle cases
where multiple matching elements exist in the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 506e6b9c87 fix: resolve type errors in OAuth and API utils
Fix null vs undefined in GitHub OAuth, use arctic's
createAuthorizationURLWithPKCE for OIDC, loosen api-utils type
signature to avoid SvelteKit route-specific generics, add @types/zxcvbn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 333bd4912d docs: update .env.example with OAuth configuration
Add Google, GitHub, and generic OIDC environment variables. Providers
are auto-enabled when their env vars are set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 198e9ea3c3 feat(ui): dynamic OAuth buttons on login page
Login page loads enabled providers from server and shows "or continue
with" divider + branded buttons for Google/GitHub/OIDC. Also handles
rate limit 429 and OAuth no_account error display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 67764cee31 feat(auth): OAuth authorize and callback endpoints
Dynamic [provider] routes for Google, GitHub, and OIDC. Stores state and
PKCE verifier in httpOnly cookies. Callback validates code, finds/links
user by provider ID or email (invite-only), and creates session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 86c5d29e62 feat(auth): OAuth provider configuration with arctic
Add Google, GitHub, and generic OIDC provider modules using arctic v3.
Providers are auto-enabled when env vars are set. Google/GitHub use PKCE,
OIDC uses OpenID Connect discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 e155251f27 feat(auth): session list and revocation
Track session metadata (createdAt, userAgent, ip, lastUsedAt). Add API
for listing and revoking sessions. Settings page shows active sessions
with "Revoke" per session and "Log out everywhere else" option.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 34c95fae92 feat(ui): password strength meter with zxcvbn
Add PasswordStrengthMeter component showing visual bar and feedback.
Integrated in setup, settings change-password, and admin create-user forms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 5aaebfefe5 feat(auth): login rate limiting
5 failed attempts per IP triggers 15-minute lockout. Uses loginAttempts
table added in Phase 1. Returns 429 with Retry-After header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 5bed796396 fix(test): update tests for multi-user schema and improve e2e reliability
Update unit test helpers with full multi-user schema, fix auth tests to
include email field, and improve e2e tests with specific card targeting
and force clicks for hover-revealed buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 8438790cc1 chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 ac9bed93b2 fix(auth): resolve session validation and layout rendering issues
Fix session expiry check (seconds vs milliseconds mismatch in raw SQL),
conditionally render app chrome only for authenticated users, use full
page reload after login/setup for proper session propagation, remove
HTML minlength to allow JS validation, fix store to remove notes on
trash/archive, and make auth tests race-condition safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 b3e55ff95c fix(db): reorder migration to run before index creation
Move migration logic (ALTER TABLE) before CREATE INDEX statements so
that user_id columns exist on old schemas before indexes reference them.
Disable foreign keys during schema setup to avoid constraint issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 0adf6051e9 test: update auth tests for multi-user
Update e2e fixtures to use email+password for setup and login.
Update auth tests to fill email field. Add multi-user test for
admin creating a new user via API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 785f313b29 refactor(sync): namespace IndexedDB by userId
Each user gets their own IDB database (keepnotes-{userId}), preventing
data leaks between users on the same device. Add initDb(userId) called
on mount and destroyDb() for logout cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 04e93ec541 feat(ui): settings page with user profile and admin user management
- Settings page: edit display name/email, change password
- Admin user management page: create/delete users, toggle roles
- Profile update API endpoint (PUT /api/auth/profile)
- Settings link added to sidebar navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 93aeb85a7e feat(api): admin user management endpoints
- GET/POST /api/admin/users — list and create users (admin only)
- PATCH/DELETE /api/admin/users/[id] — update role/reset password, delete user
- PUT /api/auth/password — change own password
All admin routes guarded by requireAdmin().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 a9cb60e852 feat(layout): expose user data to app shell
Add layout.server.ts to pass event.locals.user to the client.
Header now shows user avatar initial with dropdown menu
(Settings link, Log out button).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 c9e041bffe refactor(api): scope all queries by authenticated user
Add getUserId/requireAdmin helpers in api-utils.ts. All notes, tags,
search, sync, and attachment routes now filter by userId to ensure
complete data isolation between users. Sync server accepts userId
parameter for push/pull operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 875306c99a feat(auth): email-based login and setup flows
Login page now requires email + password. Setup page adds email and
optional display name fields. API endpoints updated to pass email
to verifyPassword/setupUser and explicit userId to createSession.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 1ae194eb17 refactor(hooks): populate event.locals.user from session
validateSession now returns user data. Middleware sets event.locals.user
on every authenticated request. Add /api/auth/oauth to public paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 54c89c77b5 refactor(auth): email-based login and user CRUD
Rewrite auth module for multi-user support:
- setupUser now takes email, password, displayName; returns User
- verifyPassword takes email+password; returns User or null
- createSession takes explicit userId
- validateSession joins sessions+users; returns user data
- Add createUser, deleteUser, changePassword, resetPassword,
  updateUserRole, updateUserProfile, listUsers, getUser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 848af42b80 feat(types): add User type and App.Locals
Define App.Locals.user interface for session user data.
Add User type to shared types (mirrors DB columns minus passwordHash).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 38dbabae74 feat(db): add multi-user columns and login_attempts table
Add email, displayName, role, authProvider, providerId to users table.
Add userId foreign key to notes, tags, attachments, syncLog tables.
Add loginAttempts table for rate limiting. Include migration logic
to detect old single-user schema and backfill existing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 01:10:37 +01:00
Frederic CabassutandClaude Opus 4.6 36b54cccd8 style: add vertical padding to checklist items for better spacing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:46:32 +01:00
github-actions[bot] 90bdf9ab96 chore: pin docker-compose to v0.7.2 [skip ci] 2026-03-08 23:45:47 +00:00
github-actions[bot] 1813c5b1a6 chore: bump version to 0.7.2 2026-03-08 23:42:16 +00:00
Frederic CabassutandClaude Opus 4.6 54416e392c fix: add error resilience for IDB failures and API error feedback
loadNotes() crashed entirely if IndexedDB was unavailable because
getAllNotes() was called outside try/catch. Non-OK API responses
(401, 500) were silently ignored across loadNotes, createNote, and
sync operations — users saw empty data with no feedback.

Changes:
- Wrap all IDB calls in try/catch so the app works without IndexedDB
- Show toast on non-OK API responses in loadNotes and createNote
- Redirect to /login on 401 responses
- Make sync pull/push resilient to IDB unavailability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:41:45 +01:00
github-actions[bot] 8a21950e53 chore: pin docker-compose to v0.7.1 [skip ci] 2026-03-08 23:37:55 +00:00
github-actions[bot] 5992a12f76 chore: bump version to 0.7.1 2026-03-08 23:33:55 +00:00
Frederic CabassutandClaude Opus 4.6 b515b0593d fix: add missing thumbnail_path migration and upload error feedback
DBs created before the image upload feature had no thumbnail_path column
in the attachments table. Since CREATE TABLE IF NOT EXISTS is a no-op for
existing tables, the column was never added, causing silent insert failures.

Add idempotent ALTER TABLE migration for thumbnail_path and surface upload
errors via toast notifications instead of swallowing them silently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:33:32 +01:00
github-actions[bot] ba665b66c2 chore: pin docker-compose to v0.7.0 [skip ci] 2026-03-08 21:42:42 +00:00
github-actions[bot] 513ddb6379 chore: bump version to 0.7.0 2026-03-08 21:38:51 +00:00
Frederic CabassutandClaude Opus 4.6 b0d44de05f feat: user-selectable featured images for note card headers
Images are hidden from card thumbnails by default. Users explicitly
feature images via a star toggle in the upload panel, and only featured
images appear in the card header strip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 a3c2fb67b7 feat: add image lightbox for full-size preview on click
Clicking a thumbnail in the editor, closed-panel view, or note card
opens a full-screen overlay with the full-size image. Closes on Escape,
background click, or X button. Escape is scoped so it won't close the
editor underneath.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 bd7ce284f6 feat: auto-save note when clicking image toggle on new note
Instead of disabling the image button for unsaved notes, clicking it
now auto-saves the note first (with title "Untitled" if empty), then
opens the upload panel. This removes friction from the upload flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 a1f6d63e0a fix: E2E test fixes — static test image, thumbnail_path in init SQL, hover for remove
- Use static test-image.png fixture instead of dynamic beforeAll creation
- Add thumbnail_path column to auto-create SQL in db/index.ts
- Hover on thumbnail before clicking remove button (group-hover:block CSS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 3492d824ea feat: replace Checklists with Image Attachments on landing page
Swap Checklists feature card for Image Attachments with photo SVG icon.
Mention checklists in the Rich Editor card description instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 8f71d16313 docs: update FEATURES, ARCHITECTURE, and OpenAPI spec for image attachments
- FEATURES.md: document client-side optimization, thumbnails, offline support, SW caching
- ARCHITECTURE.md: add attachment data flow section (client → optimize → IDB/server → SW cache)
- openapi.yaml: add &thumb=1 query param, thumbnail FormData field, thumbnailPath in Attachment schema, attachments array in Note schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 ab6ed54eaf test: add E2E tests for image attachments
Covers: upload → thumbnail in editor, persistence across close/reopen,
thumbnail on NoteCard, image removal, disabled toggle for unsaved notes.
Uses programmatically generated 1x1 PNG for test uploads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 8b9618d65f feat: add CacheFirst SW rule for attachment images
Attachment images are immutable (UUID-based), so CacheFirst is ideal.
Previously-viewed images remain available offline even after blob URLs
expire. Rule placed before general NetworkFirst API rule to match first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 3ae88c5e2c docs: add git conventions section to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 7cebf44dcc feat: add offline attachment support with IDB pending store and sync
- Bump IDB version to 2, add pendingAttachments object store with noteId index
- Add CRUD helpers: addPendingAttachment, getPendingAttachments, removePendingAttachment
- ImageUpload stores blobs in IDB when offline, displays via blob URLs
- Show pending sync indicator (RefreshCw icon) on not-yet-uploaded images
- Add pushPendingAttachments to sync client, runs after note push
- Pending attachments auto-upload on reconnect via existing online event listener

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 791876af5f feat: wire ImageUpload into NoteEditor and show thumbnails on NoteCard
- Import ImageUpload into NoteEditor with attachment state management
- Add $effect to fetch attachments for existing notes loaded from IDB
- Add upload/remove handlers and image toggle button in toolbar
- Show ImageUpload panel between content and formatting toolbar
- Add full-bleed thumbnail strip at top of NoteCard (up to 3 images)
- Use &thumb=1 URL for card previews with lazy loading
- Show +N badge when more than 3 attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 854725c7ba feat: add image attachment backend — batch fetch, API responses, optimization, thumbnails
- Add fetchAttachmentsForNotes() batch helper mirroring fetchTagsForNotes pattern
- Include attachments in all note API responses (list, single, sync)
- Fix ImageUpload.svelte URL bug (path-based → query param)
- Add client-side image optimization (resize, compress, WebP conversion)
- Add thumbnailPath column to attachments schema with migration
- Update saveAttachment/deleteAttachment for thumbnail file handling
- Update attachment API: accept thumbnail in POST, serve via &thumb=1 in GET
- Add Cache-Control immutable header for served attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 a279b2f406 chore: add .worktrees/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:37:50 +01:00
Frederic CabassutandClaude Opus 4.6 d522436389 feat: link Bretzel in footer to bretzel.app
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:25:31 +01:00
github-actions[bot] 4492fc8f39 chore: pin docker-compose to v0.6.2 [skip ci] 2026-03-08 18:19:43 +00:00
github-actions[bot] 9c0ea59431 chore: bump version to 0.6.2 2026-03-08 18:16:27 +00:00
Frederic CabassutandClaude Opus 4.6 8ea46bd745 ci: always upload Playwright test results as artifacts
Upload both playwright-report/ and test-results/ (screenshots, traces)
on every run, not just failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:11:25 +01:00
github-actions[bot] a7f85b71af chore: pin docker-compose to v0.6.1 [skip ci] 2026-03-08 18:09:02 +00:00
Frederic CabassutandClaude Opus 4.6 50e4335b0f fix: show checklist actions on mobile
Apply the same max-md:opacity-100 pattern from NoteCard to Checklist
drag handles and remove buttons, making them visible on touch devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:08:33 +01:00
github-actions[bot] 3edaed4f91 chore: bump version to 0.6.1 2026-03-08 18:05:13 +00:00
Frederic CabassutandClaude Opus 4.6 b616e3a8ff fix: update checklist e2e tests for done-expanded default
Tests were clicking the done toggle expecting to expand it, but since
fed0fc1 changed doneExpanded to true by default, the click was
collapsing it instead — causing visibility assertions to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:15:25 +01:00
Frederic CabassutandClaude Opus 4.6 e895ef77b6 ci: run all jobs in parallel to reduce pipeline time
Remove `needs` dependencies from e2e-tests and docker-build jobs.
They don't depend on lint/unit outputs — failures are visible from
their own jobs. Saves ~22s off the critical path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:14:02 +01:00
Claude 7c0958f5b4 fix: show action buttons on mobile note cards
On touch devices, hover states don't work, so the pin/archive/delete
buttons were invisible. Now they're always visible on screens below the
md breakpoint and only hover-toggled on desktop.

https://claude.ai/code/session_013DdAk1yj6r3VF6Ze4ycidp
2026-03-08 07:52:32 +01:00
github-actions[bot] 64fc5c8931 chore: bump version to 0.6.0 2026-03-07 22:41:38 +00:00
Frederic CabassutandClaude Opus 4.6 8d25a1d531 feat: add Umami analytics to landing page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:40:24 +01:00
Frederic CabassutandClaude Opus 4.6 c8f4e23f73 docs: remove dark mode references (not implemented)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:48:41 +01:00
Frederic CabassutandClaude Opus 4.6 7c5483ea5e fix: checklist UX polish — spacing, disable markdown toggle, default to raw mode
- Increase gap between checkbox and text input (gap-1 → gap-2)
- Disable markdown toggle button in checklist mode
- Default to raw markdown mode when opening checklist notes as text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:44:31 +01:00
Frederic CabassutandClaude Opus 4.6 68750fefd5 fix: show delete button on row hover and expand done items by default
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:34:09 +01:00
Frederic CabassutandClaude Opus 4.6 e3c0a1c3c0 feat: enable delete for completed checklist items
Add X button to done/completed checklist items, matching the
existing remove button on active items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:27:33 +01:00
Frederic CabassutandClaude Opus 4.6 de59b5df82 fix: note grid drag-and-drop improvements
- Use dragHandleZone with grip icon instead of whole-card dragging,
  so card interactions (checkboxes, pin, archive) still work
- Scope pinned/unpinned grids to separate dnd types to prevent
  cross-zone drag bugs
- Add h-full to flip wrapper and NoteCard for equal card heights

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:17:58 +01:00
Frederic CabassutandClaude Opus 4.6 3808f76948 fix: always separate completed checklist items from active list
Completed items now consistently appear below unchecked items in both
the editor and the note card preview. Replaces the flaky two-state
hide/show toggle with a simple expand/collapse for the done section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:56:51 +01:00
Frederic CabassutandClaude Opus 4.6 fa698574bd feat: note sorting with drag-and-drop custom order
- Add sort mode selector (Updated/Created/Custom) with localStorage persistence
- Sort notes client-side via derived stores based on selected mode
- Enable drag-to-reorder in Custom mode via svelte-dnd-action on NoteGrid
- Add POST /api/notes/reorder endpoint for bulk sortOrder updates
- E2E tests for sorting by updated/created and persistence across reload

Closes #12

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:40:04 +01:00
Frederic CabassutandClaude Opus 4.6 e87be60a4b feat: checklist improvements — drag reorder and hide completed
- Add drag-to-reorder via svelte-dnd-action with grip handles
- Add "hide completed" toggle with collapsible done section
- Each checklist item gets a unique ID for stable drag operations
- E2E test for hide/show completed items

Closes #11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:33:56 +01:00
Frederic CabassutandClaude Opus 4.6 1725db41ce fix: exclude archived/trashed notes from tag list
The allTags derived store now filters out archived and trashed notes
before collecting tags, so the sidebar tag filter only shows tags that
yield visible results.

Closes #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:08:16 +01:00
Frederic CabassutandClaude Opus 4.6 7f42bc616d fix: close mobile sidebar on navigation
Sidebar filter and tag selection handlers now call onClose() on mobile
viewports (< 1024px), so the overlay dismisses after tapping a link.

Closes #10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:08:16 +01:00
github-actions[bot] 3629e48532 chore: pin docker-compose to v0.5.0 [skip ci] 2026-03-07 19:44:03 +00:00
github-actions[bot] f3f0c54ed9 chore: bump version to 0.5.0 2026-03-07 19:40:05 +00:00
Frederic CabassutandClaude Opus 4.6 cf2772dfc3 fix: make release script sed portable for Linux CI
macOS sed requires -i '' while GNU sed requires -i without argument.
Detect OS with uname to use the correct syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:38:15 +01:00
github-actions[bot] 21b0e25c54 chore: pin docker-compose to v0.4.3 [skip ci] 2026-03-07 19:34:45 +00:00
Frederic CabassutandClaude Opus 4.6 81091c28af feat: add app screenshots, lightbox, and polish landing page
- 6 screenshots (3 desktop + 3 mobile) with real app data
- Click-to-zoom lightbox on all screenshots
- Align header logo color (gold) between website and app
- Remove placeholder images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:32:04 +01:00
Frederic CabassutandClaude Opus 4.6 8a7ef196f0 feat: move app version to sidebar, auto-update website version on release
- Move version badge from footer to bottom of sidebar (right-aligned)
- Update release script to sed-replace version in website/index.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:32:04 +01:00
github-actions[bot] d8da61c060 chore: bump version to 0.4.3 2026-03-07 19:30:50 +00:00
github-actions[bot] 09571eda59 chore: pin docker-compose to v0.4.2 [skip ci] 2026-03-07 19:12:29 +00:00
github-actions[bot] 97d8f67a8b chore: bump version to 0.4.2 2026-03-07 19:08:57 +00:00
github-actions[bot] a9f7b71a62 chore: pin docker-compose to v0.4.1 [skip ci] 2026-03-07 19:06:10 +00:00
github-actions[bot] a3d3ffa4e8 chore: bump version to 0.4.1 2026-03-07 19:02:42 +00:00
github-actions[bot] 80705cebfd chore: pin docker-compose to v0.4.0 [skip ci] 2026-03-07 18:52:47 +00:00
github-actions[bot] efdbd535f1 chore: bump version to 0.4.0 2026-03-07 18:48:50 +00:00
Frederic CabassutandClaude Opus 4.6 064b2c5209 chore: update favicon assets from RealFaviconGenerator
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:48:13 +01:00
Frederic CabassutandClaude Opus 4.6 1a15cbc401 feat: add marketing landing page for crumbs.bretzel.app
Static landing page with parchment/retro theme matching the app aesthetic.
Includes GitHub Pages deploy workflow, responsive layout, feature grid,
deploy quick-start, and 404 page. Screenshot placeholders ready for
real captures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:48:13 +01:00
Frederic CabassutandClaude Opus 4.6 1cfffa1a6e fix: patch transitive dependency vulnerabilities via pnpm overrides
Override serialize-javascript (>=7.0.3), esbuild (<0.25.0 → 0.25.12),
and cookie (>=0.7.0) to resolve Dependabot alerts #1, #2, #3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:18:52 +01:00
Frederic CabassutandClaude Opus 4.6 36f6f68feb docs: add open source community files and README badges
Add LICENSE, CONTRIBUTING, CHANGELOG, SECURITY, CODE_OF_CONDUCT,
GitHub issue/PR templates, CODEOWNERS. Update package.json with
license/repo/author metadata. Add badges and logo to README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:07:56 +01:00
github-actions[bot] 2465d1b93a chore: pin docker-compose to v0.3.3 [skip ci] 2026-03-07 07:09:56 +00:00
231 changed files with 25274 additions and 1754 deletions
+25 -1
View File
@@ -1,8 +1,32 @@
# Database path (relative to project root or absolute)
DATABASE_URL=./data/crumbs.db
# Server origin (required for CSRF protection)
# Server origin (required for CSRF protection and OAuth redirect URLs)
ORIGIN=http://localhost:3000
# Node environment
NODE_ENV=development
# --- OAuth Providers (optional, auto-enabled when set) ---
# Google OAuth 2.0
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# GitHub OAuth
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# Generic OIDC (e.g. Authentik, Keycloak, Okta)
# AUTH_OIDC_ISSUER=https://your-idp.example.com
# AUTH_OIDC_CLIENT_ID=
# AUTH_OIDC_CLIENT_SECRET=
# AUTH_OIDC_DISPLAY_NAME=SSO
# --- Email Notifications (optional, auto-enabled when SMTP_HOST is set) ---
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=Crumbs <noreply@example.com>
+2
View File
@@ -0,0 +1,2 @@
# Default owner for everything in the repo
* @fredicious
+40
View File
@@ -0,0 +1,40 @@
---
name: Bug Report
about: Something isn't working as expected
title: "bug: "
labels: bug
assignees: ""
---
## Description
A clear summary of the bug.
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What should happen.
## Actual Behavior
What happens instead.
## Environment
- **Crumbs version**: (e.g. 0.3.2)
- **Browser**: (e.g. Chrome 120, Firefox 121)
- **OS**: (e.g. macOS 15, Ubuntu 24.04)
- **Deployment**: (Docker / Node.js / dev server)
## Screenshots
If applicable, add screenshots to help explain the problem.
## Additional Context
Any other relevant information (logs, related issues, etc.).
+27
View File
@@ -0,0 +1,27 @@
---
name: Feature Request
about: Suggest an idea or improvement
title: "feat: "
labels: enhancement
assignees: ""
---
## Description
A clear description of the feature you'd like to see.
## Motivation
Why is this feature important? What problem does it solve?
## Proposed Solution
How do you think this should work?
## Alternatives Considered
Have you considered other approaches? If so, why were they ruled out?
## Additional Context
Any mockups, links, or references that help illustrate the request.
+18
View File
@@ -0,0 +1,18 @@
## Summary
What does this PR do and why?
## Test Plan
- [ ] Unit tests added/updated
- [ ] E2E tests added/updated (if user-facing)
- [ ] `make check` passes
- [ ] `make test` passes
## Related Issues
Closes #
## Screenshots
If this changes the UI, include before/after screenshots.
+76 -14
View File
@@ -48,17 +48,83 @@ jobs:
needs: bump
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
build:
name: Build Docker (${{ matrix.platform }})
needs: [bump, ci]
permissions:
packages: write
strategy:
fail-fast: true
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.tag }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
runs-on: ubuntu-latest
needs: [bump, build]
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
ref: ${{ needs.bump.outputs.tag }}
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Extract metadata
id: meta
@@ -80,15 +146,11 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -99,7 +161,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: [bump, release]
needs: [bump, merge]
permissions:
contents: write
steps:
+52 -20
View File
@@ -46,12 +46,24 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit
- run: pnpm test:unit:coverage
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "Unit Test Coverage" coverage/unit/coverage-summary.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: unit-coverage
path: coverage/unit/
retention-days: 14
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
container:
image: mcr.microsoft.com/playwright:v1.58.0-noble
steps:
- uses: actions/checkout@v4
@@ -66,38 +78,58 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Cache Playwright Browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright System Dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Build (with coverage instrumentation)
run: pnpm build
env:
DATABASE_URL: ./data/test-crumbs.db
NODE_ENV: test
VITE_COVERAGE: 'true'
- run: pnpm test:e2e
env:
CI: true
VITE_COVERAGE: 'true'
HOME: /root
- name: Merge E2E coverage
if: always()
run: npx nyc report
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "E2E Test Coverage" coverage/e2e/coverage-summary.json
- uses: actions/upload-artifact@v4
if: failure()
if: always()
with:
name: playwright-report
path: playwright-report/
path: |
playwright-report/
test-results/
retention-days: 7
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-coverage
path: coverage/e2e/
retention-days: 14
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: docker build -t crumbs:ci .
uses: docker/build-push-action@v6
with:
context: .
push: false
provenance: false
tags: crumbs:ci
cache-from: type=gha,scope=ci-docker
cache-to: type=gha,scope=ci-docker,mode=max
+73 -13
View File
@@ -13,13 +13,77 @@ jobs:
ci:
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
build:
name: Build Docker (${{ matrix.platform }})
needs: ci
strategy:
fail-fast: true
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.runner }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifests & release
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
@@ -40,15 +104,11 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
@@ -58,7 +118,7 @@ jobs:
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: release
needs: merge
permissions:
contents: write
steps:
+47
View File
@@ -0,0 +1,47 @@
name: Screenshots
on:
workflow_dispatch:
permissions:
contents: write
jobs:
screenshots:
name: Capture Landing Page Screenshots
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install Playwright with system deps
run: pnpm exec playwright install --with-deps chromium
- name: Build app
run: pnpm build
env:
DATABASE_URL: ./data/screenshots.db
- name: Capture screenshots
run: pnpm exec tsx scripts/screenshots.ts
env:
DATABASE_URL: ./data/screenshots.db
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add website/assets/screenshot-*.png
git diff --cached --quiet && echo "No changes" && exit 0
git commit -m "chore: update landing page screenshots [skip ci]"
git push
+36
View File
@@ -0,0 +1,36 @@
name: Deploy Website
on:
push:
branches: [main]
paths: ['website/**']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: website
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+11
View File
@@ -32,8 +32,19 @@ vite.config.ts.timestamp-*
/test-results
/playwright-report
# Coverage
/coverage
.nyc_output
# Claude Code
/.claude/
# Local notes
todo.md
# Worktrees
.worktrees/
# Superpowers brainstorm artifacts
.superpowers/
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
{
"report-dir": "coverage/e2e",
"temp-dir": "coverage/e2e/raw",
"reporter": ["text", "html", "lcov", "json-summary"],
"include": ["src/**"],
"exclude": ["node_modules", "tests"]
}
+42
View File
@@ -0,0 +1,42 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.2] - 2026-03-07
### Fixed
- Update docker-compose only after Docker image is pushed
## [0.3.1] - 2026-03-07
### Fixed
- Include tags in sync pull response and update API spec
### Changed
- Update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md documentation
## [0.3.0] - 2026-03-06
### Added
- Tag management system with auto-extraction from note content
- Full-text search across titles, content, and tags
- Dark mode with system preference detection
- Note pinning, archive, and trash workflows
- Image attachments with drag-and-drop upload
- 12 color themes for note cards
- PWA support with offline-first IndexedDB sync
- LWW CRDT-based conflict resolution
- Docker deployment with multi-stage build
- CI/CD pipeline with GitHub Actions
- Comprehensive E2E test suite with Playwright
- OpenAPI spec and auto-generated API docs
### Changed
- Initial public release
[0.3.2]: https://github.com/bretzel-app/crumbs/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/bretzel-app/crumbs/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/bretzel-app/crumbs/releases/tag/v0.3.0
+30 -3
View File
@@ -2,7 +2,7 @@
## Project overview
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
## Tech stack
@@ -10,7 +10,7 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at `src/lib/server/db/schema.ts`)
- **Auth**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
- **Auth**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
- **Package manager**: pnpm
@@ -55,6 +55,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
### Layout groups
@@ -74,7 +75,12 @@ src/
(app)/ # Layout group: main app (header, sidebar, footer)
+layout.svelte # App shell with Header, Sidebar, Toast
+page.svelte # Main notes view
api/auth/ # Login, logout, setup endpoints
archive/ # Archived notes view
trash/ # Trashed notes view
tag/[name]/ # Notes filtered by tag
settings/ # Settings with tabbed layout (profile, MCP, users)
api/auth/ # Login, logout, setup, OAuth endpoints
api/admin/users/ # Admin user management (CRUD, role, sessions)
api/notes/ # CRUD + attachments
api/search/ # Full-text search
api/sync/ # Offline sync
@@ -92,6 +98,10 @@ tests/
unit/ # Vitest unit tests
e2e/ # Playwright e2e tests (Gherkin-style)
helpers/fixtures.ts # Shared authenticatedPage fixture
website/ # Static landing page (crumbs.bretzel.app)
index.html # Landing page with feature cards, screenshots, deploy section
styles.css # Landing page styles (retro parchment theme)
assets/ # Favicon, screenshots
```
## Commands
@@ -124,6 +134,14 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
| `make release-minor` | — | Bump minor version, tag, and push |
| `make release-major` | — | Bump major version, tag, and push |
## Git conventions
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
- **Main branch**: `main` — use as base for all PRs and feature branches
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
## CI/CD
GitHub Actions with two workflows:
@@ -145,6 +163,13 @@ GitHub Actions with two workflows:
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
## Database schema changes
When adding or modifying tables in `src/lib/server/db/schema.ts`:
- **If the table has a foreign key to `users`**: update `deleteUser()` in `src/lib/server/auth.ts` to delete from the new table before the user row is removed. Order matters — delete child rows before parent rows.
- **If the table has a foreign key to `notes`**: either add `onDelete: 'cascade'` in the schema definition, or add explicit cleanup in the note deletion logic.
## Testing philosophy: BDD/TDD
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
@@ -162,6 +187,7 @@ Write tests first or alongside features. Tests serve as living documentation of
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
- Auth tests use `test.describe.serial` because they depend on sequential database state
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
### Gherkin-style Given/When/Then
@@ -201,5 +227,6 @@ Keep docs in `docs/` up to date when making changes. If a feature, API endpoint,
|-----|--------|
| `docs/FEATURES.md` | User-facing feature list |
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
| `docs/AUTH.md` | Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.) |
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
+31
View File
@@ -0,0 +1,31 @@
# Code of Conduct
## Our Commitment
Crumbs is a community project, and we want everyone to feel welcome regardless of background, identity, or experience level. We are committed to providing a harassment-free experience for all participants.
## Expected Behavior
- Be respectful and considerate in communication
- Give and accept constructive feedback gracefully
- Focus on what is best for the project and community
- Show empathy towards others
## Unacceptable Behavior
- Harassment, intimidation, or discrimination of any kind
- Personal attacks, insults, or derogatory language
- Publishing others' private information without consent
- Any conduct that would be considered inappropriate in a professional setting
## Scope
This code of conduct applies to all project spaces — issues, pull requests, discussions, and any other communication channel associated with Crumbs.
## Enforcement
Instances of unacceptable behavior may be reported to the maintainers at **security@bretzel.app**. All reports will be reviewed and handled confidentially. Maintainers reserve the right to remove, edit, or reject contributions that violate this code of conduct, and to temporarily or permanently ban contributors for repeated or severe violations.
## Attribution
This code of conduct is inspired by the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1.
+88
View File
@@ -0,0 +1,88 @@
# Contributing to Crumbs
Thanks for your interest in contributing! This guide will help you get started.
## Development Setup
```bash
# Clone the repo
git clone https://github.com/bretzel-app/crumbs.git
cd crumbs
# Install dependencies
pnpm install
# Start the dev server
pnpm dev
```
Open http://localhost:5173 and set your password on first visit.
Run `make help` to see all available commands.
## Project Structure
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system design. Key directories:
- `src/routes/` — SvelteKit pages and API endpoints
- `src/lib/components/` — Svelte 5 components (runes, not stores)
- `src/lib/server/db/` — Drizzle ORM schema and database connection
- `src/lib/sync/` — Offline sync and CRDT logic
- `tests/unit/` — Vitest unit tests
- `tests/e2e/` — Playwright end-to-end tests
## Making Changes
1. **Fork and branch** — Create a feature branch from `main`
2. **Follow existing patterns** — Match the code style you see in the codebase
3. **Write tests** — Add unit tests for logic, E2E tests for user-facing features
4. **Run checks before pushing:**
```bash
make check # Type checking
make lint # Linting
make test-unit # Unit tests
make test-e2e # E2E tests
```
5. **Open a PR** — Target `main`, describe what and why
## Commit Messages
We use conventional-style commit messages:
```
feat: add tag autocomplete
fix: prevent duplicate notes on sync
docs: update deployment guide
chore: bump dependencies
```
Keep the subject line under 70 characters. Use the body for context when needed.
## Code Style
- **Svelte 5 runes** — Use `$state`, `$derived`, `$props`, `$effect` (not Svelte 4 stores)
- **TypeScript** — Strict mode, no `any` unless absolutely necessary
- **Tailwind CSS v4** — Use CSS variables from `src/app.css`, never hardcoded Tailwind colors
- **Design system** — Follow the Retro Parchment aesthetic described in `CLAUDE.md`
## Testing
- **Unit tests**: Pure logic (parsing, auth, sync). Run with `make test-unit`
- **E2E tests**: User workflows in the browser. Run with `make test-e2e`
- E2E tests use Gherkin-style Given/When/Then comments (see `CLAUDE.md` for conventions)
## Reporting Bugs
Use [GitHub Issues](https://github.com/bretzel-app/crumbs/issues) with the bug report template. Include:
- Steps to reproduce
- Expected vs actual behavior
- Browser/OS and Crumbs version
## Security Issues
**Do not open public issues for security vulnerabilities.** See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present Bretzel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8 -1
View File
@@ -1,4 +1,5 @@
.PHONY: help dev build preview check test test-unit test-e2e lint \
.PHONY: help dev build preview check test test-unit test-e2e \
test-unit-coverage test-e2e-coverage lint \
db-push db-generate db-migrate db-studio docs-api \
install docker-build docker-up docker-down docker-logs clean \
release release-patch release-minor release-major
@@ -27,6 +28,12 @@ test-unit: ## Run unit tests only
test-e2e: ## Run e2e tests only
pnpm test:e2e
test-unit-coverage: ## Run unit tests with coverage
pnpm test:unit:coverage
test-e2e-coverage: ## Run e2e tests with coverage
VITE_COVERAGE=true pnpm build && pnpm test:e2e && npx nyc report
lint: ## Run linter
pnpm lint
+76 -7
View File
@@ -1,8 +1,21 @@
# 🥨 Crumbs by Bretzel
[![CI](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/github/v/release/bretzel-app/crumbs)](https://github.com/bretzel-app/crumbs/releases)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://github.com/bretzel-app/crumbs/pkgs/container/crumbs)
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
<p align="center">
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
</p>
## Quick Start
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
<p align="center">
<img src="website/assets/screenshot-grid.png" alt="Crumbs notes grid view" width="720">
</p>
## Install
### Docker (Recommended)
@@ -10,7 +23,56 @@ A self-hostable, offline-first note-taking app inspired by Google Keep. Part of
docker compose up -d
```
Open http://localhost:3000 and set your password on first visit.
Open http://localhost:3000 and create your admin account on first visit.
#### With SSO (optional)
```yaml
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=your-client-id
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
#### With Email Notifications (optional)
Add SMTP settings to enable email notifications (share alerts, welcome emails, security alerts):
```yaml
environment:
- SMTP_HOST=smtp.example.com
- SMTP_PORT=587
- SMTP_USER=your-username
- SMTP_PASS=your-password
- SMTP_FROM=Crumbs <noreply@example.com>
```
Auto-enabled when `SMTP_HOST` is set. Users can opt out in Settings > Preferences. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#email-notifications-optional) for details.
### Manual (Node.js)
```bash
git clone <repo-url> crumbs && cd crumbs
pnpm install
pnpm build
DATABASE_URL=./data/crumbs.db ORIGIN=http://localhost:3000 node build
```
### Development
@@ -32,9 +94,12 @@ pnpm build # Production build
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
- Organize with #tags, pinning, archive, and trash
- Full-text search across titles, content, and tags
- Dark mode with system detection
- Note version history — browse and restore previous versions
- Share notes with other users on the same instance
- Email notifications for shares, security alerts, and account events (optional SMTP)
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
- Single-user password auth (Argon2)
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
- Docker deployment with a single command
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
@@ -60,8 +125,12 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|----------|-------------|
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
| [API](docs/API.md) | REST API reference (auto-generated) |
| [Contributing](CONTRIBUTING.md) | How to contribute |
| [Security](SECURITY.md) | Vulnerability reporting policy |
| [Changelog](CHANGELOG.md) | Release history |
## CI/CD
@@ -72,4 +141,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
## License
MIT
[MIT](LICENSE)
+44
View File
@@ -0,0 +1,44 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | Yes |
| < latest | No |
We recommend always running the latest version of Crumbs.
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via [GitHub's private vulnerability reporting](https://github.com/bretzel-app/crumbs/security/advisories/new).
Include as much of the following as possible:
- Description of the vulnerability
- Steps to reproduce
- Impact assessment
- Suggested fix (if any)
You should receive an initial response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
## Scope
Since Crumbs is a self-hosted application, the following are in scope:
- Authentication bypass or session hijacking
- SQL injection or data leaks
- Cross-site scripting (XSS)
- Server-side request forgery (SSRF)
- Path traversal or file access vulnerabilities
- Denial of service via crafted input
## Disclosure Policy
We follow coordinated disclosure. Once a fix is available, we will:
1. Release a patched version
2. Publish a security advisory on GitHub
3. Credit the reporter (unless anonymity is requested)
+16 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.3.2
image: ghcr.io/bretzel-app/crumbs:0.25.0
container_name: crumbs
ports:
- "3000:3000"
@@ -11,6 +11,21 @@ services:
- DATA_DIR=/data
- DATABASE_URL=/data/crumbs.db
- ORIGIN=${ORIGIN:-http://localhost:3000}
# OAuth / SSO (optional — providers auto-enable when configured)
- AUTH_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID:-}
- AUTH_GOOGLE_CLIENT_SECRET=${AUTH_GOOGLE_CLIENT_SECRET:-}
- AUTH_GITHUB_CLIENT_ID=${AUTH_GITHUB_CLIENT_ID:-}
- AUTH_GITHUB_CLIENT_SECRET=${AUTH_GITHUB_CLIENT_SECRET:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
- AUTH_OIDC_CLIENT_ID=${AUTH_OIDC_CLIENT_ID:-}
- AUTH_OIDC_CLIENT_SECRET=${AUTH_OIDC_CLIENT_SECRET:-}
- AUTH_OIDC_DISPLAY_NAME=${AUTH_OIDC_DISPLAY_NAME:-}
# Email notifications (optional — auto-enabled when SMTP_HOST is set)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
restart: unless-stopped
volumes:
+383
View File
@@ -111,6 +111,17 @@ List notes
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -157,6 +168,17 @@ Create a note
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -192,6 +214,17 @@ Get a note
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -247,6 +280,17 @@ Partial update — only include changed fields.
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
```
@@ -277,6 +321,231 @@ Permanently delete a note
---
## Collaboration
### `GET /api/notes/{id}/collaborators`
List collaborators
Returns all collaborators for a note. Requires access (owner or collaborator).
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `200`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/notes/{id}/collaborators`
Add a collaborator
Owner-only. Adds a user as collaborator on the note.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Request:**
```json
{
"userId": 1
}
```
**Response:** `201`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
**Errors:** `400` Invalid request (missing userId, owner as collaborator), `409` User is already a collaborator
---
### `DELETE /api/notes/{id}/collaborators`
Remove a collaborator
Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
| `userId` | query | | yes | |
**Response:** `200`
```json
{
"success": true
}
```
---
## Sharing
### `POST /api/notes/{id}/share`
Create a public share link
Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `201`
```json
{
"token": "token",
"url": "url"
}
```
**Errors:** `404` Note not found or not owner
---
### `DELETE /api/notes/{id}/share`
Revoke a public share link
Owner-only. Removes the public share link for the note.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `204`
**Errors:** `404` Note not found or not owner
---
### `GET /api/shared/{token}`
Get a shared note (public)
No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
> No authentication required.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | |
**Response:** `200`
```json
{
"title": "title",
"content": "content",
"checklistMode": true,
"color": "default",
"attachments": [
{
"id": "id",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"featured": true,
"createdAt": "2025-01-01T00:00:00.000Z"
}
],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
```
**Errors:** `404` Invalid token or note is trashed, `429` Rate limit exceeded
---
### `GET /api/shared/{token}/attachment/{attachmentId}`
Get an attachment from a shared note (public)
No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
> No authentication required.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | |
| `attachmentId` | path | string | yes | |
| `thumb` | query | string | no | Set to '1' to get the thumbnail version (1) |
**Response:** `200`
**Errors:** `404` Invalid token, note trashed, or attachment not found, `429` Rate limit exceeded
---
## Users
### `GET /api/users/search`
Search users
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Search query (matches email or displayName) |
**Response:** `200`
```json
[
{
"id": 1,
"displayName": "displayName",
"email": "email"
}
]
```
---
## Attachments
### `GET /api/notes/{id}/attachments`
@@ -291,6 +560,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
| `attachmentId` | query | string | no | |
| `thumb` | query | string | no | Set to "1" to return the thumbnail instead of the full image (requires attachmentId) (1) |
**Response:** `200`
@@ -302,6 +572,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
@@ -330,6 +601,7 @@ Upload an image attachment
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
```
@@ -393,6 +665,17 @@ Searches across note titles, content, and tags. Excludes trashed notes.
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -451,6 +734,17 @@ Pull changes since timestamp
"version": 1,
"tags": [
"item"
],
"attachments": [
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
}
]
@@ -498,3 +792,92 @@ Push local changes
**Errors:** `400` Invalid changes payload
---
## Settings
### `GET /api/settings/api-keys`
List API keys
**Response:** `200`
```json
[
{
"id": "uuid",
"name": "name",
"keyPrefix": "keyPrefix",
"createdAt": "2025-01-01T00:00:00.000Z",
"lastUsedAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/settings/api-keys`
Create API key
**Request:**
```json
{
"name": "name"
}
```
**Response:** `201`
```json
{
"id": "id",
"key": "key",
"name": "name",
"keyPrefix": "keyPrefix"
}
```
---
### `DELETE /api/settings/api-keys/{id}`
Revoke API key
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `200`
```json
{
"success": true
}
```
**Errors:** `404` Key not found
---
## MCP
### `POST /api/mcp`
MCP Streamable HTTP endpoint
Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
**Request:**
```json
{}
```
**Response:** `200`
**Errors:** `400` Invalid session, `401` Invalid or missing API key
---
+77 -7
View File
@@ -49,6 +49,14 @@ For a single-user app with multiple devices:
- If timestamps match: higher version wins
- If both match: local version is preferred
### Attachment Data Flow
1. User drops/picks image in NoteEditor
2. Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
3. **Online**: POST optimized + thumbnail to `/api/notes/{id}/attachments` → server saves to `data/attachments/` + DB
4. **Offline**: store blobs in IDB `pendingAttachments` store → display via `URL.createObjectURL()` → on reconnect, sync pushes to server
5. **Display**: NoteCard uses `&thumb=1` URL (200px WebP), editor shows full-size
6. **SW Caching**: `CacheFirst` rule caches attachment URLs — previously-viewed images available offline
## Tech Stack Rationale
### SvelteKit
@@ -75,32 +83,94 @@ For a single-user app with multiple devices:
- New v4 uses Vite plugin (faster builds)
### Argon2
- Current best practice for password hashing
- Memory-hard (resists GPU attacks)
- Winner of the Password Hashing Competition
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
## Database Schema
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - Single user (password hash)
- `users` - User accounts (password hash, OAuth provider link, role)
- `sessions` - Auth sessions (30-day TTL)
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
- `note_tags` - Many-to-many note ↔ tag
- `attachments` - Image file metadata
- `attachments` - Image file metadata (path + thumbnailPath)
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
- `shared_notes` - Public share links (noteId → token, 1:1 with cascade delete)
- `sync_log` - Sync operation history
### Nested Checklist Serialization
Checklist items are stored as plain text in the note `content` field, one item per line. Child items use a 2-space indent prefix:
```
- [ ] Parent item
- [ ] Child item
- [x] Checked child item
- [x] Another parent
```
- A line starting with ` - [ ]` or ` - [x]` (2 spaces) is parsed as a child of the preceding top-level item.
- The `parentId` field on `ChecklistItem` is **runtime-only** — it is derived from position during parsing and never stored explicitly.
- This format is fully backward compatible: flat checklists (no indented lines) parse identically to before.
### Collaboration Access Control
Notes support sharing with other users on the same instance:
- **Owner**: Full control (edit, trash, delete, share/unshare)
- **Collaborator**: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via `note_user_state`
- Access checks use `canAccessNote()` / `requireNoteAccess()` / `requireNoteOwnership()` helpers
- `note_user_state` stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
- When owner trashes a note, it vanishes for all collaborators
- Collaborators can leave (remove themselves) from shared notes
## MCP Server
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
### Architecture
- **Endpoint**: `POST /api/mcp` (Streamable HTTP transport)
- **Auth**: Bearer token via API keys (generated in Settings)
- **Sessions**: Stateful per MCP spec — session ID in `mcp-session-id` header
- **Transport**: `WebStandardStreamableHTTPServerTransport` (works with SvelteKit's Web Standard Request/Response)
### Shared Logic
MCP tool handlers and REST API routes both call the same service layer (`src/lib/server/notes-service.ts`). This avoids duplicating business logic:
```
REST API routes ──┐
├──► notes-service.ts ──► Drizzle DB
MCP tool handlers ┘
```
### Tools (14)
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
## Dark Mode
Theme switching uses CSS variable overrides on the `<html>` element:
- **CSS**: `[data-theme="dark"]` attribute on `<html>` overrides all `--bg-*`, `--text-*`, `--border-*`, and `--primary-*` variables defined in `src/app.css`
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and returns the appropriate color variant per theme
## File Organization
```
src/lib/server/ # Server-only code (DB, auth, attachments)
src/lib/server/ # Server-only code (DB, auth, attachments, notes-service)
src/lib/server/mcp/ # MCP server + tool definitions
src/lib/sync/ # Sync engine (shared types, server + client implementations)
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
src/lib/components/ # Svelte UI components
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
src/lib/types/ # TypeScript type definitions
src/routes/api/ # REST API endpoints
src/routes/api/ # REST API + MCP endpoints
src/routes/ # Page routes (SvelteKit file-based routing)
```
+209
View File
@@ -0,0 +1,209 @@
# Authentication
Crumbs supports password-based authentication and optional OAuth/SSO via Google, GitHub, or any OpenID Connect (OIDC) provider (e.g. Authentik, Keycloak, Okta, Auth0).
## First-run setup
On first launch, Crumbs presents a setup screen to create the initial admin account with email and password (minimum 8 characters). This account becomes the instance administrator.
## Password authentication
- Passwords are hashed with **Argon2** (argon2id variant)
- Sessions use httpOnly cookies with 30-day expiry
- Admins can reset passwords and revoke sessions for any user via Settings
## OAuth / SSO
OAuth providers are **auto-enabled** when their environment variables are set — no code changes needed. The login page dynamically shows buttons for each configured provider.
### How it works
1. User clicks an OAuth button on the login page
2. Crumbs redirects to the provider's authorization endpoint (with PKCE)
3. Provider authenticates the user and redirects back to Crumbs
4. Crumbs validates the response, extracts the user's email and name
5. If a matching user exists (by provider ID or email), a session is created
6. If no matching user exists, the login is rejected (invite-only model)
### Invite-only model
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
1. Go to **Settings > Users > Invite User**
2. Create the user with the **same email** they use on the OAuth provider
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
### Environment variables
#### Google OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
| `AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/google/callback`
Setup:
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create an OAuth 2.0 Client ID (Web application)
3. Add the callback URL above as an authorized redirect URI
4. Copy the client ID and secret into your environment
#### GitHub OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/github/callback`
Setup:
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set the authorization callback URL to the callback URL above
4. Copy the client ID and secret into your environment
#### Generic OIDC
Any OpenID Connect-compliant provider works via the generic OIDC integration. Crumbs automatically discovers endpoints via the provider's `/.well-known/openid-configuration` document.
| Variable | Description |
|----------|-------------|
| `AUTH_OIDC_ISSUER` | OIDC issuer URL (must serve `/.well-known/openid-configuration`) |
| `AUTH_OIDC_CLIENT_ID` | OIDC client ID |
| `AUTH_OIDC_CLIENT_SECRET` | OIDC client secret |
| `AUTH_OIDC_DISPLAY_NAME` | Button label on login page (default: `SSO`) |
**Callback URL:** `https://<your-domain>/api/auth/oauth/oidc/callback`
**Required scopes:** `openid`, `profile`, `email`
### Provider guides
#### Authentik
[Authentik](https://goauthentik.io/) is a self-hosted identity provider that works with Crumbs via the generic OIDC integration.
**1. Create a provider in Authentik:**
1. Open the Authentik admin panel
2. Go to **Applications > Providers > Create**
3. Select **OAuth2/OpenID Connect**
4. Configure:
- **Name:** `Crumbs`
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or `explicit-consent` to prompt users)
- **Client type:** `Confidential`
- **Redirect URIs:** `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
- **Scopes:** `openid`, `profile`, `email`
5. Save and note the **Client ID** and **Client Secret**
**2. Create an application in Authentik:**
1. Go to **Applications > Applications > Create**
2. Configure:
- **Name:** `Crumbs`
- **Slug:** `crumbs`
- **Provider:** Select the `Crumbs` provider you just created
- **Launch URL:** `https://<your-crumbs-domain>` (optional, for Authentik's app dashboard)
3. Save
**3. Configure Crumbs:**
```yaml
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
environment:
- ORIGIN=https://notes.example.com
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=<client-id>
- AUTH_OIDC_CLIENT_SECRET=<client-secret>
- AUTH_OIDC_DISPLAY_NAME=Authentik
```
> **Note:** The issuer URL for Authentik follows the pattern `https://<authentik-domain>/application/o/<app-slug>/`. This URL must serve a valid `/.well-known/openid-configuration` document — you can verify by visiting `https://<authentik-domain>/application/o/crumbs/.well-known/openid-configuration` in your browser.
**4. Pre-create users:**
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
#### Keycloak
1. Create a new client in your Keycloak realm
2. Set **Client type** to `OpenID Connect`, **Client authentication** to `On`
3. Add the callback URL: `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
4. Set the issuer URL to `https://<keycloak-domain>/realms/<realm-name>`
```env
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
AUTH_OIDC_CLIENT_ID=crumbs
AUTH_OIDC_CLIENT_SECRET=<client-secret>
AUTH_OIDC_DISPLAY_NAME=Keycloak
```
#### Okta / Auth0
Use the issuer URL from your Okta org or Auth0 tenant:
```env
# Okta
AUTH_OIDC_ISSUER=https://your-org.okta.com
# Auth0
AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
```
### Docker example with multiple providers
```yaml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
# Google
- AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
- AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
# GitHub
- AUTH_GITHUB_CLIENT_ID=Iv1.abc123
- AUTH_GITHUB_CLIENT_SECRET=abc123secret
# Authentik (OIDC)
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=crumbs-client-id
- AUTH_OIDC_CLIENT_SECRET=crumbs-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
All configured providers will appear as buttons on the login page.
## Security notes
- All OAuth flows use **PKCE** (Proof Key for Code Exchange) with S256 challenge method
- OAuth state and code verifier are stored as httpOnly cookies (10-minute expiry)
- Passwords are hashed with **Argon2** (never stored in plain text)
- Sessions expire after **30 days**
- The `ORIGIN` environment variable must match your actual domain for CSRF protection
- Always use **HTTPS** in production (via reverse proxy)
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| OAuth button doesn't appear | Provider env vars not set or missing a required variable | Check all required env vars are set and restart |
| "No account found" after OAuth | User doesn't exist in Crumbs | Create the user via Settings with matching email |
| OIDC discovery fails | Issuer URL is wrong or unreachable | Verify `AUTH_OIDC_ISSUER` serves `/.well-known/openid-configuration` |
| Redirect URI mismatch | Callback URL in provider doesn't match `ORIGIN` | Ensure `ORIGIN` matches your domain and the callback URL uses the same origin |
| PKCE error | Provider doesn't support S256 PKCE | Most modern providers support it; check provider docs |
+47 -3
View File
@@ -100,6 +100,8 @@ node build
## Environment Variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
@@ -108,6 +110,24 @@ node build
| `PORT` | `3000` | HTTP port |
| `NODE_ENV` | `development` | Set to `production` for deployment |
### OAuth / SSO (optional)
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
### Email Notifications (optional)
Email notifications are auto-enabled when `SMTP_HOST` is set. See [FEATURES.md](FEATURES.md#note-sharing--collaboration) for the full list of notification types.
| Variable | Default | Description |
|----------|---------|-------------|
| `SMTP_HOST` | — | SMTP server hostname (e.g. `smtp.example.com`) |
| `SMTP_PORT` | `587` | SMTP port (`587` for STARTTLS, `465` for SSL) |
| `SMTP_USER` | — | SMTP username (optional for unauthenticated relays) |
| `SMTP_PASS` | — | SMTP password |
| `SMTP_FROM` | `Crumbs <noreply@localhost>` | Sender address |
Users can opt out of share notifications in **Settings > Preferences**.
## Health Check
The Docker image includes a health check that pings `/login`:
@@ -116,10 +136,34 @@ The Docker image includes a health check that pings `/login`:
docker inspect --format='{{.State.Health.Status}}' crumbs
```
## MCP Server Configuration
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. See [ARCHITECTURE.md](ARCHITECTURE.md#mcp-server) for technical details and the full tool list.
### Setup
1. Open **Settings** in the Crumbs UI
2. Create an API key (give it a name like "Claude Code")
3. Copy the key (shown only once)
4. Configure your MCP client:
```json
{
"mcpServers": {
"crumbs": {
"type": "streamable-http",
"url": "https://your-crumbs-instance/api/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}
```
## Security Notes
- Always use HTTPS in production (via reverse proxy)
- Set a strong password (min 8 characters recommended)
- The `ORIGIN` variable must match your actual domain for CSRF protection
- Sessions expire after 30 days
- Passwords are hashed with Argon2
- API keys are SHA-256 hashed (never stored in plain text)
- See [AUTH.md](AUTH.md#security-notes) for authentication security details (password hashing, session expiry, PKCE)
+85 -19
View File
@@ -25,8 +25,8 @@
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Inline code (single backtick)
- Tables
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
- Dividers (horizontal rules)
- Text alignment (left, center, right, justify)
- Line breaks
@@ -37,12 +37,23 @@
- Check/uncheck items with checkboxes
- Checked items get strikethrough styling
- Enter key adds new item, Backspace removes empty items
- **Nested checklists**: single-level nesting for sub-tasks
- Tab / Shift+Tab to indent or outdent an item (keyboard)
- Drag handle horizontal swipe for mobile indentation
- Checking a parent item checks all its children
- Done section groups checked children under their parent label
### Image Attachments
- Upload images via file picker or drag-and-drop
- Supports JPEG, PNG, GIF, WebP, SVG
- Max file size: 10MB
- Thumbnail previews in note cards
- **Client-side optimization**: images resized to fit 1920×1920, compressed to WebP (~80% quality), transparent PNGs preserved
- **Auto-generated thumbnails**: 200×200 WebP at 60% quality for fast card previews
- Thumbnail strip on note cards (up to 3 images, "+N" badge for more)
- Full-size images in editor, thumbnails on cards (`&thumb=1`)
- **Offline support**: optimized blobs stored in IndexedDB, displayed via blob URLs, auto-synced on reconnect
- Pending upload indicator on offline-queued images
- Service worker CacheFirst caching for previously-viewed images
- Remove attachments from editor
### Tag Organization
@@ -53,10 +64,16 @@
- Tags inside code blocks and URLs are ignored
### Color-Coded Notes
12 colors matching Google Keep's palette:
12 note colors:
- Default, Coral, Peach, Sand, Mint, Sage
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to light/dark mode
- Colors adapt to note theme
### Dark Mode
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
- Respects system `prefers-color-scheme` preference by default
- FOUC-free: theme applied before first paint via a blocking inline script
### Pin Notes
- Pin important notes to always appear at top
@@ -74,35 +91,84 @@
- Restore notes from trash
- Permanently delete from trash
### Note Sharing / Collaboration
- **Public link sharing**: share any note via a public URL — no login required for viewers
- Toggle public link on/off from the share dialog
- Copy shareable URL with one click
- Read-only public page with full markdown/checklist/image rendering
- Public links are automatically invalidated when a note is trashed
- Rate-limited public endpoints (60 req/min per IP)
- Globe icon on note cards indicates a public link is active
- Share notes with other users on the same instance
- User search autocomplete for adding collaborators
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
- Per-user state: pin, archive, and sort order are independent per user
- Owner-only actions: trash, delete, share/unshare
- Sharing indicator on note cards (Globe for public, Users for collaborators-only)
- Hover popover showing sharing status and collaborator names
- Share dialog accessible from editor toolbar
- Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned notes
- Email notifications (requires SMTP config, opt-out in preferences):
- Share notifications when a note is shared with you
- Welcome email when admin creates your account
- Password reset notification
- Account deletion confirmation
- Email address change notification
- Role change notification (admin/user)
- Removed from shared note notification
- Shared note permanently deleted notification
- Account locked alert (after failed login attempts)
### Note Version History
- Browse previous versions of any note
- Versions are automatically saved when content changes
- Restore any previous version with one click
- Version snapshots are only created when content actually changes (not on metadata-only updates)
### Full-Text Search
- Search across note titles, content, and tags
- Real-time search results as you type
- Clear search to restore original view
- Case-insensitive matching
### Dark Mode
- System preference detection (auto)
- Manual toggle override
- Preference persisted in localStorage
- All components adapt to theme
- Note colors adjust for dark backgrounds
### Authentication & OAuth / SSO
- Multi-user with role-based access (admin / user)
- Password auth (Argon2) + optional OAuth/SSO (Google, GitHub, any OIDC provider)
- Session-based auth with active session management
- Invite-only OAuth model with PKCE-secured flows
- See [AUTH.md](AUTH.md) for full details and provider setup guides
### Single-User Auth
- First-run setup: create password (min 8 chars)
- Argon2 password hashing (industry standard)
- Session-based auth with 30-day expiry
- All routes protected except /login and /setup
- Logout clears session
### User Management (admin)
- Admin dashboard to create, list, and delete users
- Assign roles (admin / user)
- Reset passwords for any user
- Revoke all sessions (force logout) for any user
### PWA / Offline-First
- Installable as standalone app
- Web app manifest with icons
- Service worker for asset caching
- IndexedDB for local data storage
- Background sync every 30 seconds
- Service worker for asset caching (CacheFirst for attachments, NetworkFirst for API)
- IndexedDB for local data storage (notes + pending attachments)
- Background sync every 30 seconds (notes + offline attachments)
- LWW conflict resolution for multi-device use
- Sync status indicator (synced/syncing/offline/error)
### MCP Server (Model Context Protocol)
- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport)
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; pin, reorder notes; upload images
- API key authentication (Bearer token)
- Compatible with Claude Code, Claude Desktop, and other MCP clients
- Stateful sessions with automatic cleanup
### API Key Management
- Generate API keys from the Settings page
- Keys use `crumbs_` prefix for easy identification
- SHA-256 hashed storage (keys shown once at creation)
- Track last-used timestamps
- Revoke keys with confirmation dialog
- MCP client config example shown in Settings
### Docker Deployment
- Multi-stage Dockerfile (build + slim runtime)
- docker-compose.yml with persistent volume
+142
View File
@@ -0,0 +1,142 @@
# E2E BDD Testing Best Practices
This document defines the 10 best practices for writing E2E tests in Crumbs. All tests use Playwright with Gherkin-style BDD comments.
## 1. Declarative scenarios, not imperative scripts
Describe **what** the system does, not **how** the user clicks through the UI.
```
// Good: "When the user creates a note titled 'Shopping List'"
// Bad: "When the user clicks the + button, types 'Shopping List' in the title field, and clicks save"
```
If the UI changes (button moves, field renames), only the Playwright code changes — the scenario comment stays stable.
## 2. Given describes state, not navigation
`Given` sets up preconditions. It should read like a database fixture, not a user journey.
```
// Good: "Given a note titled 'Old Note' exists in the trash"
// Bad: "Given the user creates a note, then clicks trash, then confirms deletion"
```
Use API calls or helpers like `createNote()` in `Given` steps to avoid coupling tests to unrelated UI flows.
## 3. One behavior per scenario
Each `test()` should verify **one outcome**. If you need multiple `Then` assertions about different behaviors, split them.
```ts
// Good: Two focused scenarios
test('Trashed note disappears from main view', ...)
test('Trashed note appears in trash view', ...)
// Bad: One bloated scenario testing everything
test('Trash workflow', ...) // creates, trashes, checks main, checks trash, restores...
```
## 4. Scenario names describe outcomes, not actions
Name tests by the **observable result**, not the user action that triggers it.
```ts
// Good: test('Archived note no longer appears in the main view')
// Bad: test('User archives a note')
```
This makes test reports read like a behavior specification.
## 5. Isolate test state completely
Each test should start from a **known, clean state**. Use API-based setup/teardown or the shared `authenticatedPage` fixture.
```ts
// Use API to create/reset state before each test
test.beforeEach(async ({ authenticatedPage: page }) => {
await page.request.put('/api/preferences', { data: { ... } });
});
```
This prevents the "test B fails because test A didn't clean up" problem — especially important since Playwright runs workers in parallel.
## 6. Use `test.describe.serial` only when truly sequential
Reserve `serial` for flows where state genuinely cascades (setup -> login -> session). For everything else, isolate with fixtures. Treat `serial` as a **code smell** — most tests should run in **any order**.
## 7. Abstract repeated UI actions into helpers
When multiple scenarios share the same UI interaction (e.g., "create a note"), extract it into a helper at the **intent level**.
```ts
// helpers/fixtures.ts
export async function createNote(page, title, content?) { ... }
// In tests — reads like BDD
await createNote(page, 'My Note', 'Hello');
```
The shared `authenticatedPage` fixture and `noteCard()` locator are examples of this pattern. The `createNote()` helper abstracts the 4-step note creation flow used across most test files.
## 8. Apply the resilience test to every comment
Before committing a Gherkin comment, ask: *"Would this comment need to change if we redesigned the UI?"*
- If **yes** — rewrite to remove implementation details
- If **no** — it's at the right abstraction level
```
// Fails (mentions hover + button):
// "When the user hovers the card and clicks the 3-dot menu"
// Passes:
// "When the user opens the note actions menu"
```
## 9. Prefer `await expect(...).toBeVisible()` over arbitrary waits
Playwright's auto-waiting is powerful. Never use `page.waitForTimeout()` — it's flaky and slow. Assert on the **expected end state** instead.
```ts
// Good: Waits intelligently for the element
await expect(page.getByText('Note saved')).toBeVisible();
// Bad: Arbitrary delay
await page.waitForTimeout(2000);
```
The exception is `waitForLoadState('networkidle')` after authentication redirects, where the full page reload makes auto-waiting insufficient.
## 10. Structure tests in the Given/When/Then rhythm
Use comments to maintain the three-act structure. This makes tests scannable and self-documenting.
```ts
test('Pinned note appears at the top of the list', async ({ authenticatedPage: page }) => {
// Given a note titled "Important" exists
await createNote(page, 'Important');
// When the user pins the note
await pinNote(page, 'Important');
// Then it appears before unpinned notes
const firstCard = page.locator('[data-testid="note-card"]').first();
await expect(firstCard).toContainText('Important');
});
```
## Quick reference
| Practice | Category | Goal |
|----------|----------|------|
| 1. Declarative scenarios | Language | Stable comments despite UI changes |
| 2. Given = state | Language | Decouple setup from UI flow |
| 3. One behavior per test | Structure | Focused, debuggable tests |
| 4. Outcome-based names | Language | Test reports read as specs |
| 5. Isolate state | Reliability | No cross-test pollution |
| 6. Avoid serial | Reliability | Parallel-safe by default |
| 7. Extract helpers | Structure | DRY, intent-level code |
| 8. Resilience test | Language | UI-change-proof comments |
| 9. Auto-wait assertions | Reliability | No flaky timeouts |
| 10. Given/When/Then | Structure | Scannable three-act tests |
+376
View File
@@ -168,6 +168,202 @@ paths:
'404':
description: Note not found
/api/notes/{id}/collaborators:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: List collaborators
description: Returns all collaborators for a note. Requires access (owner or collaborator).
tags: [Collaboration]
responses:
'200':
description: Collaborator list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
post:
summary: Add a collaborator
description: Owner-only. Adds a user as collaborator on the note.
tags: [Collaboration]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [userId]
properties:
userId:
type: integer
responses:
'201':
description: Collaborator added, returns updated list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
'400':
description: Invalid request (missing userId, owner as collaborator)
'409':
description: User is already a collaborator
delete:
summary: Remove a collaborator
description: Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
tags: [Collaboration]
parameters:
- name: userId
in: query
required: true
schema:
oneOf:
- type: integer
- type: string
enum: [self]
responses:
'200':
description: Collaborator removed
content:
application/json:
schema:
$ref: '#/components/schemas/Success'
/api/notes/{id}/share:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
post:
summary: Create a public share link
description: Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
tags: [Sharing]
responses:
'201':
description: Share link created
content:
application/json:
schema:
type: object
properties:
token:
type: string
url:
type: string
format: uri
'404':
description: Note not found or not owner
delete:
summary: Revoke a public share link
description: Owner-only. Removes the public share link for the note.
tags: [Sharing]
responses:
'204':
description: Share link revoked
'404':
description: Note not found or not owner
/api/shared/{token}:
parameters:
- name: token
in: path
required: true
schema:
type: string
get:
summary: Get a shared note (public)
description: No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
tags: [Sharing]
security: []
responses:
'200':
description: Shared note data
content:
application/json:
schema:
$ref: '#/components/schemas/SharedNoteData'
'404':
description: Invalid token or note is trashed
'429':
description: Rate limit exceeded
/api/shared/{token}/attachment/{attachmentId}:
parameters:
- name: token
in: path
required: true
schema:
type: string
- name: attachmentId
in: path
required: true
schema:
type: string
get:
summary: Get an attachment from a shared note (public)
description: No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
tags: [Sharing]
security: []
parameters:
- name: thumb
in: query
schema:
type: string
enum: ['1']
description: Set to '1' to get the thumbnail version
responses:
'200':
description: Image binary
content:
image/*:
schema:
type: string
format: binary
'404':
description: Invalid token, note trashed, or attachment not found
'429':
description: Rate limit exceeded
/api/users/search:
get:
summary: Search users
description: Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
tags: [Users]
parameters:
- name: q
in: query
required: true
schema:
type: string
description: Search query (matches email or displayName)
responses:
'200':
description: Matching users (max 10)
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
displayName:
type: string
email:
type: string
/api/notes/{id}/attachments:
parameters:
- name: id
@@ -185,6 +381,12 @@ paths:
in: query
schema:
type: string
- name: thumb
in: query
description: Set to "1" to return the thumbnail instead of the full image (requires attachmentId)
schema:
type: string
enum: ['1']
responses:
'200':
description: Attachment list (without attachmentId) or file content (with attachmentId)
@@ -209,6 +411,10 @@ paths:
type: string
format: binary
description: Image file (max 10MB)
thumbnail:
type: string
format: binary
description: Pre-generated thumbnail (WebP, ~200px)
responses:
'201':
description: Attachment metadata
@@ -315,14 +521,144 @@ paths:
'400':
description: Invalid changes payload
/api/settings/api-keys:
get:
summary: List API keys
tags: [Settings]
responses:
'200':
description: List of API keys (without hashes)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApiKey'
post:
summary: Create API key
tags: [Settings]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
'201':
description: API key created (key shown only once)
content:
application/json:
schema:
type: object
properties:
id:
type: string
key:
type: string
name:
type: string
keyPrefix:
type: string
/api/settings/api-keys/{id}:
delete:
summary: Revoke API key
tags: [Settings]
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Key revoked
content:
application/json:
schema:
$ref: '#/components/schemas/Success'
'404':
description: Key not found
/api/mcp:
post:
summary: MCP Streamable HTTP endpoint
description: Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
tags: [MCP]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
description: JSON-RPC 2.0 message
responses:
'200':
description: JSON-RPC response (may be SSE stream)
'400':
description: Invalid session
'401':
description: Invalid or missing API key
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: session
bearerAuth:
type: http
scheme: bearer
description: API key (crumbs_...)
schemas:
PublicAttachment:
type: object
description: Sanitized attachment metadata for public sharing (no server paths)
properties:
id:
type: string
filename:
type: string
mimeType:
type: string
size:
type: integer
featured:
type: boolean
createdAt:
type: string
format: date-time
SharedNoteData:
type: object
properties:
title:
type: string
content:
type: string
checklistMode:
type: boolean
color:
type: string
enum: [default, coral, peach, sand, mint, sage, fog, storm, dusk, blossom, clay, chalk]
attachments:
type: array
items:
$ref: '#/components/schemas/PublicAttachment'
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
Success:
type: object
properties:
@@ -368,6 +704,10 @@ components:
type: array
items:
type: string
attachments:
type: array
items:
$ref: '#/components/schemas/Attachment'
NoteInput:
type: object
@@ -415,6 +755,10 @@ components:
type: string
size:
type: integer
thumbnailPath:
type: string
nullable: true
description: Server path to thumbnail file (null if no thumbnail)
createdAt:
type: string
format: date-time
@@ -434,3 +778,35 @@ components:
description: Unix timestamp in milliseconds
data:
$ref: '#/components/schemas/NoteInput'
Collaborator:
type: object
properties:
userId:
type: integer
displayName:
type: string
email:
type: string
addedAt:
type: string
format: date-time
ApiKey:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
keyPrefix:
type: string
description: First 12 characters of the key for display
createdAt:
type: string
format: date-time
lastUsedAt:
type: string
format: date-time
nullable: true
@@ -0,0 +1,781 @@
# Dark Mode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a dark parchment theme variant with system/light/dark toggle in Settings > Preferences.
**Architecture:** CSS `[data-theme="dark"]` attribute on `<html>` overrides CSS variables in `app.css`. A blocking inline script in `app.html` prevents FOUC. Theme preference uses the existing `UserPreferences` key-value system. A reactive `isDarkMode` rune in `theme.svelte.ts` drives note card color switching.
**Tech Stack:** SvelteKit, Svelte 5 runes, CSS custom properties, Tailwind CSS v4, Vitest, Playwright
**Spec:** `docs/superpowers/specs/2026-03-20-dark-mode-design.md`
---
## File Map
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `src/app.css` | Add `--grid-dot` to `:root`, add `[data-theme="dark"]` block, update `body::before` |
| Modify | `src/app.html` | Add FOUC-prevention inline script |
| Modify | `src/lib/types/preferences.ts` | Add `theme` field to `UserPreferences` |
| Create | `src/lib/utils/theme.svelte.ts` | `applyTheme()`, reactive `isDarkMode` rune |
| Modify | `src/lib/utils/colors.ts` | Add `NOTE_COLORS_DARK`, export `getNoteColor()` |
| Modify | `src/routes/+layout.svelte` | Add theme `$effect` watcher |
| Modify | `src/routes/(app)/settings/preferences/+page.svelte` | Add theme segmented toggle |
| Modify | `src/lib/components/NoteCard.svelte` | Use `getNoteColor()` with `isDarkMode` |
| Modify | `src/lib/components/NoteEditor.svelte` | Use `getNoteColor()` with `isDarkMode` |
| Modify | `src/lib/components/ColorPicker.svelte` | Use dark color swatches when `isDarkMode` |
| Skip | `src/routes/(share)/s/[token]/+page.svelte` | Out of scope per spec (public share pages) |
| Modify | `.gitignore` | Add `.superpowers/` |
| Create | `tests/unit/theme.test.ts` | Unit tests for `applyTheme` and `isDarkMode` |
| Create | `tests/unit/colors.test.ts` | Unit tests for `getNoteColor` |
| Create | `tests/e2e/dark-mode.spec.ts` | E2E tests for theme toggle, persistence, system detection |
---
### Task 1: CSS Dark Theme Variables
**Files:**
- Modify: `src/app.css:6-23` (`:root` block) and `src/app.css:36` (`body::before`)
- [ ] **Step 1: Add `--grid-dot` to `:root` and add `[data-theme="dark"]` block**
In `src/app.css`, add `--grid-dot: #1a1a2e;` to the end of the `:root` block, then add the dark theme block after `:root`:
```css
:root {
--bg-base: #f0e6d3;
--bg-surface: #faf5eb;
--primary: #C8860A;
--primary-hover: #E8A020;
--text: #1a1a2e;
--text-muted: #6b6272;
--border: #1a1a2e;
--border-subtle: #d4cabb;
--destructive: #a63d2f;
--error-bg: #f5e0db;
--error-border: #d4a69e;
--error-text: #8b2e1f;
--success-bg: #dce8d4;
--success-text: #3a5a2e;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #1a1a2e;
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #e8dcc8;
}
```
- [ ] **Step 2: Update `body::before` to use `--grid-dot` variable**
Replace the hardcoded color in the `body::before` rule:
```css
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
```
- [ ] **Step 3: Verify by temporarily adding `data-theme="dark"` to `app.html`**
In `src/app.html`, temporarily change `<html lang="en">` to `<html lang="en" data-theme="dark">`. Run `make dev` and visually confirm dark background and light text. Then revert the change.
- [ ] **Step 4: Commit**
```bash
git add src/app.css
git commit -m "feat: add dark theme CSS variables and grid-dot extraction"
```
---
### Task 2: Theme Preference Type
**Files:**
- Modify: `src/lib/types/preferences.ts`
- [ ] **Step 1: Add `theme` field to `UserPreferences`**
```typescript
export interface UserPreferences {
theme: 'system' | 'light' | 'dark';
defaultNoteMode: 'richtext' | 'markdown';
defaultNoteColor: NoteColor;
hideFooter: boolean;
sidebarDefaultState: 'open' | 'collapsed';
notifyOnShare: boolean;
notifyOnCollabRemoved: boolean;
notifyOnNoteDeleted: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
theme: 'system',
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open',
notifyOnShare: true,
notifyOnCollabRemoved: true,
notifyOnNoteDeleted: true
};
```
Do NOT add `theme` to `BOOLEAN_PREF_KEYS`.
- [ ] **Step 2: Commit**
```bash
git add src/lib/types/preferences.ts
git commit -m "feat: add theme preference type (system/light/dark)"
```
---
### Task 3: Theme Utility — `applyTheme` and `isDarkMode`
**Files:**
- Create: `src/lib/utils/theme.svelte.ts`
- Test: `tests/unit/theme.test.ts`
- [ ] **Step 1: Write failing tests for theme utility**
Note: `theme.svelte.ts` uses module-scope `$state` which requires the Svelte compiler. The Vitest node environment does not process `.svelte.ts` runes correctly, so we test only the DOM side-effects of `applyTheme` (attribute and meta tag changes). The reactive `isDarkMode` / `getIsDarkMode()` behavior is covered by E2E tests instead.
Create `tests/unit/theme.test.ts`:
```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({ browser: true }));
describe('applyTheme', () => {
let applyTheme: (theme: 'system' | 'light' | 'dark') => void;
beforeEach(async () => {
// Reset DOM
document.documentElement.removeAttribute('data-theme');
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.remove();
const metaEl = document.createElement('meta');
metaEl.setAttribute('name', 'theme-color');
metaEl.setAttribute('content', '#f0e6d3');
document.head.appendChild(metaEl);
// Reset module state
vi.resetModules();
const mod = await import('$lib/utils/theme.svelte.js');
applyTheme = mod.applyTheme;
});
it('sets data-theme="dark" for dark mode', () => {
applyTheme('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('removes data-theme for light mode', () => {
document.documentElement.setAttribute('data-theme', 'dark');
applyTheme('light');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('updates meta theme-color for dark mode', () => {
applyTheme('dark');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#1a1715');
});
it('updates meta theme-color for light mode', () => {
applyTheme('dark');
applyTheme('light');
const meta = document.querySelector('meta[name="theme-color"]');
expect(meta?.getAttribute('content')).toBe('#f0e6d3');
});
it('falls back to system for unknown values', () => {
// matchMedia returns false by default in jsdom → resolves to light
applyTheme('invalid' as 'system' | 'light' | 'dark');
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
});
it('resolves system preference to dark when prefers-color-scheme is dark', () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(prefers-color-scheme: dark)',
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});
applyTheme('system');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm vitest run tests/unit/theme.test.ts`
Expected: FAIL — module not found
- [ ] **Step 3: Implement `theme.svelte.ts`**
Create `src/lib/utils/theme.svelte.ts`:
```typescript
import { browser } from '$app/environment';
const LIGHT_THEME_COLOR = '#f0e6d3';
const DARK_THEME_COLOR = '#1a1715';
let darkMode = $state(false);
export function getIsDarkMode(): boolean {
return darkMode;
}
export function applyTheme(theme: 'system' | 'light' | 'dark'): void {
if (!browser) return;
// Validate input
if (theme !== 'system' && theme !== 'light' && theme !== 'dark') {
theme = 'system';
}
// Resolve system preference
let resolved = theme;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
const isDark = resolved === 'dark';
darkMode = isDark;
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm vitest run tests/unit/theme.test.ts`
Expected: All PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/utils/theme.svelte.ts tests/unit/theme.test.ts
git commit -m "feat: add theme utility with applyTheme and isDarkMode"
```
---
### Task 4: Note Card Dark Colors
**Files:**
- Modify: `src/lib/utils/colors.ts`
- Test: `tests/unit/colors.test.ts`
- [ ] **Step 1: Write failing tests for `getNoteColor`**
Create `tests/unit/colors.test.ts`:
```typescript
import { describe, it, expect } from 'vitest';
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
describe('getNoteColor', () => {
it('returns light color when isDark is false', () => {
expect(getNoteColor('default', false)).toBe('#faf5eb');
expect(getNoteColor('coral', false)).toBe('#faafa8');
});
it('returns dark color when isDark is true', () => {
expect(getNoteColor('default', true)).toBe('#2a2520');
expect(getNoteColor('coral', true)).toBe('#4a2522');
});
it('returns default color for unknown color name', () => {
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
});
it('has matching keys in light and dark color maps', () => {
const lightKeys = Object.keys(NOTE_COLORS).sort();
const darkKeys = Object.keys(NOTE_COLORS_DARK).sort();
expect(lightKeys).toEqual(darkKeys);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm vitest run tests/unit/colors.test.ts`
Expected: FAIL — `getNoteColor` and `NOTE_COLORS_DARK` not found
- [ ] **Step 3: Add dark colors and helper to `colors.ts`**
Update `src/lib/utils/colors.ts`:
```typescript
import type { NoteColor } from '$lib/types/index.js';
export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#faf5eb', label: 'Default' },
coral: { bg: '#faafa8', label: 'Coral' },
peach: { bg: '#f39f76', label: 'Peach' },
sand: { bg: '#fff8b8', label: 'Sand' },
mint: { bg: '#e2f6d3', label: 'Mint' },
sage: { bg: '#b4ddd3', label: 'Sage' },
fog: { bg: '#d4e4ed', label: 'Fog' },
storm: { bg: '#aeccdc', label: 'Storm' },
dusk: { bg: '#d3bfdb', label: 'Dusk' },
blossom: { bg: '#f6e2dd', label: 'Blossom' },
clay: { bg: '#e9e3d4', label: 'Clay' },
chalk: { bg: '#efeff1', label: 'Chalk' }
};
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
default: { bg: '#2a2520', label: 'Default' },
coral: { bg: '#4a2522', label: 'Coral' },
peach: { bg: '#4a3020', label: 'Peach' },
sand: { bg: '#3a3520', label: 'Sand' },
mint: { bg: '#2a3a22', label: 'Mint' },
sage: { bg: '#223a32', label: 'Sage' },
fog: { bg: '#222e3a', label: 'Fog' },
storm: { bg: '#1e2a35', label: 'Storm' },
dusk: { bg: '#352540', label: 'Dusk' },
blossom: { bg: '#3a2830', label: 'Blossom' },
clay: { bg: '#302e28', label: 'Clay' },
chalk: { bg: '#2e2e30', label: 'Chalk' }
};
export function getNoteColor(color: NoteColor, isDark: boolean): string {
const map = isDark ? NOTE_COLORS_DARK : NOTE_COLORS;
return (map[color] ?? map.default).bg;
}
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
value: value as NoteColor,
label
}));
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm vitest run tests/unit/colors.test.ts`
Expected: All PASS
- [ ] **Step 5: Commit**
```bash
git add src/lib/utils/colors.ts tests/unit/colors.test.ts
git commit -m "feat: add dark note card colors and getNoteColor helper"
```
---
### Task 5: FOUC Prevention Script
**Files:**
- Modify: `src/app.html`
- [ ] **Step 1: Add inline script to `app.html`**
Insert after the `<meta name="theme-color">` tag (line 6) and before `%sveltekit.head%` (line 15):
```html
<script>
(function() {
try {
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
var t = p.theme || 'system';
if (t !== 'light' && t !== 'dark') t = 'system';
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
}
} catch(e) {}
})();
</script>
```
- [ ] **Step 2: Commit**
```bash
git add src/app.html
git commit -m "feat: add FOUC-prevention script for dark mode"
```
---
### Task 6: Reactive Theme in Root Layout
**Files:**
- Modify: `src/routes/+layout.svelte`
- [ ] **Step 1: Add theme `$effect` to root layout**
The root layout currently imports CSS and registers the PWA service worker. Add the theme `$effect` here so both `(app)` and `(auth)` layouts get the theme applied. **Important:** Keep `initPreferences()` in `(app)/+layout.svelte` — do NOT move it here. The `(app)` layout reads `sidebarDefaultState` immediately after init, and moving init to the root layout would create a race condition. The root layout only watches the preference reactively via `$effect`, which fires after preferences are loaded.
On auth pages (login/setup), `initPreferences()` hasn't been called, so `prefs.theme` will be `'system'` (the default). The FOUC script already handles the initial theme from localStorage, and the `$effect` will apply the correct theme once it runs.
```svelte
<script lang="ts">
import '../app.css';
import { onMount, onDestroy } from 'svelte';
import { pwaInfo } from 'virtual:pwa-info';
import { browser } from '$app/environment';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import { applyTheme } from '$lib/utils/theme.svelte.js';
let { children } = $props();
let mediaQuery: MediaQueryList | undefined;
let systemListener: ((e: MediaQueryListEvent) => void) | undefined;
const prefs = $derived(getPreferences());
onMount(async () => {
if (pwaInfo) {
const { registerSW } = await import('virtual:pwa-register');
registerSW({
immediate: true,
onRegistered(r: ServiceWorkerRegistration | undefined) {
console.log('SW Registered:', r);
},
onRegisterError(error: Error) {
console.log('SW registration error', error);
}
});
}
});
$effect(() => {
if (!browser) return;
const theme = prefs.theme ?? 'system';
applyTheme(theme);
// Listen for system preference changes when in system mode
if (systemListener && mediaQuery) {
mediaQuery.removeEventListener('change', systemListener);
}
if (theme === 'system') {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
systemListener = () => applyTheme('system');
mediaQuery.addEventListener('change', systemListener);
}
});
onDestroy(() => {
if (systemListener && mediaQuery) {
mediaQuery.removeEventListener('change', systemListener);
}
});
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
<svelte:head>
{@html webManifest}
</svelte:head>
{@render children()}
```
**Note:** `initPreferences()` stays in `(app)/+layout.svelte`. The root layout only adds the theme `$effect` and does not call `initPreferences()`.
- [ ] **Step 2: Verify dev server works**
Run `make dev`, open the app, go to Settings > Preferences. The theme toggle doesn't exist yet, but the app should render without errors. Check browser console for any issues.
- [ ] **Step 4: Commit**
```bash
git add src/routes/+layout.svelte src/routes/\(app\)/+layout.svelte
git commit -m "feat: add reactive theme watcher in root layout"
```
---
### Task 7: Theme Toggle in Settings
**Files:**
- Modify: `src/routes/(app)/settings/preferences/+page.svelte`
- [ ] **Step 1: Add theme segmented toggle**
Add as the first preference section (before "Default note mode"):
```svelte
<!-- Theme -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
<div class="flex gap-1" role="group" aria-label="Theme">
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
<button
onclick={() => updatePreference('theme', value)}
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.theme === value ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
data-testid="pref-theme-{value}"
>
{label}
</button>
{/each}
</div>
</div>
```
- [ ] **Step 2: Manually test**
Run `make dev`. Go to Settings > Preferences. Click System / Light / Dark and confirm:
- Clicking "Dark" immediately applies dark theme
- Clicking "Light" reverts to light theme
- Clicking "System" follows OS preference
- Refreshing the page persists the choice (no FOUC)
- [ ] **Step 3: Commit**
```bash
git add src/routes/\(app\)/settings/preferences/+page.svelte
git commit -m "feat: add theme toggle to preferences settings"
```
---
### Task 8: Update Components to Use Dark Note Colors
**Files:**
- Modify: `src/lib/components/NoteCard.svelte:2,29-32`
- Modify: `src/lib/components/NoteEditor.svelte:12,194-197`
- Modify: `src/lib/components/ColorPicker.svelte`
**Important:** `getIsDarkMode()` must be called **inside** `$effect` or `$derived` blocks for reactivity. Calling it as a top-level `const` would capture the value once and never update when the theme changes. Each component already has an `$effect` block where it computes card/bg styles — add `getIsDarkMode()` inside those existing blocks.
Public share page (`src/routes/(share)/s/[token]/+page.svelte`) is out of scope per the spec.
- [ ] **Step 1: Update `NoteCard.svelte`**
Replace `NOTE_COLORS` import with `getNoteColor` and `getIsDarkMode`:
```svelte
<!-- Change import at top of script -->
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
<!-- Inside the existing $effect block where cardStyle is computed (around lines 29-32): -->
$effect(() => {
// ... existing logic ...
const bg = getNoteColor(note.color, getIsDarkMode());
cardStyle = `background-color: ${bg}`;
});
```
Remove the `NOTE_COLORS` import. `getIsDarkMode()` is called inside the `$effect` so it re-runs when the theme changes.
- [ ] **Step 2: Update `NoteEditor.svelte`**
Same pattern — inside the existing `$effect` where `bgStyle` is computed:
```svelte
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
<!-- Inside the existing $effect (around lines 194-197): -->
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
```
Remove the `NOTE_COLORS` import.
- [ ] **Step 3: Update `ColorPicker.svelte`**
The ColorPicker shows circular swatches. Use `$derived` for the dark mode check so swatches update reactively:
```svelte
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
<!-- Use getIsDarkMode() in the template directly (it's reactive in Svelte 5 template expressions): -->
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
```
Remove `NOTE_COLORS` import if no longer needed.
- [ ] **Step 4: Verify all components render correctly in both themes**
Run `make dev`. Toggle between light and dark in Settings > Preferences. Check:
- Note cards show correct dark/light background colors
- Color picker shows dark/light swatches
- Note editor uses correct background color
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/NoteCard.svelte src/lib/components/NoteEditor.svelte src/lib/components/ColorPicker.svelte
git commit -m "feat: use dark note card colors in all components"
```
---
### Task 9: E2E Tests
**Files:**
- Create: `tests/e2e/dark-mode.spec.ts`
- [ ] **Step 1: Write E2E tests**
Create `tests/e2e/dark-mode.spec.ts`:
```typescript
import { test, expect } from './helpers/fixtures.js';
test.describe('Dark mode', () => {
test('Scenario: Theme toggle persists selection across page reload', async ({
authenticatedPage: page
}) => {
// Given the user is on the preferences page
await page.goto('/settings/preferences');
// When the user selects dark theme
await page.getByTestId('pref-theme-dark').click();
// Then the page applies dark theme
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the page is reloaded
await page.reload();
// Then the dark theme persists without flash
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.getByTestId('pref-theme-dark')).toHaveClass(/font-medium/);
});
test('Scenario: Light mode removes dark theme attribute', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.getByTestId('pref-theme-dark').click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
// When the user selects light theme
await page.getByTestId('pref-theme-light').click();
// Then the dark theme attribute is removed
await expect(page.locator('html')).not.toHaveAttribute('data-theme');
});
test('Scenario: System mode follows OS dark preference', async ({
authenticatedPage: page
}) => {
// Given the OS prefers dark mode
await page.emulateMedia({ colorScheme: 'dark' });
// When the user selects system theme
await page.goto('/settings/preferences');
await page.getByTestId('pref-theme-system').click();
// Then the page applies dark theme from system preference
// Use toHaveAttribute with timeout to handle async $effect execution
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark', { timeout: 5000 });
});
test('Scenario: Note cards use dark colors in dark mode', async ({
authenticatedPage: page
}) => {
// Given dark theme is active
await page.goto('/settings/preferences');
await page.getByTestId('pref-theme-dark').click();
// When viewing the notes page
await page.goto('/');
// Then note cards should have dark background colors
const noteCard = page.getByTestId('note-card').first();
if (await noteCard.isVisible()) {
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
// Dark default color is #2a2520 = rgb(42, 37, 32)
expect(bg).not.toBe('rgb(250, 245, 235)'); // Not the light default
}
});
});
```
- [ ] **Step 2: Run E2E tests**
Run: `pnpm test:e2e tests/e2e/dark-mode.spec.ts`
Expected: All PASS
- [ ] **Step 3: Commit**
```bash
git add tests/e2e/dark-mode.spec.ts
git commit -m "test: add E2E tests for dark mode toggle and persistence"
```
---
### Task 10: Housekeeping and Type Check
**Files:**
- Modify: `.gitignore`
- [ ] **Step 1: Add `.superpowers/` to `.gitignore`**
Append to `.gitignore`:
```
# Superpowers brainstorm artifacts
.superpowers/
```
- [ ] **Step 2: Run type check**
Run: `pnpm check`
Expected: No errors. If there are type errors, fix them.
- [ ] **Step 3: Run full test suite**
Run: `make test`
Expected: All unit and E2E tests pass.
- [ ] **Step 4: Commit**
```bash
git add .gitignore
git commit -m "chore: add .superpowers/ to gitignore"
```
@@ -0,0 +1,190 @@
# Dark Mode / Theme Toggle — Design Spec
**Issue:** #26
**Date:** 2026-03-20
**Status:** Draft
## Summary
Add a dark variant of the retro parchment theme with a three-way toggle (system / light / dark) in Settings > Preferences. The dark theme preserves the retro 8-bit character — warm dark tones, not cold grays.
## Decisions
- **Toggle location:** Settings > Preferences only (no header icon)
- **UI pattern:** Segmented buttons (system / light / dark), matching existing `defaultNoteMode` toggle style
- **Implementation:** CSS `[data-theme="dark"]` attribute on `<html>`, variable overrides in `app.css`
- **Pixel grid texture:** Inverted to light dots (`#e8dcc8`) at 3% opacity in dark mode
- **Default:** `system` (follows `prefers-color-scheme`)
- **Light mode:** No `data-theme` attribute set (`:root` defaults apply). `data-theme="dark"` is only set for dark mode. This means no redundant `[data-theme="light"]` block is needed.
## 1. CSS Variable Overrides
Add `--grid-dot` to the existing `:root` block and add a `[data-theme="dark"]` block after it in `app.css`:
```css
:root {
/* ...existing vars... */
--grid-dot: #1a1a2e; /* new — extracted from body::before */
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #e8dcc8;
}
```
`--primary` and `--primary-hover` remain unchanged — gold works on both backgrounds.
`--card-shadow` uses `var(--border-subtle)` in both modes for consistency — the dark `--border-subtle` (#3a3530) produces the right effect.
Update `body::before` to use the new variable:
```css
body::before {
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
}
```
## 2. Theme Preference Storage
Extend the existing `UserPreferences` system:
- **Type change:** Add `theme: 'system' | 'light' | 'dark'` to `UserPreferences` interface
- **Default:** `'system'`
- **Storage:** Same key-value `userPreferences` table + localStorage cache — no schema migration needed
- **Do NOT add `theme` to `BOOLEAN_PREF_KEYS`** — it's a string enum, not a boolean
Files to modify:
- `src/lib/types/preferences.ts` — add `theme` field and default
The existing `fromServerRecord` in `preferences.svelte.ts` does not validate string values. The `applyTheme` function (Section 4) must guard against unexpected values and fall back to `'system'` if the value is not one of the three valid options.
## 3. FOUC Prevention
Add a blocking `<script>` in `app.html` `<head>`, **after** the `<meta name="theme-color">` tag and **before** `%sveltekit.head%`:
```html
<script>
(function() {
try {
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
var t = p.theme || 'system';
if (t !== 'light' && t !== 'dark') t = 'system';
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
}
} catch(e) {}
})();
</script>
```
This runs synchronously before first paint. The script validates the theme value and null-checks the meta element.
## 4. Reactive Theme Application
Create `src/lib/utils/theme.svelte.ts` (`.svelte.ts` to enable Svelte 5 runes) with:
- `applyTheme(theme: 'system' | 'light' | 'dark')` — resolves system preference, sets/removes `data-theme="dark"` on `document.documentElement`, updates `<meta name="theme-color">`. Guards against unexpected values (falls back to `'system'`). All DOM/`matchMedia` access guarded with `browser` check from `$app/environment`.
- `isDarkMode` — a reactive `$state` boolean, updated by `applyTheme` and the `matchMedia` listener. Components import this to conditionally use dark color variants.
Place the theme `$effect` in the **root `+layout.svelte`** (not just `(app)` layout). This ensures both `(app)` and `(auth)` layout groups get the theme applied. The root layout currently only imports CSS — this adds a small `$effect` that watches `preferences.theme` and calls `applyTheme`. It also registers a `matchMedia('(prefers-color-scheme: dark)')` change listener when in system mode, cleaned up on destroy.
## 5. Note Card Dark Colors
Add a parallel `NOTE_COLORS_DARK` map in `src/lib/utils/colors.ts`:
| Color | Light | Dark |
|-------|-------|------|
| default | `#faf5eb` | `#2a2520` |
| coral | `#faafa8` | `#4a2522` |
| peach | `#f39f76` | `#4a3020` |
| sand | `#fff8b8` | `#3a3520` |
| mint | `#e2f6d3` | `#2a3a22` |
| sage | `#b4ddd3` | `#223a32` |
| fog | `#d4e4ed` | `#222e3a` |
| storm | `#aeccdc` | `#1e2a35` |
| dusk | `#d3bfdb` | `#352540` |
| blossom | `#f6e2dd` | `#3a2830` |
| clay | `#e9e3d4` | `#302e28` |
| chalk | `#efeff1` | `#2e2e30` |
Export a helper `getNoteColor(color: NoteColor, isDark: boolean): string` that returns the correct bg hex.
Components (`NoteCard.svelte`, `NoteEditor.svelte`, `ColorPicker.svelte`) import `isDarkMode` from `theme.svelte.ts` and pass it to `getNoteColor`. This is the single approach — do not use direct DOM inspection for dark mode detection.
## 6. Settings UI
Add a "Theme" section to `src/routes/(app)/settings/preferences/+page.svelte` as the **first** preference (before "Default note mode"):
```svelte
<!-- Theme -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
<div class="flex gap-1" role="group" aria-label="Theme">
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
<button
onclick={() => updatePreference('theme', value)}
class="rounded-sm border px-4 py-2 text-sm transition-colors ..."
data-testid="pref-theme-{value}"
>
{label}
</button>
{/each}
</div>
</div>
```
Test IDs: `pref-theme-system`, `pref-theme-light`, `pref-theme-dark`.
## 7. Landing Page
Out of scope for this spec. The landing page (`website/`) uses its own color system separate from `app.css`. Dark mode support for the landing page will be tracked separately.
## 8. Prose / Typography Overrides
The `.prose` overrides in `app.css` already use CSS variables, so they'll automatically adapt. No changes needed.
The TipTap editor styles (table borders, selected cells, task lists) also use CSS variables. No changes needed.
## 9. Color Picker in Dark Mode
The `ColorPicker` component shows circular swatches. In dark mode, these should display the dark color variants. The component imports `isDarkMode` from `theme.svelte.ts` and uses `getNoteColor(color, isDarkMode)` to get the correct swatch background.
## 10. Testing
### Unit tests
- `theme.svelte.ts` — test `applyTheme` sets correct attribute and meta tag, validates unknown values
- `colors.ts` — test `getNoteColor` returns correct color for light/dark
### E2E tests
- Theme toggle in preferences persists and applies (use `data-testid` selectors)
- System preference detection (use Playwright's `page.emulateMedia({ colorScheme: 'dark' })`)
- No FOUC on page reload in dark mode
- Note cards render with correct dark colors
## 11. PWA Manifest
The PWA manifest (`@vite-pwa/sveltekit`) contains a static `theme_color`. This cannot be dynamically changed. The manifest will keep the light theme color (`#f0e6d3`). The `<meta name="theme-color">` is updated dynamically by the FOUC script and `applyTheme`, which handles the browser chrome color. This is a known limitation of PWA manifests and is acceptable.
## Out of Scope
- Header quick-toggle icon
- Per-note theme override
- Custom color theme builder
- Dark mode for public shared note pages (can be added later)
- Dark mode for landing page (separate task)
@@ -0,0 +1,232 @@
# Public Note Sharing — Design Spec
**Issue**: [#7 — Public links and shared notes](https://github.com/bretzel-app/crumbs/issues/7)
**Date**: 2026-03-20
## Overview
Allow sharing individual notes via a public URL that anyone can view without authentication. Read-only sharing via unguessable tokens. Integrates into the existing ShareDialog alongside collaborator management.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Share UI location | Inside existing ShareDialog | One "Share" entry point for all sharing (public + collaborators) |
| Expiry | Deferred to future version | Simpler v1; active/revoked toggle only |
| Rate limiting | In-memory per-IP | Lightweight, resets on restart; token entropy handles enumeration |
| Architecture | Separated shares-service | Follows collaborators-service pattern; clean module boundary |
| Sharing icon | Globe (public) or Users (collaborators) | Single icon slot; tooltip shows combined sharing state |
## Schema
New `sharedNotes` table in `src/lib/server/db/schema.ts`:
```typescript
export const sharedNotes = sqliteTable('shared_notes', {
noteId: text('note_id').primaryKey().references(() => notes.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }), // NULL = no expiry, reserved for future use
});
```
- `noteId` as primary key — one share per note, no separate `id` needed
- `integer` timestamps with `mode: 'timestamp'` — matches all other tables in the schema
- `createdAt` set in service layer (`new Date()`), not via SQL default
- CASCADE delete — deleting the note removes the share
- Token: `nanoid(21)` — ~128 bits of entropy
- No update to `deleteUser()` needed — shares cascade through notes (foreign keys enabled via `PRAGMA foreign_keys = ON` in db connection)
## Types
Add to `src/lib/types/index.ts`:
```typescript
export interface NoteShare {
noteId: string;
token: string;
createdAt: Date;
expiresAt: Date | null;
}
```
Extend the `Note` interface:
```typescript
export interface Note {
// ...existing fields
shareToken?: string; // populated during hydration if a public share exists
}
```
## Service Layer
New file: `src/lib/server/shares-service.ts`
| Function | Params | Returns | Description |
|---|---|---|---|
| `createShare` | `db, noteId, userId` | `{ token }` | Verify ownership, generate nanoid(21), insert row, return token. If share already exists, return existing token. |
| `revokeShare` | `db, noteId, userId` | `void` | Verify ownership, delete share row |
| `getShareByNoteId` | `db, noteId` | `NoteShare \| null` | Return share if exists (for ShareDialog) |
| `getSharedNote` | `db, token` | `SharedNoteData \| null` | Public — fetch note by token, return null if trashed or not found |
| `fetchSharesForNotes` | `db, noteIds` | `Map<string, string>` | Batch fetch — returns Map of noteId → token for hydration (only token needed for NoteCard indicator) |
`SharedNoteData` type (returned to public viewers):
```typescript
interface SharedNoteData {
title: string;
content: string;
checklistMode: boolean;
color: NoteColor;
attachments: Attachment[];
createdAt: Date;
updatedAt: Date;
}
```
## API Endpoints
### `POST /api/notes/[id]/share`
- **Auth**: Required (owner only)
- **Body**: None
- **Response**: `{ token: string, url: string }`
- **Behavior**: Creates share or returns existing one. URL constructed from request origin + `/s/${token}`
### `DELETE /api/notes/[id]/share`
- **Auth**: Required (owner only)
- **Response**: `204 No Content`
- **Behavior**: Deletes share row. Link immediately returns 404.
### `GET /api/shared/[token]`
- **Auth**: None (public)
- **Rate limit**: 60 requests/min per IP (in-memory)
- **Response**: `SharedNoteData` JSON
- **Error cases**:
- Invalid/unknown token → 404
- Note is trashed → 404
- Rate limited → 429
### `GET /api/shared/[token]/attachment/[attachmentId]`
- **Auth**: None (public)
- **Rate limit**: Same as above
- **Response**: Image binary (proxied from attachment storage)
- **Behavior**: Verify attachment belongs to the shared note, then serve
## Routes
```
src/routes/
(share)/
+layout.svelte # Minimal layout: parchment bg, centered, no app chrome
s/[token]/
+page.server.ts # Load shared note by token via shares-service
+page.svelte # Read-only note renderer
```
### Auth Middleware
Add `/s/` and `/api/shared` to `PUBLIC_PATHS` in `src/hooks.server.ts`. Both are needed — `/s/` for the public page route, `/api/shared` for the public API endpoints.
### Public Page Design
- Parchment background (`--bg-base`)
- Centered content card (`--bg-surface`, `--border-subtle`, `--card-shadow`)
- Note title in JetBrains Mono
- Rendered markdown content or checklist (read-only)
- Inline images from attachments
- No tags, no edit controls, no sidebar, no navigation
- Footer: "POWERED BY CRUMBS" in Press Start 2P pixel font, links to project
## UI Components
### ShareDialog Changes
Add a "Public link" section **above** the existing collaborators section:
**Inactive state:**
- Globe icon + "Public link" label + toggle switch (off)
- Subtitle: "Anyone with the link can view this note (read-only)"
**Active state:**
- Globe icon + "Public link" label + toggle switch (on, gold)
- URL input (readonly) + "Copy" button
- Subtitle: "Toggle off to revoke access. The link will return a 404."
- Border highlight in `--primary` gold
Toggle on → calls `POST /api/notes/[id]/share`, displays URL
Toggle off → calls `DELETE /api/notes/[id]/share`, hides URL
Copy button → clipboard API, brief "Copied!" feedback
### NoteCard Share Indicator
Single icon slot in top-right of NoteCard (replaces showing both icons):
| State | Icon | Color |
|---|---|---|
| Public link active | `Globe` (Lucide) | `--primary` gold |
| Collaborators only | `Users` (Lucide) | `--text-muted` |
| Both | `Globe` (Lucide) | `--primary` gold |
| Neither | No icon | — |
**Hover tooltip** shows combined sharing info regardless of which icon is displayed:
- "Shared publicly via link" (if public share exists)
- "Shared with N collaborator(s)" (if collaborators exist)
- Both lines if both exist
### NoteEditor Share Button
Existing Share button behavior unchanged. The Globe/Users icon logic from NoteCard also applies to the editor toolbar button to indicate current sharing state.
## Rate Limiting
New file: `src/lib/server/ip-rate-limit.ts` — separate from the existing DB-backed `rate-limit.ts` (which is login-specific).
```typescript
// In-memory store: Map<IP, { count: number, windowStart: number }>
// 60 requests per minute per IP
// Applied to GET /api/shared/[token] and GET /api/shared/[token]/attachment/[id]
```
Resets on server restart. No database table needed.
## Hydration
`hydrateNotes()` in `notes-service.ts` must be updated to call `fetchSharesForNotes()` and populate `shareToken` on each note. This follows the existing pattern where `hydrateNotes` already batch-fetches tags, attachments, and collaborators.
## Security
- Tokens are unguessable: nanoid(21) = ~128 bits of entropy
- `/s/` and `/api/shared` routes bypass auth middleware — only these prefixes
- Trashed notes return 404 even if share token is valid
- Only note owners can create/revoke shares — collaborators cannot
- No user information exposed on public page (no username, no email, no tags)
- Rate limiting prevents brute-force enumeration
- Attachments served through share-specific route (validates attachment belongs to shared note)
## Out of Scope (v1)
- Expiry dates / auto-expire
- Password-protected shares
- Edit access through share links
- Share analytics / view counts
- Email notifications for share events
- E2EE interaction (no E2EE exists)
## Acceptance Criteria
- [ ] Share toggle in ShareDialog creates/revokes public link
- [ ] Unique nanoid(21) token generated per shared note
- [ ] Public `/s/[token]` route renders note without auth
- [ ] Copy link to clipboard with feedback
- [ ] Revoke share → link returns 404
- [ ] Globe indicator on NoteCard for publicly shared notes
- [ ] Combined tooltip showing all sharing state
- [ ] Trashed notes return 404 even if shared
- [ ] Minimal, parchment-styled public page with "Powered by Crumbs"
- [ ] In-memory rate limiting on public endpoints
- [ ] Attachments accessible on public page
@@ -0,0 +1,322 @@
# Screenshot Automation for Landing Page
## Overview
A Playwright-based script that seeds the Crumbs app with bretzel-themed demo data, captures 10 screenshots (7 desktop + 3 mobile) for the landing page, and commits them. Triggered manually via a GitHub Actions workflow.
## Architecture
```
scripts/
screenshots.ts # Main orchestrator: seed + capture
fixtures/
bretzel-pixel.ts # Generates a pixel-art bretzel PNG programmatically
```
Single script (`scripts/screenshots.ts`) that:
1. Spawns the preview server as a child process (`pnpm preview --port 4173`), same approach as `playwright.config.ts` uses for e2e tests. The app uses `@sveltejs/adapter-node`, so `vite preview` import won't work — must spawn via CLI.
2. Deletes any existing screenshot DB to start fresh
3. Seeds data via API calls (setup admin, login, create notes/users/keys)
4. Navigates to each view and captures screenshots at specific viewports
5. Saves PNGs directly to `website/assets/`
6. Kills the preview server in `finally` block
## Seed Data — Bretzel Universe
### Users
| User | Email | Role | Purpose |
|------|-------|------|---------|
| Bretzel Admin | admin@bretzel.app | admin | Main user, owns all notes |
| Alice Chen | alice@bretzel.app | user | Collaborator for sharing screenshot |
### Tags
Passed explicitly in the `tags` array of each `POST /api/notes` request body: `baking`, `bretzel`, `dev`, `devops`, `homelab`, `personal`, `reading`, `weekend`. Tags are NOT auto-parsed from hashtags in content — they must be provided as a separate field.
### Notes
| # | Title | Color | Mode | Tags | Pinned | Special |
|---|-------|-------|------|------|--------|---------|
| 1 | Bretzel Ingredients | sand | checklist | #baking, #bretzel | yes | Checklist items (see below) |
| 2 | Self-Hosting Stack | fog | rich text | #homelab, #devops | no | Homelab list (see below) |
| 3 | Back to Retro Gaming | peach | rich text | #reading, #personal | no | Retro gaming note |
| 4 | The Best Software Is the Software You Own | coral | rich text | #personal, #bretzel | no | Self-hosting manifesto |
| 5 | Weekend Plans | mint | rich text | #personal, #weekend | no | Casual weekend plans |
| 6 | Ancient Flute Harmonica Discovered | chalk | rich text | #reading | no | Quirky news note |
| 7 | Baking code for the... | clay | checklist | #dev, #bretzel | no | Dev/baking crossover |
### Note Content Details
**Bretzel Ingredients** (checklist):
- [x] 500g bread flour
- [x] 300ml warm water
- [x] 10g salt
- [ ] 7g dry yeast
- [ ] 30g butter (softened)
- [ ] Baking soda for the bath
- [ ] Coarse salt for topping
**Self-Hosting Stack** (rich text — used for editor screenshot):
```
Current homelab setup:
**Crumbs** — notes (obviously)
**42** — holiday budget tracking
**Dokploy** — deployment platform
**Outline** — team wiki
**Navidrome** — music streaming
**Vaultwarden** — passwords
**Nextcloud** — files & calendar
**Plausible** — analytics
Everything below daily reverse proxy.
```
**The Best Software Is the Software You Own** (rich text):
```
Self-host everything. Trust no cloud.
The best software is the software you own.
Your data. Your server. Your rules.
If it can't run on your hardware, it's not really yours.
```
**Back to Retro Gaming** (rich text):
```
Games to replay this summer:
Chrono Trigger (SNES) — best RPG ever made
The Legend of Zelda: Link's Awakening (GB)
Castlevania: Symphony of the Night (PS1)
Advance Wars (GBA)
Final Fantasy Tactics (PS1)
The pixel art era was peak game design.
```
**Weekend Plans** (rich text):
```
Saturday:
- Farmer's market (get sourdough starter)
- Fix the Dokploy SSL cert renewal
- Bake a batch of bretzels
Sunday:
- Retro gaming marathon
- Update the self-hosting stack
- Plan next trip on 42
```
**Ancient Flute Harmonica Discovered** (rich text):
```
Archaeologists found a 40,000-year-old flute carved from vulture bone.
The oldest known musical instrument.
Imagine the first song ever played.
```
**Baking code for the...** (checklist):
```
- [x] Set up CI/CD pipeline
- [x] Write the CRDT sync logic
- [ ] Bake the docker image
- [ ] Deploy to the homelab
- [ ] Celebrate with a bretzel
```
### Image Attachments
**Pixel-art bretzel image** (`scripts/fixtures/bretzel-pixel.ts`):
- Generated programmatically as a PNG using raw pixel data (no external dependencies)
- ~64x64 pixel grid, bretzel shape in warm browns (#C8860A gold, #8B6914 dark, #F0E6D3 parchment bg)
- Attached as featured image to "Bretzel Ingredients" note
- Also create a second smaller image attached to the same note (e.g. a pixel-art salt shaker or oven) for visual variety
### Version History
Send 4-5 `PATCH /api/notes/:id` calls to "Self-Hosting Stack" with varying content. Version history entries are created as a side-effect of each PATCH — there is no explicit "create version" endpoint. Each version adds or changes a tool in the stack, so the diff is meaningful when viewed in the history panel.
### API Keys
Create via API after seeding:
| Name | Purpose |
|------|---------|
| Claude Code | For MCP integration screenshot |
| N8N | For automation screenshot |
## Screenshot Capture Plan
### Desktop (1280x800)
| # | Filename | View | Capture Steps |
|---|----------|------|---------------|
| 1 | screenshot-grid.png | Home grid | Navigate to `/`, wait for notes to render, ensure sidebar is expanded with tags visible |
| 2 | screenshot-editor.png | Note editor | Click "Self-Hosting Stack" note, wait for editor to open with content and toolbar visible |
| 3 | screenshot-checklist.png | Checklist mode | Click "Bretzel Ingredients" note, wait for checklist items with checkboxes visible |
| 4 | screenshot-sharing.png | Share dialog | Open a note, click share button, wait for ShareDialog with "Alice Chen" listed as collaborator |
| 5 | screenshot-history.png | Version history | Open "Self-Hosting Stack", click history button, select a middle version in the list, wait for preview panel |
| 6 | screenshot-attachments.png | Attachments | Open "Bretzel Ingredients" note (has featured bretzel image), ensure image thumbnails and featured strip visible |
| 7 | screenshot-api.png | Settings > API/MCP | Navigate to `/settings/mcp`, wait for API keys list ("Claude Code", "N8N") and MCP config JSON block |
### Mobile (390x844)
| # | Filename | View | Capture Steps |
|---|----------|------|---------------|
| 1 | screenshot-mobile-grid.png | Home grid | Navigate to `/`, wait for notes in mobile layout (no sidebar, tags as horizontal pills) |
| 2 | screenshot-mobile-editor.png | Note editor | Open "Self-Hosting Stack" note, wait for mobile editor |
| 3 | screenshot-mobile-checklist.png | Checklist | Open "Bretzel Ingredients" note, wait for mobile checklist view |
### Capture Settings
- Format: PNG
- Full page: false (viewport only)
- Animations disabled via `page.emulateMedia({ reducedMotion: 'reduce' })` to avoid capturing mid-animation
- Wait for `networkidle` after each navigation before capturing
## GitHub Actions Workflow
**File**: `.github/workflows/screenshots.yml`
```yaml
name: Screenshots
on:
workflow_dispatch:
permissions:
contents: write
jobs:
screenshots:
name: Capture Landing Page Screenshots
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Install Playwright browsers and system deps
run: pnpm exec playwright install --with-deps chromium
- name: Build app
run: pnpm build
env:
DATABASE_URL: ./data/screenshots.db
- name: Capture screenshots
run: pnpm exec tsx scripts/screenshots.ts
env:
DATABASE_URL: ./data/screenshots.db
- name: Commit screenshots
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add website/assets/screenshot-*.png
git diff --cached --quiet && echo "No screenshot changes" && exit 0
git commit -m "chore: update landing page screenshots [skip ci]"
git push origin main
```
## Script Structure
```typescript
// scripts/screenshots.ts
import { chromium, type Page } from 'playwright';
import { spawn, type ChildProcess } from 'child_process';
import { generateBretzelPixelArt } from './fixtures/bretzel-pixel';
const BASE = 'http://localhost:4173';
const DESKTOP = { width: 1280, height: 800 };
const MOBILE = { width: 390, height: 844 };
const OUTPUT = 'website/assets';
async function main() {
// 1. Start preview server (spawn child process, wait for port 4173)
const server = await startPreviewServer();
// 2. Launch browser
const browser = await chromium.launch();
try {
// 3. Seed data (desktop context — API calls work at any viewport)
const seedContext = await browser.newContext({ viewport: DESKTOP });
const seedPage = await seedContext.newPage();
await seed(seedPage);
// 4. Desktop screenshots
await captureDesktop(seedPage);
await seedContext.close();
// 5. Mobile screenshots
const mobileContext = await browser.newContext({ viewport: MOBILE });
const mobilePage = await mobileContext.newPage();
await login(mobilePage);
await captureMobile(mobilePage);
await mobileContext.close();
} finally {
await browser.close();
server.close();
}
}
async function seed(page: Page) {
// Setup admin via POST /api/auth/setup
// Login via POST /api/auth/login (sets session cookie on page context)
// Create notes via POST /api/notes (with content, color, tags:string[], pinned, checklistMode)
// → capture note IDs from responses for later use
// Create collaborator user via POST /api/admin/users
// → capture alice.id (numeric) from response
// Share a note via POST /api/notes/:id/collaborators { userId: alice.id }
// Create version history: PATCH /api/notes/:id × 4-5 with varying content
// Upload pixel-art bretzel image via POST /api/notes/:id/attachments (multipart form)
// Create API keys via POST /api/settings/api-keys { name: "Claude Code" } and { name: "N8N" }
}
async function captureDesktop(page: Page) {
// D1: Grid view
// D2: Editor (Self-Hosting Stack)
// D3: Checklist (Bretzel Ingredients)
// D4: Sharing dialog
// D5: Version history
// D6: Attachments
// D7: API/MCP settings
}
async function captureMobile(page: Page) {
// M1: Grid view
// M2: Editor
// M3: Checklist
}
```
## Landing Page Updates
After the screenshots are generated, `website/index.html` needs updating:
- Add new screenshot entries for: checklist, sharing, history, attachments, API
- Update alt text and labels
- Hero image stays as `screenshot-grid.png`
- Mobile section adds checklist screenshot, replaces sidebar screenshot
## Dependencies
No new dependencies. Uses:
- `playwright` (already in devDependencies for e2e tests)
- `tsx` (already in devDependencies for other scripts)
- Node.js built-in `fs`, `path`, `Buffer` for pixel-art PNG generation
- SvelteKit's preview server (spawned as child process via `pnpm preview`)
## Open Questions
None — all decisions resolved during brainstorming.
+67
View File
@@ -0,0 +1,67 @@
CREATE TABLE `attachments` (
`id` text PRIMARY KEY NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `note_tags` (
`note_id` text NOT NULL,
`tag_id` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_tags_note_id_idx` ON `note_tags` (`note_id`);--> statement-breakpoint
CREATE INDEX `note_tags_tag_id_idx` ON `note_tags` (`tag_id`);--> statement-breakpoint
CREATE TABLE `notes` (
`id` text PRIMARY KEY NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL
);
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE `login_attempts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`ip` text NOT NULL,
`email` text NOT NULL,
`success` integer NOT NULL,
`timestamp` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint
DROP INDEX `tags_name_unique`;--> statement-breakpoint
ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text DEFAULT '' NOT NULL,
`display_name` text DEFAULT '' NOT NULL,
`role` text DEFAULT 'user' NOT NULL,
`password_hash` text,
`auth_provider` text DEFAULT 'password' NOT NULL,
`provider_id` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint
ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint
ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id);
+114
View File
@@ -0,0 +1,114 @@
CREATE TABLE `api_keys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`key_hash` text NOT NULL,
`key_prefix` text NOT NULL,
`created_at` integer NOT NULL,
`last_used_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `note_collaborators` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`added_by` integer NOT NULL,
`added_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
CREATE TABLE `note_user_state` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
CREATE TABLE `user_preferences` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_attachments` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`featured` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
DROP TABLE `attachments`;--> statement-breakpoint
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_notes` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
DROP TABLE `notes`;--> statement-breakpoint
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `__new_sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
DROP TABLE `sync_log`;--> statement-breakpoint
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `__new_tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
DROP TABLE `tags`;--> statement-breakpoint
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE `note_versions` (
`id` text PRIMARY KEY NOT NULL,
`note_id` text NOT NULL,
`version` integer NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`checklist_mode` integer DEFAULT false NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_versions_note_id_idx` ON `note_versions` (`note_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `note_versions_note_id_version_unique` ON `note_versions` (`note_id`,`version`);--> statement-breakpoint
CREATE TABLE `shared_notes` (
`note_id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shared_notes_token_unique` ON `shared_notes` (`token`);
+465
View File
@@ -0,0 +1,465 @@
{
"version": "6",
"dialect": "sqlite",
"id": "682b02aa-a28d-4904-8996-cbc4d740f626",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_unique": {
"name": "tags_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+684
View File
@@ -0,0 +1,684 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3",
"prevId": "682b02aa-a28d-4904-8996-cbc4d740f626",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"featured": {
"name": "featured",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_user_id_users_id_fk": {
"name": "attachments_user_id_users_id_fk",
"tableFrom": "attachments",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"login_attempts": {
"name": "login_attempts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"success": {
"name": "success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"login_attempts_ip_timestamp_idx": {
"name": "login_attempts_ip_timestamp_idx",
"columns": [
"ip",
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_user_id_idx": {
"name": "notes_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {
"notes_user_id_users_id_fk": {
"name": "notes_user_id_users_id_fk",
"tableFrom": "notes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_user_id_users_id_fk": {
"name": "sync_log_user_id_users_id_fk",
"tableFrom": "sync_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_user_unique": {
"name": "tags_name_user_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"tags_user_id_users_id_fk": {
"name": "tags_user_id_users_id_fk",
"tableFrom": "tags",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'password'"
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1773000424830,
"tag": "0000_wooden_vargas",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773016788899,
"tag": "0001_strange_lord_hawal",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1773150888301,
"tag": "0002_deep_black_widow",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1774037379518,
"tag": "0003_uneven_tomorrow_man",
"breakpoints": true
}
]
}
+67 -30
View File
@@ -1,18 +1,29 @@
{
"name": "crumbs",
"private": true,
"version": "0.3.3",
"version": "0.26.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
"repository": {
"type": "git",
"url": "https://github.com/bretzel-app/crumbs.git"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"build": "npx tsx scripts/generate-openapi.ts && vite build",
"openapi": "npx tsx scripts/generate-openapi.ts",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:coverage": "VITE_COVERAGE=true pnpm build && pnpm test:e2e && nyc report",
"coverage:merge-e2e": "nyc merge coverage/e2e/raw coverage/e2e/merged.json && nyc report",
"test": "pnpm test:unit && pnpm test:e2e",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
@@ -22,43 +33,63 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"@tiptap/core": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-task-item": "^3.20.0",
"@tiptap/extension-task-list": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"argon2": "^0.43.0",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"idb": "^8.0.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-table-cell": "^3.20.1",
"@tiptap/extension-table-header": "^3.20.1",
"@tiptap/extension-table-row": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.0.0",
"markdown-it": "^14.1.1",
"nanoid": "^5.1.7",
"node-diff3": "^3.2.0",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.0.0"
"uuid": "^11.1.0",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@sveltejs/kit": "^2.50.2",
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/markdown-it": "^14.0.0",
"@tailwindcss/vite": "^4.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^7.0.11",
"@types/uuid": "^10.0.0",
"@vite-pwa/sveltekit": "^1.0.0",
"drizzle-kit": "^0.31.0",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.9",
"jsdom": "^29.0.1",
"nyc": "^18.0.0",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^3.0.0",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.2.4",
"workbox-window": "^7.4.0",
"yaml": "^2.8.2"
},
"pnpm": {
@@ -66,6 +97,12 @@
"argon2",
"better-sqlite3",
"esbuild"
]
],
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
}
}
}
+14 -10
View File
@@ -1,37 +1,41 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: './tests/e2e/global-setup.ts',
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
retries: 0,
workers: process.env.CI ? 4 : undefined,
reporter: 'html',
globalSetup: './tests/e2e/global-setup.ts',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
screenshot: 'only-on-failure',
serviceWorkers: 'block'
},
projects: [
{
name: 'setup',
testMatch: /auth\.spec\.ts/,
name: 'auth-setup',
testMatch: 'auth.spec.ts',
fullyParallel: false,
use: { ...devices['Desktop Chrome'] }
},
{
name: 'chromium',
testIgnore: /auth\.spec\.ts/,
dependencies: ['setup'],
name: 'app',
testIgnore: 'auth.spec.ts',
dependencies: ['auth-setup'],
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'pnpm build && pnpm preview --port 4173',
command: 'pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
DATABASE_URL: './data/test-crumbs.db'
DATABASE_URL: './data/test-crumbs.db',
NODE_ENV: 'test'
}
}
});
+2194 -527
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Writes a coverage summary table to $GITHUB_STEP_SUMMARY (or stdout if not in CI).
# Usage: scripts/coverage-summary.sh <title> <json-summary-path>
set -euo pipefail
TITLE="${1:?Usage: coverage-summary.sh <title> <json-summary-path>}"
JSON="${2:?Usage: coverage-summary.sh <title> <json-summary-path>}"
if [ ! -f "$JSON" ]; then
echo "Coverage summary not found: $JSON" >&2
exit 0
fi
# Resolve to absolute path for require()
ABS_JSON="$(cd "$(dirname "$JSON")" && pwd)/$(basename "$JSON")"
# Extract totals with node (available in CI, no jq dependency)
read -r stmts branches funcs lines < <(node -e "
const s = require('$ABS_JSON').total;
console.log(s.statements.pct, s.branches.pct, s.functions.pct, s.lines.pct);
")
TABLE="## $TITLE
| Metric | Coverage |
|--------|----------|
| Statements | ${stmts}% |
| Branches | ${branches}% |
| Functions | ${funcs}% |
| Lines | ${lines}% |
"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
else
echo "$TABLE"
fi
+147
View File
@@ -0,0 +1,147 @@
/**
* Auto-generates an OpenAPI spec from the SvelteKit route tree.
*
* Scans all +server.ts files under src/routes/api/ for exported HTTP methods and
* builds a minimal OpenAPI 3.1 spec at static/.well-known/openapi.json.
*
* Usage: npx tsx scripts/generate-openapi.ts
*/
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
import { resolve, relative, dirname, join } from 'path';
const ROOT = process.cwd();
const ROUTES_DIR = resolve(ROOT, 'src/routes/api');
const OUTPUT = resolve(ROOT, 'static/api/openapi.json');
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'));
function walkDir(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...walkDir(full));
} else if (entry === '+server.ts') {
results.push(full);
}
}
return results;
}
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
const METHOD_RE = new RegExp(`export\\s+const\\s+(${HTTP_METHODS.join('|')})`, 'g');
function svelteKitPathToOpenAPI(filePath: string): string {
const rel = relative(ROUTES_DIR, dirname(filePath));
const segments = rel === '' ? [] : rel.split('/');
const apiPath =
'/api/' +
segments
.map((s) => {
const match = s.match(/^\[(.+)\]$/);
return match ? `{${match[1]}}` : s;
})
.join('/');
return apiPath;
}
function extractMethods(filePath: string): string[] {
const content = readFileSync(filePath, 'utf-8');
const methods: string[] = [];
let match;
while ((match = METHOD_RE.exec(content)) !== null) {
methods.push(match[1].toLowerCase());
}
METHOD_RE.lastIndex = 0;
return methods;
}
function inferTag(path: string): string {
const first = path.replace('/api/', '').split('/')[0];
return first.charAt(0).toUpperCase() + first.slice(1);
}
function extractPathParams(path: string) {
const params: { name: string; in: string; required: boolean; schema: { type: string } }[] = [];
const re = /\{(\w+)\}/g;
let match;
while ((match = re.exec(path)) !== null) {
params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } });
}
return params;
}
const files = walkDir(ROUTES_DIR);
const paths: Record<string, Record<string, unknown>> = {};
for (const fullPath of files) {
const apiPath = svelteKitPathToOpenAPI(fullPath);
const methods = extractMethods(fullPath);
if (methods.length === 0) continue;
const pathParams = extractPathParams(apiPath);
const pathEntry: Record<string, unknown> = {};
if (pathParams.length > 0) {
pathEntry.parameters = pathParams;
}
for (const method of methods) {
const operation: Record<string, unknown> = {
tags: [inferTag(apiPath)],
responses: {
'200': { description: 'Success' }
}
};
if (['post', 'put', 'patch'].includes(method)) {
operation.requestBody = {
content: {
'application/json': {
schema: { type: 'object' }
}
}
};
}
pathEntry[method] = operation;
}
paths[apiPath] = pathEntry;
}
const sortedPaths: Record<string, Record<string, unknown>> = {};
for (const key of Object.keys(paths).sort()) {
sortedPaths[key] = paths[key];
}
const spec = {
openapi: '3.1.0',
info: {
title: `${pkg.name.charAt(0).toUpperCase() + pkg.name.slice(1)} API`,
description: `Auto-generated API spec for ${pkg.name}`,
version: pkg.version,
contact: {
name: 'Bretzel',
url: 'https://bretzel.app'
}
},
servers: [{ url: '/', description: 'Current instance' }],
security: [{ sessionCookie: [] }],
paths: sortedPaths,
components: {
securitySchemes: {
sessionCookie: {
type: 'apiKey',
in: 'cookie',
name: 'session'
}
}
}
};
mkdirSync(dirname(OUTPUT), { recursive: true });
writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + '\n');
console.log(`Generated ${relative(ROOT, OUTPUT)} with ${Object.keys(paths).length} paths`);
+9 -1
View File
@@ -60,7 +60,15 @@ echo "Bumping $BUMP version..."
npm version "$BUMP" -m "chore: bump version to %s" --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
git add package.json
# Update version in marketing website
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
else
sed -i "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
fi
git add package.json website/index.html
git commit -m "chore: bump version to $NEW_VERSION"
git tag "v$NEW_VERSION"
+110
View File
@@ -0,0 +1,110 @@
import { chromium } from '@playwright/test';
import { spawn, type ChildProcess } from 'child_process';
import { existsSync, unlinkSync, mkdirSync } from 'fs';
import { seed, login } from './screenshots/seed';
import { captureDesktop, captureMobile } from './screenshots/capture';
import {
BASE_URL,
SCREENSHOT_DB,
DESKTOP_VIEWPORT,
MOBILE_VIEWPORT,
OUTPUT_DIR
} from './screenshots/constants';
function cleanDatabase(): void {
for (const ext of ['', '-journal', '-wal', '-shm']) {
const path = `${SCREENSHOT_DB}${ext}`;
if (existsSync(path)) {
unlinkSync(path);
console.log(` deleted ${path}`);
}
}
}
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok || res.status === 302) return;
} catch {
// Server not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Server not reachable at ${url} after ${timeoutMs}ms`);
}
function startServer(): Promise<ChildProcess> {
return new Promise((resolve, reject) => {
const dir = SCREENSHOT_DB.substring(0, SCREENSHOT_DB.lastIndexOf('/'));
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const server = spawn('pnpm', ['preview', '--port', '4173'], {
env: { ...process.env, DATABASE_URL: SCREENSHOT_DB, NODE_ENV: 'production' },
stdio: 'pipe'
});
server.on('error', reject);
server.on('exit', (code) => {
if (code !== null && code !== 0) reject(new Error(`Server exited with code ${code}`));
});
// Poll until server responds (stdout detection is unreliable in CI)
waitForServer(BASE_URL, 60_000)
.then(() => resolve(server))
.catch(reject);
});
}
async function main(): Promise<void> {
console.log('Screenshot automation starting...\n');
// 1. Clean DB
console.log('Cleaning database...');
cleanDatabase();
// 2. Ensure output dir exists
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
// 3. Start preview server
console.log('Starting preview server...');
const server = await startServer();
console.log('Server ready.\n');
const browser = await chromium.launch();
try {
// 4. Seed data (in desktop context)
console.log('Seeding data...');
const desktopContext = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
const desktopPage = await desktopContext.newPage();
await seed(desktopPage);
console.log('Seed complete.\n');
// 5. Desktop screenshots
await captureDesktop(desktopPage);
await desktopContext.close();
console.log('');
// 6. Mobile screenshots (fresh context with mobile viewport)
const mobileContext = await browser.newContext({ viewport: MOBILE_VIEWPORT });
const mobilePage = await mobileContext.newPage();
await login(mobilePage);
await captureMobile(mobilePage);
await mobileContext.close();
console.log('\nAll screenshots captured successfully!');
} finally {
await browser.close();
server.kill('SIGTERM');
// Force exit — pnpm spawns vite as a grandchild process that
// server.kill() doesn't reach, leaving the CI job hanging
process.exit(0);
}
}
main().catch((err) => {
console.error('Screenshot automation failed:', err);
process.exit(1);
});
+103
View File
@@ -0,0 +1,103 @@
import type { Page, Locator } from '@playwright/test';
import { join } from 'path';
import { BASE_URL, OUTPUT_DIR } from './constants';
async function screenshot(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(OUTPUT_DIR, name), type: 'png' });
console.log(` captured ${name}`);
}
async function waitForApp(page: Page): Promise<void> {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(300);
}
/** Click a note card by its title (targets the h3 inside note-card to avoid content substring matches) */
function noteCard(page: Page, title: string): Locator {
return page.locator('[data-testid="note-card"]', { has: page.locator('h3', { hasText: title }) });
}
export async function captureDesktop(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: 'reduce' });
console.log('Capturing desktop screenshots...');
// D1: Grid view (also used as hero)
await page.goto(BASE_URL);
await waitForApp(page);
await screenshot(page, 'screenshot-grid.png');
// D2: Rich text editor — open "Self-Hosting Stack"
await noteCard(page, 'Self-Hosting Stack').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-editor.png');
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D3: Checklist — open "Bretzel Ingredients"
await noteCard(page, 'Bretzel Ingredients').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-checklist.png');
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D4: Sharing dialog — open "Weekend Plans", click share
await noteCard(page, 'Weekend Plans').click();
await page.waitForSelector('[data-testid="note-editor"]');
await page.getByTestId('share-toggle').click();
await page.waitForSelector('[data-testid="share-dialog"]');
await waitForApp(page);
await screenshot(page, 'screenshot-sharing.png');
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D5: Version history — open "Self-Hosting Stack", click history
await noteCard(page, 'Self-Hosting Stack').click();
await page.waitForSelector('[data-testid="note-editor"]');
await page.getByTestId('history-toggle').click();
await page.waitForSelector('[data-testid="history-panel"]');
// Wait for version items to load from API
await page.waitForSelector('[data-testid="version-item"]', { timeout: 10_000 });
const versionItems = page.getByTestId('version-item');
if ((await versionItems.count()) > 1) {
await versionItems.nth(1).click();
}
await waitForApp(page);
await screenshot(page, 'screenshot-history.png');
// Close history panel first (overlay blocks close-editor-btn)
await page.getByTestId('close-history-btn').click();
await waitForApp(page);
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// D6: API/MCP settings
await page.goto(`${BASE_URL}/settings/mcp`);
await waitForApp(page);
await screenshot(page, 'screenshot-api.png');
}
export async function captureMobile(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: 'reduce' });
console.log('Capturing mobile screenshots...');
// M1: Mobile grid view
await page.goto(BASE_URL);
await waitForApp(page);
await screenshot(page, 'screenshot-mobile-grid.png');
// M2: Mobile editor — open "Self-Hosting Stack"
await noteCard(page, 'Self-Hosting Stack').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-mobile-editor.png');
await page.getByTestId('close-editor-btn').click();
await waitForApp(page);
// M3: Mobile checklist — open "Bretzel Ingredients"
await noteCard(page, 'Bretzel Ingredients').click();
await page.waitForSelector('[data-testid="note-editor"]');
await waitForApp(page);
await screenshot(page, 'screenshot-mobile-checklist.png');
}
+18
View File
@@ -0,0 +1,18 @@
export const BASE_URL = 'http://localhost:4173';
export const SCREENSHOT_DB = './data/screenshots.db';
export const OUTPUT_DIR = 'website/assets';
export const DESKTOP_VIEWPORT = { width: 1280, height: 800 };
export const MOBILE_VIEWPORT = { width: 390, height: 844 };
export const ADMIN = {
email: 'admin@bretzel.app',
displayName: 'Bretzel Admin',
password: 'bretzel2026!'
};
export const COLLABORATOR = {
email: 'salzig@bretzel.app',
displayName: 'Salzig Bretzel',
password: 'salzig2026!!'
};
+271
View File
@@ -0,0 +1,271 @@
import type { Page } from '@playwright/test';
import { BASE_URL, ADMIN, COLLABORATOR } from './constants';
type HttpMethod = 'post' | 'patch' | 'get' | 'delete';
async function api(page: Page, method: HttpMethod, path: string, data?: unknown) {
const res = await page.request[method](
`${BASE_URL}${path}`,
data !== undefined ? { data } : undefined
);
if (!res.ok()) {
throw new Error(`${method.toUpperCase()} ${path} failed: ${res.status()} ${await res.text()}`);
}
const text = await res.text();
return text ? JSON.parse(text) : {};
}
interface NoteData {
key: string;
title: string;
content: string;
color: string;
pinned?: boolean;
checklistMode?: boolean;
}
function getNotesData(): NoteData[] {
// Created in reverse display order so newest (first created) appears last
// and the grid reads naturally top-left to bottom-right
return [
{
key: 'bakingcode',
title: 'Crumbs v0.23 Release',
content: `- [x] CRDT sync logic
- [x] MCP server integration
- [ ] Bake the Docker image
- [ ] Deploy to the homelab
- [ ] Celebrate with a bretzel`,
color: 'clay',
checklistMode: true
},
{
key: 'coding-music',
title: 'Coding Playlists',
content: `Curated on **Navidrome** for maximum focus.
### Late Night Focus
1. *Tycho* — Dive (ambient electronic)
2. *Boards of Canada* — Music Has the Right to Children
3. *Nujabes* — Modal Soul
4. *C418* — Minecraft Volume Alpha
### Saturday Baking
For slow mornings in the kitchen:
1. *Khruangbin* — Con Todo El Mundo
2. *Tatsuro Yamashita* — For You
#music #personal`,
color: 'chalk'
},
{
key: 'weekend',
title: 'Weekend Plans',
content: `### Saturday
- Farmer's market — get sourdough starter
- Fix the Dokploy SSL cert renewal
- Bake a batch of bretzels
### Sunday
- Retro gaming marathon on *RetroAssembly*
- Update the self-hosting stack
- Plan next trip on **42**
> A weekend well spent brings a week of content.
#personal #weekend`,
color: 'mint'
},
{
key: 'manifesto',
title: '',
content: `The best software is the software you **own**. Self-host everything. Trust no cloud. 🥨
#personal #selfhosting`,
color: 'coral'
},
{
key: 'retro',
title: 'Back to Retro Gaming',
content: `Games running on **RetroAssembly** this summer:
- **Chrono Trigger** (SNES) — best RPG ever made
- **Link's Awakening** (GB) — pure portable magic
- **Symphony of the Night** (PS1) — the *definitive* Metroidvania
- **Advance Wars** (GBA) — turn-based perfection
- **Final Fantasy Tactics** (PS1) — political intrigue + job system
> The pixel art era was peak game design.
#gaming #retro`,
color: 'peach'
},
{
key: 'selfhosting',
title: 'Self-Hosting Stack',
content: `All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **Dokploy** — deployment platform
- **Outline** — team wiki
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
Just getting started. More to come.
#homelab #devops`,
color: 'fog'
},
{
key: 'bretzel',
title: 'Bretzel Ingredients',
content: `- [x] 500g bread flour
- [x] 300ml warm water
- [x] 10g salt
- [ ] 7g dry yeast
- [ ] 30g butter (softened)
- [ ] Baking soda for the bath
- [ ] Coarse salt for topping`,
color: 'sand',
checklistMode: true
}
];
}
function getVersionHistoryContents(): string[] {
return [
// Version 2: Add Navidrome + RetroAssembly
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **Dokploy** — deployment platform
- **Outline** — team wiki
- **Navidrome** — music streaming
- **RetroAssembly** — retro game emulation
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
Growing the stack.
#homelab #devops`,
// Version 3: Add 42 + Umami
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **42** — holiday budget tracking
- **Dokploy** — deployment platform
- **Outline** — team wiki
- **Navidrome** — music streaming
- **RetroAssembly** — retro game emulation
- **Umami** — privacy-first analytics
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
The stack is getting serious.
#homelab #devops`,
// Version 4: Final version
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
### Core Services
- **Crumbs** — notes *(obviously)*
- **42** — holiday budget tracking
- **Dokploy** — deployment platform
- **Outline** — team wiki
- **Navidrome** — music streaming
- **RetroAssembly** — retro game emulation
- **Umami** — privacy-first analytics
### Monitoring
> Everything that can break *will* break at 3 AM on a Sunday.
Everything below daily reverse proxy. The dream stack is *almost* complete.
#homelab #devops`
];
}
export async function seed(page: Page): Promise<void> {
// 1. Setup admin (sets session cookie)
await api(page, 'post', '/api/auth/setup', {
email: ADMIN.email,
displayName: ADMIN.displayName,
password: ADMIN.password
});
console.log(' admin created');
// 2. Create collaborator user
const alice = await api(page, 'post', '/api/admin/users', {
email: COLLABORATOR.email,
displayName: COLLABORATOR.displayName,
password: COLLABORATOR.password,
role: 'user'
});
const aliceId: number = alice.id;
console.log(' collaborator created (id:', aliceId, ')');
// 3. Create notes (order matters — newest appear first in grid)
const noteData = getNotesData();
const noteIds: Record<string, string> = {};
for (const note of noteData) {
const created = await api(page, 'post', '/api/notes', {
title: note.title,
content: note.content,
color: note.color,
pinned: note.pinned ?? false,
checklistMode: note.checklistMode ?? false
});
noteIds[note.key] = created.id;
console.log(' note created:', note.title);
}
// 4. Share "Weekend Plans" with Alice
await api(page, 'post', `/api/notes/${noteIds['weekend']}/collaborators`, {
userId: aliceId
});
console.log(' shared "Weekend Plans" with Alice');
// 5. Create version history for "Self-Hosting Stack"
const versionContents = getVersionHistoryContents();
for (let i = 0; i < versionContents.length; i++) {
await api(page, 'patch', `/api/notes/${noteIds['selfhosting']}`, {
content: versionContents[i]
});
console.log(` version ${i + 2} created for "Self-Hosting Stack"`);
}
// 6. Create API keys
await api(page, 'post', '/api/settings/api-keys', { name: 'Claude Code' });
await api(page, 'post', '/api/settings/api-keys', { name: 'N8N' });
console.log(' API keys created');
}
export async function login(page: Page): Promise<void> {
await page.goto(`${BASE_URL}/login`);
await page.getByTestId('email-input').fill(ADMIN.email);
await page.getByTestId('password-input').fill(ADMIN.password);
await page.getByTestId('login-btn').click();
await page.waitForURL('**/');
await page.waitForLoadState('networkidle');
}
+153 -1
View File
@@ -20,6 +20,25 @@
--success-text: #3a5a2e;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #1a1a2e;
}
[data-theme="dark"] {
--bg-base: #1a1715;
--bg-surface: #2a2520;
--text: #e8dcc8;
--text-muted: #9a8e7e;
--border: #e8dcc8;
--border-subtle: #3a3530;
--destructive: #d4604e;
--error-bg: #3a2020;
--error-border: #5a3030;
--error-text: #e8a090;
--success-bg: #203020;
--success-text: #90c880;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
--grid-dot: #e8dcc8;
}
body {
@@ -33,7 +52,7 @@ body::before {
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
@@ -72,3 +91,136 @@ body::before {
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: var(--primary-hover);
}
/* Table styles for TipTap editor */
.tiptap table {
border-collapse: collapse;
width: 100%;
margin: 0.5rem 0;
}
.tiptap th,
.tiptap td {
border: 1px solid var(--border-subtle);
padding: 0.375rem 0.625rem;
text-align: left;
vertical-align: top;
min-width: 4rem;
}
.tiptap th {
font-weight: 600;
background-color: color-mix(in srgb, var(--bg-base) 60%, transparent);
}
.tiptap td p,
.tiptap th p {
margin: 0;
}
.tiptap .selectedCell {
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
}
/* Task list styles — shared between TipTap editor and markdown-it preview */
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
.tiptap ul[data-type="taskList"],
.prose ul.task-list {
list-style: none;
padding-left: 0;
margin: 0.25rem 0;
}
.tiptap ul[data-type="taskList"] li,
.prose ul.task-list li {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.375rem;
margin: 0;
padding: 0;
}
/* Nested task lists take full width below the parent item */
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"] {
flex-basis: 100%;
padding-left: 1.5rem;
}
.tiptap ul[data-type="taskList"] li::marker,
.prose ul.task-list li::marker {
content: none;
}
/* TipTap: checkbox is inside a label */
.tiptap ul[data-type="taskList"] li label {
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 0.125rem;
}
/* Shared checkbox styles */
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
flex-shrink: 0;
}
/* TipTap: text content is in a div>p */
.tiptap ul[data-type="taskList"] li > div {
flex: 1;
min-width: 0;
}
.tiptap ul[data-type="taskList"] li > div p {
margin: 0;
}
/* Checked item styling */
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--text-muted);
}
/* Override prose spacing for task lists */
.prose ul[data-type="taskList"],
.prose ul.task-list {
padding-left: 0;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.prose ul[data-type="taskList"] li,
.prose ul.task-list li {
padding-left: 0;
margin-top: 0;
margin-bottom: 0;
}
.prose ul.task-list li p {
margin: 0;
}
/* Custom tooltips */
.tooltip-popup {
position: fixed;
z-index: 9999;
padding: 0.25rem 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
line-height: 1.2;
color: var(--bg-surface);
background: var(--text);
border-radius: 2px;
pointer-events: none;
white-space: nowrap;
opacity: 0;
transition: opacity 100ms ease-out;
}
.tooltip-visible {
opacity: 1;
}
+11 -1
View File
@@ -1,9 +1,19 @@
/// <reference types="vite-plugin-pwa/info" />
/// <reference types="vite-plugin-pwa/vanillajs" />
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: {
id: number;
email: string;
displayName: string;
role: 'admin' | 'user';
} | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+15 -1
View File
@@ -4,6 +4,21 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f0e6d3" />
<script>
(function() {
try {
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
var t = p.theme || 'system';
if (t !== 'light' && t !== 'dark') t = 'system';
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.content = '#1a1715';
}
} catch(e) {}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
@@ -11,7 +26,6 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Crumbs" />
<link rel="manifest" href="/site.webmanifest" />
<title>Crumbs</title>
%sveltekit.head%
</head>
+49 -3
View File
@@ -1,17 +1,56 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
import { validateApiKey, getUserForApiKey } from '$lib/server/api-keys.js';
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
const PUBLIC_PATHS = [
'/login',
'/setup',
'/api/auth/login',
'/api/auth/setup',
'/api/auth/oauth',
'/s/',
'/api/shared'
];
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Initialize locals
event.locals.user = null;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return resolve(event);
}
// API routes: authenticate via Bearer token if present
if (pathname.startsWith('/api/')) {
const authHeader = event.request.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (token) {
const { valid, userId } = validateApiKey(token);
if (!valid || !userId) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const user = getUserForApiKey(userId);
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
event.locals.user = user;
return resolve(event);
}
}
// Check if setup is complete
const setupDone = await isSetupComplete();
if (!setupDone) {
@@ -21,9 +60,16 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// Validate session
// Validate session and populate user
const sessionToken = event.cookies.get('session');
if (!sessionToken || !(await validateSession(sessionToken))) {
if (sessionToken) {
const result = await validateSession(sessionToken);
if (result.valid && result.user) {
event.locals.user = result.user;
}
}
if (!event.locals.user) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
+5
View File
@@ -0,0 +1,5 @@
// Mock for $app/environment used in Vitest unit tests
export const browser = true;
export const dev = false;
export const building = false;
export const version = 'test';
+11
View File
@@ -0,0 +1,11 @@
/**
* Polyfill Svelte 5 runes for Vitest.
* The Svelte compiler transforms these in .svelte and .svelte.ts files,
* but Vitest doesn't run the Svelte compiler, so we provide plain-JS stubs.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
(globalThis as any).$state = <T>(initial: T): T => initial;
(globalThis as any).$derived = <T>(value: T): T => value;
(globalThis as any).$effect = (_fn: () => void): void => {};
(globalThis as any).$props = (): any => ({});
+299 -62
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import Plus from 'lucide-svelte/icons/plus';
interface ChecklistItem {
text: string;
checked: boolean;
}
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { onDestroy } from 'svelte';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem } from '$lib/utils/checklist.js';
interface Props {
content: string;
@@ -14,106 +15,342 @@
let { content, onChange }: Props = $props();
// svelte-ignore state_referenced_locally
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ text: '', checked: false }];
const lines = text.split('\n');
return lines.map((line) => {
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
return { text: line, checked: false };
});
// Track the last content we emitted so we can distinguish self-originated
// changes from external ones (sync, history restore, etc.)
let lastEmitted = content;
// Re-parse items when content changes externally (not from our own emitChange)
$effect(() => {
if (content !== lastEmitted) {
items = parseChecklist(content);
lastEmitted = content;
}
});
let activeItems = $derived(items.filter((i) => !i.checked));
let doneItems = $derived(items.filter((i) => i.checked));
let doneCount = $derived(doneItems.length);
interface DoneSectionGroup {
parentLabel: ChecklistItem | null;
children: ChecklistItem[];
}
function serializeChecklist(list: ChecklistItem[]): string {
return list
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
}
let doneSectionGroups = $derived.by(() => {
const groups: DoneSectionGroup[] = [];
const childrenByParent = new Map<string, ChecklistItem[]>();
const topLevelDone: ChecklistItem[] = [];
for (const item of doneItems) {
if (item.parentId) {
const siblings = childrenByParent.get(item.parentId) ?? [];
siblings.push(item);
childrenByParent.set(item.parentId, siblings);
} else {
topLevelDone.push(item);
}
}
for (const item of topLevelDone) {
const doneChildren = childrenByParent.get(item.id) ?? [];
groups.push({ parentLabel: null, children: [item, ...doneChildren] });
childrenByParent.delete(item.id);
}
for (const [parentId, children] of childrenByParent) {
const parent = items.find((i) => i.id === parentId);
if (parent) {
groups.push({ parentLabel: parent, children });
} else {
groups.push({ parentLabel: null, children });
}
}
return groups;
});
function emitChange() {
onChange(serializeChecklist(items));
const serialized = serializeChecklist(items);
lastEmitted = serialized;
onChange(serialized);
}
function toggleItem(index: number) {
items[index].checked = !items[index].checked;
function toggleItem(id: string) {
items = toggleItemWithCascade(id, items);
emitChange();
}
function updateText(index: number, text: string) {
items[index].text = text;
emitChange();
function updateText(id: string, text: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = text;
emitChange();
}
}
function addItem(afterIndex: number) {
items.splice(afterIndex + 1, 0, { text: '', checked: false });
const afterItem = items[afterIndex];
const newItem: ChecklistItem = {
id: generateId(),
text: '',
checked: false,
parentId: afterItem?.parentId ?? null
};
items.splice(afterIndex + 1, 0, newItem);
items = [...items];
emitChange();
// Focus new input on next tick
const newId = newItem.id;
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[afterIndex + 1]?.focus();
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
}, 0);
}
function removeItem(index: number) {
if (items.length <= 1) return;
function removeItem(id: string) {
if (items.length <= 1) {
if (items[0].parentId) items[0].parentId = null;
items = [...items];
emitChange();
return;
}
items = items.map((i) => (i.parentId === id ? { ...i, parentId: null } : i));
const index = items.findIndex((i) => i.id === id);
items.splice(index, 1);
items = [...items];
emitChange();
}
function handleKeydown(e: KeyboardEvent, index: number) {
function handleKeydown(e: KeyboardEvent, id: string) {
const index = items.findIndex((i) => i.id === id);
if (e.key === 'Enter') {
e.preventDefault();
addItem(index);
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
e.preventDefault();
removeItem(index);
removeItem(id);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]'));
const current = inputs.findIndex((el) => el.dataset.itemId === id);
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
if (target >= 0 && target < inputs.length) {
e.preventDefault();
inputs[target].focus();
}
} else if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
items = indentItem(id, items, activeItems);
emitChange();
} else if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
items = outdentItem(id, items);
emitChange();
}
}
// --- Directional lock drag handle ---
// Instead of blocking DnD events (which corrupts its internal state),
// we detect horizontal intent and store the pending operation. DnD runs
// its full course unimpeded. After DnD finalizes, we apply the indent.
let dragStartX = 0;
let dragStartY = 0;
let pendingIndent: { id: string; direction: 'indent' | 'outdent' } | null = null;
let directionLocked = false;
const LOCK_THRESHOLD = 10;
const INDENT_THRESHOLD = 32;
function windowPointerMove(e: PointerEvent) {
if (directionLocked) return;
const dx = Math.abs(e.clientX - dragStartX);
const dy = Math.abs(e.clientY - dragStartY);
if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) {
directionLocked = true;
if (dx <= dy) {
// Vertical — DnD handles it, we're done
removeWindowListeners();
}
// Horizontal — keep listening for pointerup to measure total dx
}
}
function windowPointerUp(e: PointerEvent) {
const dx = e.clientX - dragStartX;
if (Math.abs(dx) > INDENT_THRESHOLD && directionLocked && currentDragItemId) {
pendingIndent = {
id: currentDragItemId,
direction: dx > 0 ? 'indent' : 'outdent'
};
}
removeWindowListeners();
directionLocked = false;
}
function addWindowListeners() {
window.addEventListener('pointermove', windowPointerMove, { capture: true });
window.addEventListener('pointerup', windowPointerUp, { capture: true });
}
function removeWindowListeners() {
window.removeEventListener('pointermove', windowPointerMove, { capture: true });
window.removeEventListener('pointerup', windowPointerUp, { capture: true });
}
let currentDragItemId = '';
function handleDragPointerDown(e: PointerEvent, id: string) {
dragStartX = e.clientX;
dragStartY = e.clientY;
directionLocked = false;
pendingIndent = null;
currentDragItemId = id;
addWindowListeners();
}
onDestroy(removeWindowListeners);
// --- DnD handlers ---
function recomputeParentIds(reorderedItems: ChecklistItem[]): ChecklistItem[] {
let lastTopLevelId: string | null = null;
return reorderedItems.map((item) => {
if (item.parentId === null) {
lastTopLevelId = item.id;
return item;
}
return { ...item, parentId: lastTopLevelId };
});
}
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
items = [...e.detail.items, ...items.filter((i) => i.checked)];
}
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
const reorderedActive = recomputeParentIds(e.detail.items);
items = [...reorderedActive, ...items.filter((i) => i.checked)];
emitChange();
// Apply pending indent/outdent from horizontal drag (after DnD fully cleans up)
if (pendingIndent && pendingIndent.id) {
const { id, direction } = pendingIndent;
pendingIndent = null;
if (direction === 'indent') {
items = indentItem(id, items, activeItems);
} else {
items = outdentItem(id, items);
}
emitChange();
}
}
</script>
<div class="space-y-1" data-testid="checklist">
{#each items as item, index}
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(index)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, index)}
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
/>
<button
onclick={() => removeItem(index)}
class="opacity-0 hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
<div class="space-y-0.5" data-testid="checklist">
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5 outline-none {item.parentId ? 'pl-8' : ''}"
data-testid={item.parentId ? 'checklist-child-row' : undefined}
animate:flip={{ duration: flipDurationMs }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div use:dragHandle
aria-label="drag handle"
onpointerdown={(e) => handleDragPointerDown(e, item.id)}
class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 touch-none outline-none"
data-testid="checklist-drag-handle">
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, item.id)}
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
data-item-id={item.id}
/>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
</section>
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-add"
>
<Plus class="h-4 w-4" />
Add item
</button>
{#if doneCount > 0}
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
<button
onclick={() => (doneExpanded = !doneExpanded)}
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-toggle-done"
>
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
{doneCount} done
</button>
{#if doneExpanded}
<div class="mt-1 space-y-0.5" data-testid="checklist-done-section">
{#each doneSectionGroups as group}
{#if group.parentLabel}
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
<span class="text-sm text-[var(--text-muted)]">{group.parentLabel.text}</span>
</div>
{/if}
{#each group.children as item (item.id)}
<div class="group flex items-center gap-2 py-1.5 {item.parentId ? 'pl-10' : 'pl-5'}">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
{/each}
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Users from 'lucide-svelte/icons/users';
interface Props {
collaborators: Collaborator[];
ownerName?: string;
}
const { collaborators, ownerName }: Props = $props();
let showPopover = $state(false);
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
function handleMouseEnter() {
hoverTimeout = setTimeout(() => {
showPopover = true;
}, 150);
}
function handleMouseLeave() {
clearTimeout(hoverTimeout);
showPopover = false;
}
function handleClick(e: Event) {
e.stopPropagation();
showPopover = !showPopover;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative inline-flex"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<button
type="button"
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
title="Shared note"
onclick={handleClick}
data-testid="collaborator-indicator"
>
<Users class="h-4 w-4" />
</button>
{#if showPopover}
<div
class="absolute right-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="collaborator-popover"
>
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
<ul class="space-y-0.5">
{#if ownerName}
<li class="text-xs text-[var(--text)]">
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
</li>
{/if}
{#each collaborators as collab}
<li class="text-xs text-[var(--text)]">
{collab.displayName || collab.email}
</li>
{/each}
</ul>
</div>
{/if}
</div>
+3 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import type { NoteColor } from '$lib/types/index.js';
interface Props {
@@ -12,11 +13,10 @@
<div class="flex flex-wrap gap-1" data-testid="color-picker">
{#each COLOR_OPTIONS as option}
{@const colors = NOTE_COLORS[option.value]}
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
style="background-color: {colors.bg}"
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
title={option.label}
data-testid="color-{option.value}"
></button>
+179 -82
View File
@@ -6,6 +6,7 @@
Heading,
List,
ListOrdered,
ListChecks,
TextQuote,
CodeXml,
Bold,
@@ -23,7 +24,10 @@
AlignJustify,
Minus,
ChevronDown,
Ellipsis
Table2,
BetweenHorizontalEnd,
BetweenVerticalEnd,
RemoveFormatting
} from 'lucide-svelte';
interface Props {
@@ -35,6 +39,9 @@
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
let openDropdown: string | null = $state(null);
let linkUrl: string = $state('');
@@ -110,13 +117,35 @@
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
data-testid="formatting-toolbar"
>
<!-- Undo/Redo -->
<button
onclick={() => editor?.chain().focus().undo().run()}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo"
data-testid="format-undo"
>
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo"
data-testid="format-redo"
>
<Redo2 size={iconSize} />
</button>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(editor?.isActive('bold') ?? false)}
class={btnClass(isActive('bold'))}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
@@ -124,7 +153,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(editor?.isActive('italic') ?? false)}
class={btnClass(isActive('italic'))}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
@@ -132,7 +161,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(editor?.isActive('strike') ?? false)}
class={btnClass(isActive('strike'))}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
@@ -140,7 +169,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(editor?.isActive('underline') ?? false)}
class={btnClass(isActive('underline'))}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
@@ -153,7 +182,7 @@
<div class="relative" data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
class={dropdownBtnClass(isActive('heading'))}
title="Heading"
data-testid="format-heading"
>
@@ -164,7 +193,7 @@
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
@@ -172,7 +201,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
@@ -180,7 +209,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
@@ -194,7 +223,7 @@
<div class="relative" data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
title="Lists"
data-testid="format-list"
>
@@ -205,7 +234,7 @@
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
@@ -213,12 +242,20 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{/if}
</div>
@@ -227,7 +264,7 @@
<div class="relative" data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
class={btnClass(editor?.isActive('link') ?? false)}
class={btnClass(isActive('link'))}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
@@ -259,7 +296,7 @@
<button
type="button"
onclick={openLink}
disabled={!editor?.getAttributes('link').href}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
@@ -269,7 +306,7 @@
<button
type="button"
onclick={removeLink}
disabled={!editor?.isActive('link')}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
title="Remove link"
data-testid="format-unlink"
@@ -281,99 +318,123 @@
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- More dropdown -->
<div class="relative" data-dropdown="more">
<!-- Table dropdown -->
<div class="relative" data-dropdown="table">
<button
onclick={() => toggleDropdown('more')}
class={dropdownBtnClass(false)}
title="More options"
data-testid="format-more"
onclick={() => toggleDropdown('table')}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
>
<Ellipsis size={iconSize} />
<Table2 size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'more'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
{#if openDropdown === 'table'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
disabled={!canUndo()}
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-undo"
data-testid="format-table-insert"
>
<Undo2 size={iconSize} />
<span>Undo</span>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
<button
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
disabled={!canRedo()}
class={dropdownItemClass()}
data-testid="format-redo"
>
<Redo2 size={iconSize} />
<span>Redo</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('code') ?? false)}
data-testid="format-code"
>
<Code size={iconSize} />
<span>Inline code</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
<span>Blockquote</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
<span>Code block</span>
</button>
<button
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-hr"
>
<Minus size={iconSize} />
<span>Divider</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-col"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-col"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-table-delete"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{/if}
</div>
<!-- Alignment dropdown -->
<div class="relative" data-dropdown="align">
<button
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
title="Text alignment"
data-testid="format-align"
>
{#if isActive({ textAlign: 'center' })}
<AlignCenter size={iconSize} />
{:else if isActive({ textAlign: 'right' })}
<AlignRight size={iconSize} />
{:else if isActive({ textAlign: 'justify' })}
<AlignJustify size={iconSize} />
{:else}
<AlignLeft size={iconSize} />
{/if}
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Align left</span>
<span>Left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Align center</span>
<span>Center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Align right</span>
<span>Right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
@@ -382,4 +443,40 @@
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Block-level formatting -->
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(isActive('code'))}
title="Inline code"
data-testid="format-code"
>
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(isActive('blockquote'))}
title="Blockquote"
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(isActive('codeBlock'))}
title="Code block"
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
class={btnClass()}
title="Divider"
data-testid="format-hr"
>
<Minus size={iconSize} />
</button>
</div>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
interface Props {
src: string;
alt: string;
onClose: () => void;
}
const { src, alt, onClose }: Props = $props();
let dialogEl: HTMLDivElement | undefined = $state();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}
function handleBackgroundClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
$effect(() => {
dialogEl?.focus();
});
</script>
<div
bind:this={dialogEl}
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
onclick={handleBackgroundClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label="Image lightbox"
tabindex="-1"
data-testid="image-lightbox"
>
<button
onclick={onClose}
class="absolute right-4 top-4 rounded-sm p-2 text-white hover:bg-white/10"
aria-label="Close lightbox"
data-testid="lightbox-close"
>
<XIcon class="h-6 w-6" />
</button>
<img
{src}
{alt}
class="max-h-[90vh] max-w-[90vw] object-contain rounded-sm animate-[pop-in_150ms_ease-out]"
data-testid="lightbox-image"
/>
</div>
+145 -35
View File
@@ -1,28 +1,83 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import Star from 'lucide-svelte/icons/star';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import ImageLightbox from './ImageLightbox.svelte';
import type { Attachment } from '$lib/types/index.js';
import { optimizeImage } from '$lib/utils/image-optimize.js';
import { browser } from '$app/environment';
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
import { showToast } from '$lib/stores/toast.js';
interface Props {
noteId: string;
attachments: Attachment[];
onUpload: (attachment: Attachment) => void;
onRemove: (attachmentId: string) => void;
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
showDropZone?: boolean;
}
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: Props = $props();
let uploading = $state(false);
let optimizing = $state(false);
let dragOver = $state(false);
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
async function handleFiles(files: FileList | null) {
if (!files || !noteId) return;
uploading = true;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
optimizing = true;
let optimized: Blob;
let thumbnail: Blob;
try {
const result = await optimizeImage(file);
optimized = result.optimized;
thumbnail = result.thumbnail;
} catch (err) {
console.error('Optimization failed, using original:', err);
optimized = file;
thumbnail = file;
}
optimizing = false;
// Offline: store in IDB and display via blob URL
if (browser && !navigator.onLine) {
const tempId = crypto.randomUUID();
const pending: PendingAttachment = {
id: tempId,
noteId,
optimized,
thumbnail,
filename: file.name,
mimeType: optimized.type || file.type,
timestamp: Date.now()
};
await addPendingAttachment(pending);
const blobUrl = URL.createObjectURL(optimized);
onUpload({
id: tempId,
noteId,
filename: file.name,
mimeType: optimized.type || file.type,
size: optimized.size,
path: blobUrl,
pending: true,
createdAt: new Date()
} as Attachment & { pending: boolean });
continue;
}
uploading = true;
const formData = new FormData();
formData.append('file', file);
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
try {
const res = await fetch(`/api/notes/${noteId}/attachments`, {
@@ -32,12 +87,16 @@
if (res.ok) {
const attachment = await res.json();
onUpload(attachment);
} else {
console.error('Upload failed:', res.status, await res.text());
showToast('Failed to upload image', 'error');
}
} catch (err) {
console.error('Upload failed:', err);
showToast('Failed to upload image', 'error');
}
uploading = false;
}
uploading = false;
}
function handleDrop(e: DragEvent) {
@@ -57,16 +116,38 @@
{#if attachments.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each attachments as attachment}
{@const isPending = 'pending' in attachment && attachment.pending}
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
<div class="group relative">
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded-sm object-cover"
<button
type="button"
class="h-20 w-20 cursor-pointer rounded-sm p-0 border-0 bg-transparent overflow-hidden"
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
data-testid="attachment-thumbnail"
/>
>
<img
src={imgSrc}
alt={attachment.filename}
class="h-20 w-20 object-cover {isPending ? 'opacity-70' : ''}"
/>
</button>
{#if isPending}
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
<RefreshCw class="h-3 w-3 text-white" />
</div>
{:else if onToggleFeatured}
<button
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 transition-opacity {attachment.featured ? 'opacity-100' : 'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100'}"
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
data-testid="toggle-featured"
>
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
</button>
{/if}
<button
onclick={() => onRemove(attachment.id)}
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
class="absolute -right-1 -top-1 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
@@ -78,29 +159,58 @@
{/if}
<!-- Upload area -->
<div
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
{#if showDropZone}
<!-- Drop zone for desktop, browse-only label for mobile -->
<div
class="hidden rounded-sm border-2 border-dashed p-4 text-center transition-colors md:block {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if optimizing}
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
{:else if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
<!-- Mobile: compact browse button -->
<div class="md:hidden">
{#if optimizing}
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
{:else if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-sm text-[var(--text-muted)] hover:border-[var(--primary)]">
<span>Add images</span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input-mobile"
/>
</label>
{/if}
</div>
{/if}
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
+40 -41
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
import { StickyNote, Archive, Trash2, Tag } from 'lucide-svelte';
import type { NoteFilter } from '$lib/types/index.js';
import { page } from '$app/stores';
import { allTags } from '$lib/stores/notes.js';
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
interface Props {
open: boolean;
@@ -10,15 +10,17 @@
let { open, onClose }: Props = $props();
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
loadNotes(filter);
function closeMobile() {
if (window.matchMedia('(max-width: 1023px)').matches) {
onClose?.();
}
}
function selectTag(tag: string | null) {
selectedTag.set(tag);
loadNotes('all');
}
const navItems = [
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
];
</script>
{#if open}
@@ -35,33 +37,18 @@
>
<nav class="p-2">
<ul class="space-y-1">
<li>
<button
onclick={() => setFilter('all')}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<StickyNote size={20} />
Crumbs
</button>
</li>
<li>
<button
onclick={() => setFilter('archived')}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Archive size={20} />
Archive
</button>
</li>
<li>
<button
onclick={() => setFilter('trashed')}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Trash2 size={20} />
Trash
</button>
</li>
{#each navItems as item}
<li>
<a
href={item.href}
onclick={closeMobile}
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<item.icon size={20} />
{item.label}
</a>
</li>
{/each}
</ul>
{#if $allTags.length > 0}
@@ -70,17 +57,29 @@
<ul class="mt-2 space-y-1">
{#each $allTags as tag}
<li>
<button
onclick={() => selectTag($selectedTag === tag ? null : tag)}
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
<a
href="/tag/{tag}"
onclick={closeMobile}
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Tag size={16} />
#{tag}
</button>
</a>
</li>
{/each}
</ul>
</div>
{/if}
</nav>
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
onclick={closeMobile}
data-testid="settings-link"
>
<Settings size={20} />
Settings
</a>
</div>
</aside>
+111 -86
View File
@@ -1,45 +1,62 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import ImageLightbox from './ImageLightbox.svelte';
import SharingIndicator from './SharingIndicator.svelte';
import type { Note } from '$lib/types/index.js';
import Undo2 from 'lucide-svelte/icons/undo-2';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Bookmark from 'lucide-svelte/icons/bookmark';
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import UserMinus from 'lucide-svelte/icons/user-minus';
import { tooltip } from '$lib/utils/tooltip.js';
interface ChecklistItem {
text: string;
checked: boolean;
indented: boolean;
}
interface Props {
note: Note;
onEdit: (note: Note) => void;
fullHeight?: boolean;
}
let { note, onEdit }: Props = $props();
let { note, onEdit, fullHeight = false }: Props = $props();
$effect(() => {
const colors = NOTE_COLORS[note.color];
cardStyle = `background-color: ${colors.bg}`;
cardStyle = `background-color: ${getNoteColor(note.color, getIsDarkMode())}`;
});
let cardStyle = $state('');
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
const renderedContent = $derived(renderMarkdown(note.content));
const checklistItems = $derived<ChecklistItem[]>(
note.checklistMode
? note.content.split('\n').filter(l => l.trim()).map(line => ({
text: line.replace(/^- \[[ x]\] /, ''),
checked: line.startsWith('- [x] ')
}))
? note.content.split('\n').filter(l => l.trim()).map(line => {
const indented = line.startsWith(' - [');
return {
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
checked: /^ {0,2}- \[x\] /.test(line),
indented
};
})
: []
);
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
function stop(fn: () => void) {
return (e: Event) => {
e.stopPropagation();
@@ -47,27 +64,11 @@
};
}
function toggleChecklistItem(index: number) {
const lines = note.content.split('\n');
let checkIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('- [x] ') || line.startsWith('- [ ] ')) {
if (checkIndex === index) {
lines[i] = line.startsWith('- [x] ')
? `- [ ] ${line.slice(6)}`
: `- [x] ${line.slice(6)}`;
break;
}
checkIndex++;
}
}
updateNote(note.id, { content: lines.join('\n') });
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<article
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)]"
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] max-h-[17rem] overflow-hidden {fullHeight ? 'h-full' : ''}"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
@@ -76,33 +77,72 @@
data-testid="note-card"
data-note-id={note.id}
>
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
<!-- Thumbnail strip (featured images only) -->
{#if featuredAttachments.length > 0}
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
{#each featuredAttachments.slice(0, 3) as attachment}
<div class="relative min-w-0 flex-1">
<button
type="button"
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="card-thumbnail"
>
<img
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
alt={attachment.filename}
class="h-24 w-full object-cover"
loading="lazy"
/>
</button>
</div>
{/each}
{#if featuredAttachments.length > 3}
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
+{featuredAttachments.length - 3}
</div>
{/if}
</div>
{/if}
<!-- Status indicators (top-right) -->
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
{#if note.shareToken || (note.isShared && note.collaborators)}
<SharingIndicator
shareToken={note.shareToken}
collaborators={note.collaborators ?? []}
isOwner={note.isOwner ?? true}
/>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
</div>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-1" data-testid="note-checklist-preview">
{#each checklistItems.slice(0, 8) as item, index}
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
data-testid={item.indented ? 'card-checklist-child' : undefined}>
<input
type="checkbox"
checked={item.checked}
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(index); }}
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
data-testid="card-checklist-checkbox"
/>
<span class="truncate">{item.text}</span>
<span class="break-words min-w-0">{item.text}</span>
</li>
{/each}
{#if checklistItems.length > 8}
@@ -115,21 +155,13 @@
</div>
{/if}
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<TagChip {tag} />
{/each}
</div>
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Restore"
use:tooltip={"Restore"}
data-testid="restore-btn"
>
<Undo2 class="h-4 w-4" />
@@ -137,7 +169,7 @@
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete forever"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
@@ -147,7 +179,7 @@
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Pin"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
@@ -157,7 +189,7 @@
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Unarchive"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
<ArchiveRestore class="h-4 w-4" />
@@ -166,43 +198,36 @@
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Archive"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
<Archive class="h-4 w-4" />
</button>
{/if}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
</button>
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
</button>
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
{/if}
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
</article>
<style>
article :global(.prose li:has(.task-checkbox)) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
article :global(.prose li:has(.task-checkbox))::before {
display: none;
}
article :global(.prose li:has(.task-checkbox))::marker {
content: none;
}
article :global(.task-checkbox) {
margin-top: 0.15rem;
flex-shrink: 0;
}
</style>
+458 -41
View File
@@ -1,63 +1,288 @@
<script lang="ts">
import { onMount } from 'svelte';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
import { notes } from '$lib/stores/notes.js';
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor } from '$lib/types/index.js';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
import Type from 'lucide-svelte/icons/type';
import FileCode from 'lucide-svelte/icons/file-code';
import FileText from 'lucide-svelte/icons/file-text';
import UserPlus from 'lucide-svelte/icons/user-plus';
import Users from 'lucide-svelte/icons/users';
import Globe from 'lucide-svelte/icons/globe';
import History from 'lucide-svelte/icons/history';
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical';
import Trash2 from 'lucide-svelte/icons/trash-2';
import ListX from 'lucide-svelte/icons/list-x';
import Archive from 'lucide-svelte/icons/archive';
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
import { tooltip } from '$lib/utils/tooltip.js';
interface Props {
note: Note | null;
isNew?: boolean;
initialChecklistMode?: boolean;
onClose: () => void;
}
const { note, isNew = false, onClose }: Props = $props();
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
let title = $state(note?.title ?? '');
// svelte-ignore state_referenced_locally
let content = $state(note?.content ?? '');
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? 'default');
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
let showColorPicker = $state(false);
let rawMarkdownMode = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
let bgStyle = $state('');
$effect(() => {
const colors = NOTE_COLORS[color];
bgStyle = `background-color: ${colors.bg}`;
// Mutable note identity — allows transitioning from new → saved without closing
// svelte-ignore state_referenced_locally
let noteId = $state<string | null>(note?.id ?? null);
// svelte-ignore state_referenced_locally
let currentlyNew = $state(isNew);
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
// can produce a synthetic click that lands on editor buttons rendered at the same
// coordinates. Suppress pointer events on toolbar controls until mount completes.
let toolbarInteractive = $state(false);
onMount(() => {
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
return () => clearTimeout(timer);
});
async function save() {
let showShareDialog = $state(false);
let showHistory = $state(false);
let showOverflowMenu = $state(false);
let overflowBtnEl: HTMLButtonElement | undefined = $state();
let overflowMenuEl: HTMLDivElement | undefined = $state();
// Close overflow menu when clicking outside
$effect(() => {
if (!showOverflowMenu) return;
function handleClickOutside(e: MouseEvent) {
const target = e.target as Node;
if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return;
showOverflowMenu = false;
}
// Use setTimeout to avoid the current click from closing the menu
const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handleClickOutside);
};
});
const hasDoneItems = $derived(
checklistMode && content.includes('[x]')
);
// svelte-ignore state_referenced_locally
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
// svelte-ignore state_referenced_locally
let currentShareToken = $state<string | undefined>(note?.shareToken);
const isOwner = $derived(note?.isOwner !== false);
const isShared = $derived(collaboratorsList.length > 0);
const hasPublicLink = $derived(!!currentShareToken);
// Use displayName from first user or 'You' for owner
const ownerName = $derived(isOwner ? 'You' : 'Owner');
function handleCollaboratorsUpdate(updated: Collaborator[]) {
collaboratorsList = updated;
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, collaborators: updated, isShared: updated.length > 0 }
: n
)
);
}
}
function handleShareUpdate(token: string | null) {
currentShareToken = token ?? undefined;
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, shareToken: token ?? undefined }
: n
)
);
}
}
async function toggleShareDialog() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showShareDialog = !showShareDialog;
}
async function toggleHistory() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showHistory = !showHistory;
}
async function handleHistoryRestored() {
if (!noteId) return;
// Reload the note content from the server after a restore
try {
const res = await fetch(`/api/notes/${noteId}`);
if (res.ok) {
const updated = await res.json();
title = updated.title ?? '';
content = updated.content ?? '';
color = updated.color ?? 'default';
checklistMode = updated.checklistMode ?? false;
// Bump editor tick to force TiptapEditor to re-render
editorTick++;
// Update the store too
notes.update((list) =>
list.map((n) => (n.id === noteId ? { ...n, ...updated } : n))
);
}
} catch {
// failed silently
}
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
fetch(`/api/notes/${noteId}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
}
});
function handleAttachmentUpload(attachment: Attachment) {
attachmentsList = [...attachmentsList, attachment];
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
if (!noteId) return;
try {
const res = await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ featured })
});
if (res.ok) {
attachmentsList = attachmentsList.map((a) =>
a.id === attachmentId ? { ...a, featured } : a
);
}
} catch (err) {
console.error('Failed to toggle featured:', err);
}
}
async function handleAttachmentRemove(attachmentId: string) {
if (!noteId) return;
try {
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
}
let bgStyle = $state('');
$effect(() => {
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
});
// Use pointer events to detect backdrop clicks. Unlike mouse events,
// pointer events are never synthetically re-dispatched from touch, so
// the tap that opened the editor on mobile cannot ghost-click the overlay.
// Both pointerdown and pointerup must target the overlay itself to close.
let pointerdownOnOverlay = false;
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
}
function handleOverlayPointerup(e: PointerEvent) {
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
pointerdownOnOverlay = false;
}
async function saveAndClose() {
if (!title.trim() && !content.trim()) {
onClose();
return;
}
if (isNew) {
if (currentlyNew) {
await createNote({ title, content, color, checklistMode });
} else if (note) {
await updateNote(note.id, { title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
}
onClose();
}
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
const created = await createNote({
title: title || 'Untitled',
content,
color,
checklistMode
});
if (created) {
noteId = created.id;
currentlyNew = false;
return created.id;
}
return null;
}
async function toggleImageUpload() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showImageUpload = !showImageUpload;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
save();
saveAndClose();
}
}
@@ -72,20 +297,48 @@
requestAnimationFrame(() => textareaEl?.focus());
}
}
async function handleArchive() {
showOverflowMenu = false;
if (noteId) {
await archiveNote(noteId);
onClose();
}
}
async function handleTrash() {
showOverflowMenu = false;
if (noteId) {
await trashNote(noteId);
onClose();
}
}
function deleteCheckedItems() {
const items = parseChecklist(content).filter((i) => !i.checked);
content = serializeChecklist(items);
showOverflowMenu = false;
}
function uncheckAllItems() {
const items = parseChecklist(content).map((i) => ({ ...i, checked: false }));
content = serializeChecklist(items);
showOverflowMenu = false;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onclick={save}
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
@@ -96,6 +349,13 @@
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Content -->
@@ -124,20 +384,39 @@
{/if}
</div>
<!-- Image attachments -->
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
onUpload={handleAttachmentUpload}
onRemove={handleAttachmentRemove}
onToggleFeatured={handleToggleFeatured}
showDropZone={showImageUpload}
/>
</div>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
{/if}
<!-- Toolbar -->
<div class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2">
<div
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
style={toolbarInteractive ? '' : 'pointer-events: none'}
>
<div class="flex items-center gap-1">
<!-- Color picker toggle -->
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Background color"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
@@ -149,37 +428,64 @@
{/if}
</div>
<!-- Checklist mode toggle -->
<!-- Image attachment toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
data-testid="checklist-toggle"
use:tooltip={"Attachments"}
data-testid="image-toggle"
>
{#if checklistMode}
<Type class="h-5 w-5" />
{:else}
<SquareCheck class="h-5 w-5" />
{/if}
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</button>
<!-- Raw markdown mode toggle -->
<!-- Share button -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"Share"}
data-testid="share-toggle"
>
{#if hasPublicLink}
<Globe class="h-5 w-5 text-[var(--primary)]" />
{:else if isShared}
<Users class="h-5 w-5 text-[var(--primary)]" />
{:else}
<UserPlus class="h-5 w-5" />
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
<Users class="h-5 w-5" />
</span>
{/if}
<!-- Archive button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
>
<Archive class="h-5 w-5" />
</button>
{/if}
<!-- Overflow menu trigger -->
<button
onclick={toggleMarkdownMode}
bind:this={overflowBtnEl}
onclick={() => (showOverflowMenu = !showOverflowMenu)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
data-testid="markdown-toggle"
use:tooltip={"More"}
data-testid="overflow-menu-btn"
>
{#if rawMarkdownMode}
<FileText class="h-5 w-5" />
{:else}
<FileCode class="h-5 w-5" />
{/if}
<EllipsisVertical class="h-5 w-5" />
</button>
</div>
<button
onclick={save}
onclick={saveAndClose}
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="close-editor-btn"
>
@@ -187,4 +493,115 @@
</button>
</div>
</div>
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
{#if showOverflowMenu && overflowBtnEl}
{@const rect = overflowBtnEl.getBoundingClientRect()}
<div
bind:this={overflowMenuEl}
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
style="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
data-testid="overflow-menu"
>
<!-- Checklist mode toggle -->
<button
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="checklist-toggle"
>
{#if checklistMode}
<Type class="h-4 w-4" />
Switch to text
{:else}
<SquareCheck class="h-4 w-4" />
Checklist mode
{/if}
</button>
<!-- Markdown mode toggle (not available in checklist mode) -->
{#if !checklistMode}
<button
onclick={() => { toggleMarkdownMode(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
<FileText class="h-4 w-4" />
Rich text mode
{:else}
<FileCode class="h-4 w-4" />
Markdown mode
{/if}
</button>
{/if}
<!-- History -->
{#if !currentlyNew}
<button
onclick={() => { toggleHistory(); showOverflowMenu = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="history-toggle"
>
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
Version history
</button>
{/if}
<!-- Trash (only for saved notes) -->
{#if !currentlyNew}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
<button
onclick={handleTrash}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--border)]/10"
data-testid="trash-note-btn"
>
<Trash2 class="h-4 w-4" />
Move to trash
</button>
{/if}
<!-- Checklist-specific actions -->
{#if checklistMode}
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
{#if hasDoneItems}
<button
onclick={deleteCheckedItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="delete-checked-btn"
>
<Trash2 class="h-4 w-4" />
Delete checked
</button>
{/if}
<button
onclick={uncheckAllItems}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="uncheck-all-btn"
>
<ListX class="h-4 w-4" />
Uncheck all
</button>
{/if}
</div>
{/if}
</div>
{#if showShareDialog && noteId}
<ShareDialog
noteId={noteId}
collaborators={collaboratorsList}
{ownerName}
shareToken={currentShareToken}
onClose={() => (showShareDialog = false)}
onUpdate={handleCollaboratorsUpdate}
onShareUpdate={handleShareUpdate}
/>
{/if}
{#if showHistory && noteId}
<NoteHistory
noteId={noteId}
onClose={() => (showHistory = false)}
onRestored={handleHistoryRestored}
/>
{/if}
+47 -7
View File
@@ -1,23 +1,63 @@
<script lang="ts">
import NoteCard from './NoteCard.svelte';
import { dndzone, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import type { Note } from '$lib/types/index.js';
interface Props {
notes: Note[];
label?: string;
onEdit: (note: Note) => void;
draggable?: boolean;
dndType?: string;
onReorder?: (noteIds: string[]) => void;
}
let { notes, label = '', onEdit }: Props = $props();
let { notes, label = '', onEdit, draggable = false, dndType = 'notes', onReorder }: Props = $props();
let localItems = $state<Note[]>([]);
$effect(() => {
localItems = [...notes];
});
const flipDurationMs = 150;
function handleConsider(e: CustomEvent<DndEvent<Note>>) {
localItems = e.detail.items;
}
function handleFinalize(e: CustomEvent<DndEvent<Note>>) {
localItems = e.detail.items;
onReorder?.(localItems.map((n) => n.id));
}
let displayItems = $derived(draggable ? localItems : notes);
</script>
{#if notes.length > 0}
{#if displayItems.length > 0}
{#if label}
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
{#each notes as note (note.id)}
<NoteCard {note} {onEdit} />
{/each}
</div>
{#if draggable}
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
data-testid="note-grid"
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {}, delayTouchStart: 400 }}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each localItems as note (note.id)}
<div class="h-full outline-none" animate:flip={{ duration: flipDurationMs }}>
<NoteCard {note} {onEdit} fullHeight />
</div>
{/each}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
{#each displayItems as note (note.id)}
<NoteCard {note} {onEdit} />
{/each}
</div>
{/if}
{/if}
+224
View File
@@ -0,0 +1,224 @@
<script lang="ts">
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
import X from 'lucide-svelte/icons/x';
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
import Clock from 'lucide-svelte/icons/clock';
import { showToast } from '$lib/stores/toast.js';
interface Props {
noteId: string;
onClose: () => void;
onRestored: () => void;
}
const { noteId, onClose, onRestored }: Props = $props();
let versions = $state<NoteVersionSummary[]>([]);
let selectedVersion = $state<NoteVersion | null>(null);
let isLoading = $state(true);
let isLoadingVersion = $state(false);
let isRestoring = $state(false);
let showConfirm = $state(false);
$effect(() => {
// Track noteId explicitly so this only re-runs when the note changes
noteId;
void loadVersions();
});
async function loadVersions() {
isLoading = true;
try {
const res = await fetch(`/api/notes/${noteId}/versions`);
if (res.ok) {
versions = await res.json();
}
} catch {
// failed silently
} finally {
isLoading = false;
}
}
async function selectVersion(versionId: string) {
isLoadingVersion = true;
selectedVersion = null;
try {
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`);
if (res.ok) {
selectedVersion = await res.json();
}
} catch {
// failed silently
} finally {
isLoadingVersion = false;
}
}
async function restoreVersion() {
if (!selectedVersion) return;
isRestoring = true;
try {
const res = await fetch(
`/api/notes/${noteId}/versions/${selectedVersion.id}/restore`,
{ method: 'POST' }
);
if (res.ok) {
showToast('Version restored successfully', 'success');
onRestored();
onClose();
} else {
showToast('Failed to restore version', 'error');
}
} catch {
showToast('Failed to restore version', 'error');
} finally {
isRestoring = false;
showConfirm = false;
}
}
function formatDate(date: Date | string): string {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10"
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
data-testid="history-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
data-testid="history-panel"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
<div class="flex items-center gap-2">
<Clock class="h-4 w-4 text-[var(--text-muted)]" />
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
</div>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close history"
data-testid="close-history-btn"
>
<X class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
<div class="flex min-h-0 max-h-[60vh] flex-1">
<!-- Version list -->
<div class="w-56 flex-none border-r border-[var(--border-subtle)] overflow-y-auto">
{#if isLoading}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Loading…
</div>
{:else if versions.length === 0}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
No history yet
</div>
{:else}
<ul class="divide-y divide-[var(--border-subtle)]">
{#each versions as v (v.id)}
<li>
<button
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
onclick={() => selectVersion(v.id)}
data-testid="version-item"
>
<div class="flex items-center justify-between gap-1">
<span class="text-xs font-semibold text-[var(--primary)]">v{v.version}</span>
</div>
<div class="mt-0.5 text-xs text-[var(--text-muted)]">{formatDate(v.createdAt)}</div>
{#if v.title}
<div class="mt-1 truncate text-xs font-medium text-[var(--text)]">{v.title}</div>
{/if}
{#if v.contentPreview}
<div class="mt-0.5 line-clamp-2 text-xs text-[var(--text-muted)]">{v.contentPreview}</div>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Version preview -->
<div class="flex min-w-0 flex-1 flex-col">
{#if isLoadingVersion}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Loading…
</div>
{:else if selectedVersion}
<div class="flex flex-1 flex-col overflow-y-auto">
<div class="flex-1 overflow-y-auto px-4 py-3">
{#if selectedVersion.title}
<h3 class="mb-2 text-base font-semibold text-[var(--text)]">{selectedVersion.title}</h3>
{/if}
<pre class="whitespace-pre-wrap text-sm text-[var(--text)] font-[inherit]">{selectedVersion.content || '(empty)'}</pre>
</div>
<!-- Restore footer -->
<div class="border-t border-[var(--border-subtle)] px-4 py-3">
{#if showConfirm}
<div class="flex flex-col gap-2">
<p class="text-xs text-[var(--text-muted)]">
This will replace the current note. The current version will be saved as a snapshot.
</p>
<div class="flex gap-2">
<button
onclick={restoreVersion}
disabled={isRestoring}
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
data-testid="confirm-restore-btn"
>
<RotateCcw class="h-3.5 w-3.5" />
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
</button>
<button
onclick={() => (showConfirm = false)}
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
>
Cancel
</button>
</div>
</div>
{:else}
<button
onclick={() => (showConfirm = true)}
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
data-testid="restore-btn"
>
<RotateCcw class="h-3.5 w-3.5" />
Restore this version
</button>
{/if}
</div>
</div>
{:else}
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
Select a version to preview
</div>
{/if}
</div>
</div>
</div>
</div>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
import { onMount } from 'svelte';
import type { Note, NoteFilter } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
import SquareCheck from 'lucide-svelte/icons/square-check';
import { tooltip } from '$lib/utils/tooltip.js';
interface Props {
filter: NoteFilter;
tag?: string | null;
}
let { filter, tag = null }: Props = $props();
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
let newNoteChecklist = $state(false);
// Sync store with route props and reload notes when filter changes
$effect(() => {
currentFilter.set(filter);
selectedTag.set(tag);
loadNotes(filter);
});
function openEditor(note: Note) {
editingNote = note;
history.replaceState(null, '', `#${note.id}`);
}
function closeEditor() {
editingNote = null;
showNewNote = false;
newNoteChecklist = false;
history.replaceState(null, '', location.pathname);
}
function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders);
}
onMount(() => {
const hash = location.hash.slice(1);
if (!hash) return;
const unsubscribe = notes.subscribe(($notes) => {
if ($notes.length === 0) return;
const note = $notes.find((n) => n.id === hash);
if (note) editingNote = note;
unsubscribe();
});
});
</script>
{#if filter === 'all' && !tag}
<div class="mx-auto mb-6 hidden max-w-xl md:block">
<div class="flex cursor-pointer items-center gap-0 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] transition-colors hover:border-[var(--primary)]">
<button
onclick={() => (showNewNote = true)}
class="flex flex-1 cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm text-[var(--text-muted)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
<button
onclick={() => { newNoteChecklist = true; showNewNote = true; }}
class="cursor-pointer rounded-sm p-3 text-[var(--text-muted)] hover:text-[var(--primary)]"
use:tooltip={"New checklist"}
data-testid="new-checklist-btn"
>
<SquareCheck class="h-4 w-4" />
</button>
</div>
</div>
<button
onclick={() => (showNewNote = true)}
class="fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border)] bg-[var(--primary)] text-white shadow-[var(--card-shadow)] transition-colors hover:bg-[var(--primary-hover)] md:hidden"
aria-label="Add a crumb"
data-testid="new-note-fab"
>
<Plus class="h-5 w-5" />
</button>
{/if}
<div class="mb-4">
<TagFilter />
</div>
{#if filter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
{:else if filter === 'trashed'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
{:else if tag}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
{/if}
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
</div>
{/if}
<NoteGrid
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable
dndType="unpinned-notes"
onReorder={handleReorder}
/>
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
<p class="font-['Press_Start_2P'] text-xs">
{#if filter === 'trashed'}
No crumbs in trash
{:else if filter === 'archived'}
No archived crumbs
{:else if tag}
No crumbs with tag #{tag}
{:else}
No crumbs yet
{/if}
</p>
</div>
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
@@ -0,0 +1,46 @@
<script lang="ts">
import type { ZXCVBNResult } from 'zxcvbn';
let { password = '' }: { password: string } = $props();
let result = $state<ZXCVBNResult | null>(null);
$effect(() => {
if (password.length > 0) {
import('zxcvbn').then((mod) => {
result = mod.default(password);
});
} else {
result = null;
}
});
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
const colors = [
'bg-[var(--destructive)]',
'bg-[var(--destructive)]',
'bg-[var(--primary)]',
'bg-[var(--success-text)]',
'bg-[var(--success-text)]'
];
</script>
{#if result}
<div class="mt-1 mb-2" data-testid="password-strength">
<div class="flex gap-1">
{#each { length: 4 } as _, i}
<div
class="h-1 flex-1 rounded-sm transition-colors {i <= result.score
? colors[result.score]
: 'bg-[var(--border-subtle)]'}"
></div>
{/each}
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]">
{labels[result.score]}
{#if result.feedback.warning}
— {result.feedback.warning}
{/if}
</p>
</div>
{/if}
+297
View File
@@ -0,0 +1,297 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import X from 'lucide-svelte/icons/x';
import UserMinus from 'lucide-svelte/icons/user-minus';
import Search from 'lucide-svelte/icons/search';
import Globe from 'lucide-svelte/icons/globe';
import Link from 'lucide-svelte/icons/link';
import Copy from 'lucide-svelte/icons/copy';
import Check from 'lucide-svelte/icons/check';
import { showToast } from '$lib/stores/toast.js';
import { page } from '$app/state';
interface UserResult {
id: number;
displayName: string;
email: string;
}
interface Props {
noteId: string;
collaborators: Collaborator[];
ownerName: string;
shareToken?: string;
onClose: () => void;
onUpdate: (collaborators: Collaborator[]) => void;
onShareUpdate: (token: string | null) => void;
}
const { noteId, collaborators, ownerName, shareToken, onClose, onUpdate, onShareUpdate }: Props = $props();
let publicShareToken = $state(shareToken ?? null);
let copying = $state(false);
let togglingShare = $state(false);
let searchQuery = $state('');
let searchResults = $state<UserResult[]>([]);
let isSearching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
const shareUrl = $derived(publicShareToken ? `${page.url.origin}/s/${publicShareToken}` : null);
async function togglePublicShare() {
togglingShare = true;
try {
if (publicShareToken) {
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'DELETE' });
if (!res.ok) {
showToast('Failed to remove share link', 'error');
return;
}
publicShareToken = null;
onShareUpdate(null);
showToast('Public link removed', 'success');
} else {
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'POST' });
if (!res.ok) {
showToast('Failed to create share link', 'error');
return;
}
const data = await res.json();
publicShareToken = data.token;
onShareUpdate(data.token);
showToast('Public link created', 'success');
}
} catch {
showToast('Failed to update share link', 'error');
} finally {
togglingShare = false;
}
}
async function copyShareUrl() {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
copying = true;
setTimeout(() => (copying = false), 2000);
} catch {
showToast('Failed to copy link', 'error');
}
}
function handleSearch(query: string) {
clearTimeout(searchTimeout);
if (!query.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
isSearching = true;
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const users: UserResult[] = await res.json();
const collabIds = new Set(collaborators.map((c) => c.userId));
searchResults = users.filter((u) => !collabIds.has(u.id));
}
} catch {
// Search failed silently
} finally {
isSearching = false;
}
}, 300);
}
$effect(() => {
handleSearch(searchQuery);
});
async function addCollaborator(user: UserResult) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id })
});
if (res.ok) {
const updated: Collaborator[] = await res.json();
onUpdate(updated);
searchQuery = '';
searchResults = [];
showToast(`Shared with ${user.displayName || user.email}`, 'success');
} else {
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
showToast(err.message || 'Failed to share', 'error');
}
} catch {
showToast('Failed to share note', 'error');
}
}
async function removeCollaborator(userId: number) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
method: 'DELETE'
});
if (res.ok) {
const updated = collaborators.filter((c) => c.userId !== userId);
onUpdate(updated);
}
} catch {
showToast('Failed to remove collaborator', 'error');
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onclick={onClose}
onkeydown={handleKeydown}
data-testid="share-dialog-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
data-testid="share-dialog"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Public link section -->
<div class="px-4 pt-3 pb-3" data-testid="public-link-section">
<div
class="rounded-sm border p-3 transition-colors {publicShareToken
? 'border-[var(--primary)] bg-[var(--primary)]/5'
: 'border-[var(--border-subtle)]'}"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Globe class="h-4 w-4 {publicShareToken ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'}" />
<span class="text-sm font-medium text-[var(--text)]">Public link</span>
</div>
<button
onclick={togglePublicShare}
disabled={togglingShare}
class="relative h-5 w-9 rounded-sm transition-colors {publicShareToken
? 'bg-[var(--primary)]'
: 'bg-[var(--border-subtle)]'}"
title={publicShareToken ? 'Disable public link' : 'Enable public link'}
data-testid="public-share-toggle"
>
<span
class="absolute top-0.5 h-4 w-4 rounded-sm bg-[var(--bg-surface)] shadow-[1px_1px_0px_var(--border-subtle)] transition-all {publicShareToken
? 'left-[1.125rem]'
: 'left-0.5'}"
></span>
</button>
</div>
{#if publicShareToken && shareUrl}
<div class="mt-2 flex items-center gap-1" data-testid="share-url-container">
<div class="flex min-w-0 flex-1 items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-1.5">
<Link class="h-3 w-3 shrink-0 text-[var(--text-muted)]" />
<span class="truncate text-xs text-[var(--text-muted)]" data-testid="share-url-text">{shareUrl}</span>
</div>
<button
onclick={copyShareUrl}
class="shrink-0 rounded-sm border border-[var(--border-subtle)] p-1.5 hover:border-[var(--primary)] hover:bg-[var(--primary)]/5 transition-colors"
title="Copy link"
data-testid="copy-share-url-btn"
>
{#if copying}
<Check class="h-4 w-4 text-[var(--success-text)]" />
{:else}
<Copy class="h-4 w-4 text-[var(--text-muted)]" />
{/if}
</button>
</div>
{/if}
</div>
</div>
<div class="border-t border-[var(--border-subtle)]"></div>
<!-- Search input -->
<div class="relative px-4 pt-3">
<div class="relative">
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
<input
type="text"
placeholder="Search users..."
bind:value={searchQuery}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="share-search-input"
/>
</div>
<!-- Search results dropdown -->
{#if searchResults.length > 0}
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
{#each searchResults as user}
<li>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
onclick={() => addCollaborator(user)}
data-testid="share-user-result"
>
<span class="font-medium">{user.displayName || 'User'}</span>
{#if user.email}
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Current collaborators -->
<div class="px-4 py-3">
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
<ul class="space-y-1">
<!-- Owner -->
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
<span class="text-sm text-[var(--text)]">
{ownerName}
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
</span>
</li>
<!-- Collaborators -->
{#each collaborators as collab}
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
<span class="text-sm text-[var(--text)]">
{collab.displayName || collab.email}
</span>
<button
onclick={() => removeCollaborator(collab.userId)}
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
title="Remove"
data-testid="remove-collaborator-btn"
>
<UserMinus class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
</div>
</div>
</div>
@@ -0,0 +1,95 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Globe from 'lucide-svelte/icons/globe';
import Users from 'lucide-svelte/icons/users';
interface Props {
shareToken?: string;
collaborators: Collaborator[];
isOwner: boolean;
}
const { shareToken, collaborators, isOwner }: Props = $props();
let showPopover = $state(false);
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
const hasPublicLink = $derived(!!shareToken);
const hasCollaborators = $derived(collaborators.length > 0);
const tooltipLines = $derived.by(() => {
const lines: string[] = [];
if (hasPublicLink) lines.push('Shared publicly via link');
if (hasCollaborators) {
lines.push(`Shared with ${collaborators.length} collaborator${collaborators.length > 1 ? 's' : ''}`);
}
return lines;
});
function handleMouseEnter() {
hoverTimeout = setTimeout(() => {
showPopover = true;
}, 150);
}
function handleMouseLeave() {
clearTimeout(hoverTimeout);
showPopover = false;
}
function handleClick(e: Event) {
e.stopPropagation();
showPopover = !showPopover;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative inline-flex"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<button
type="button"
class="rounded-sm p-1 {hasPublicLink ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'} hover:text-[var(--primary)]"
title={tooltipLines.join(', ')}
aria-label={tooltipLines.join(', ')}
onclick={handleClick}
data-testid="sharing-indicator"
>
{#if hasPublicLink}
<Globe class="h-4 w-4" />
{:else}
<Users class="h-4 w-4" />
{/if}
</button>
{#if showPopover}
<div
class="absolute right-0 top-full z-20 mt-1 min-w-[180px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="sharing-popover"
>
{#if hasPublicLink}
<div class="mb-1 flex items-center gap-1.5">
<Globe class="h-3 w-3 text-[var(--primary)]" />
<span class="text-xs font-semibold text-[var(--primary)]">Public link active</span>
</div>
{/if}
{#if hasCollaborators}
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
<ul class="space-y-0.5">
{#if isOwner}
<li class="text-xs text-[var(--text)]">
You <span class="text-[var(--text-muted)]">(Owner)</span>
</li>
{/if}
{#each collaborators as collab}
<li class="text-xs text-[var(--text)]">
{collab.displayName || collab.email}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
+8 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
import CloudCheck from 'lucide-svelte/icons/cloud-check';
import CloudSync from 'lucide-svelte/icons/cloud-sync';
import CloudOff from 'lucide-svelte/icons/cloud-off';
@@ -44,7 +44,12 @@
});
</script>
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
<button
class="flex cursor-pointer items-center rounded-sm p-1 transition-colors hover:bg-[var(--border-subtle)]/50"
title="Sync status: {status} — click to sync"
onclick={() => sync()}
data-testid="sync-indicator"
>
{#if displayStatus === 'synced'}
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
@@ -54,4 +59,4 @@
{:else}
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
{/if}
</div>
</button>
+1 -1
View File
@@ -11,7 +11,7 @@
{#if onclick}
<button
{onclick}
class="rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
class="whitespace-nowrap rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
? 'bg-[var(--primary)] text-white'
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
data-testid="tag-chip"
+21 -4
View File
@@ -1,16 +1,33 @@
<script lang="ts">
import { allTags, selectedTag } from '$lib/stores/notes.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { allTags } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
function toggleTag(tag: string) {
selectedTag.update((current) => (current === tag ? null : tag));
const isActive = $page.url.pathname === `/tag/${tag}`;
goto(isActive ? '/' : `/tag/${tag}`);
}
function isTagActive(tag: string, pathname: string): boolean {
return pathname === `/tag/${tag}`;
}
</script>
{#if $allTags.length > 0}
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
<!-- svelte-ignore css_unused_selector -->
<div class="tag-scroll flex gap-2 overflow-x-auto max-md:flex-nowrap md:flex-wrap" data-testid="tag-filter">
{#each $allTags as tag}
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
{/each}
</div>
{/if}
<style>
.tag-scroll {
scrollbar-width: none;
}
.tag-scroll::-webkit-scrollbar {
display: none;
}
</style>
+19 -2
View File
@@ -6,6 +6,12 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
interface Props {
@@ -30,6 +36,12 @@
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Markdown
],
content,
@@ -57,16 +69,21 @@
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
data-testid="tiptap-editor"
role="textbox"
aria-multiline="true"
tabindex="0"
onclick={() => editor?.commands.focus()}
></div>
<style>
.tiptap-wrapper :global(.tiptap) {
outline: none;
min-height: 80px;
flex: 1;
}
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { generateApiKey, validateApiKey, listApiKeys, deleteApiKey } from './api-keys.js';
import { users } from './db/schema.js';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
describe('API Keys', () => {
let db: BetterSQLite3Database<typeof schema>;
let userId: number;
beforeEach(() => {
({ db } = createTestDb());
// Create a test user
db.insert(users)
.values({ email: 'test@example.com', displayName: 'Test', createdAt: new Date() })
.run();
userId = 1;
});
describe('generateApiKey', () => {
it('returns key with crumbs_ prefix', () => {
const result = generateApiKey('Test Key', userId, db);
expect(result.key).toMatch(/^crumbs_[a-f0-9]{64}$/);
expect(result.name).toBe('Test Key');
expect(result.id).toBeTruthy();
expect(result.keyPrefix).toBe(result.key.slice(0, 12));
});
it('generates unique keys each time', () => {
const key1 = generateApiKey('Key 1', userId, db);
const key2 = generateApiKey('Key 2', userId, db);
expect(key1.key).not.toBe(key2.key);
expect(key1.id).not.toBe(key2.id);
});
});
describe('validateApiKey', () => {
it('returns valid with userId for a valid key', () => {
const { key } = generateApiKey('Test Key', userId, db);
const result = validateApiKey(key, db);
expect(result.valid).toBe(true);
expect(result.userId).toBe(userId);
});
it('returns invalid for an invalid key', () => {
const result = validateApiKey('crumbs_invalid', db);
expect(result.valid).toBe(false);
expect(result.userId).toBeNull();
});
it('returns invalid for empty string', () => {
expect(validateApiKey('', db).valid).toBe(false);
});
it('returns invalid for key without prefix', () => {
expect(validateApiKey('no_prefix_here', db).valid).toBe(false);
});
it('updates lastUsedAt on validation', () => {
const { key, id } = generateApiKey('Test Key', userId, db);
const before = listApiKeys(userId, db).find((k) => k.id === id);
expect(before?.lastUsedAt).toBeNull();
validateApiKey(key, db);
const after = listApiKeys(userId, db).find((k) => k.id === id);
expect(after?.lastUsedAt).toBeInstanceOf(Date);
});
});
describe('listApiKeys', () => {
it('returns empty array when no keys exist', () => {
expect(listApiKeys(userId, db)).toEqual([]);
});
it('returns keys without hash', () => {
generateApiKey('Key 1', userId, db);
generateApiKey('Key 2', userId, db);
const keys = listApiKeys(userId, db);
expect(keys).toHaveLength(2);
expect(keys[0]).toHaveProperty('name');
expect(keys[0]).toHaveProperty('keyPrefix');
expect(keys[0]).toHaveProperty('createdAt');
expect(keys[0]).not.toHaveProperty('keyHash');
});
it('only returns keys for the specified user', () => {
db.insert(users)
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
.run();
const otherUserId = 2;
generateApiKey('My Key', userId, db);
generateApiKey('Other Key', otherUserId, db);
expect(listApiKeys(userId, db)).toHaveLength(1);
expect(listApiKeys(otherUserId, db)).toHaveLength(1);
});
});
describe('deleteApiKey', () => {
it('deletes an existing key and returns true', () => {
const { id, key } = generateApiKey('Test Key', userId, db);
expect(deleteApiKey(id, userId, db)).toBe(true);
expect(validateApiKey(key, db).valid).toBe(false);
expect(listApiKeys(userId, db)).toHaveLength(0);
});
it('returns false for non-existent key', () => {
expect(deleteApiKey('nonexistent', userId, db)).toBe(false);
});
it('cannot delete another user\'s key', () => {
db.insert(users)
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
.run();
const otherUserId = 2;
const { id } = generateApiKey('Other Key', otherUserId, db);
expect(deleteApiKey(id, userId, db)).toBe(false);
});
});
});
+105
View File
@@ -0,0 +1,105 @@
import { db } from './db/index.js';
import { apiKeys, users } from './db/schema.js';
import { eq } from 'drizzle-orm';
import { randomBytes, createHash } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
type Db = BetterSQLite3Database<typeof schema>;
const PREFIX = 'crumbs_';
function hashKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
export function generateApiKey(
name: string,
userId: number,
database: Db = db
): { id: string; key: string; name: string; keyPrefix: string } {
const id = uuidv4();
const raw = randomBytes(32).toString('hex');
const key = `${PREFIX}${raw}`;
const keyHash = hashKey(key);
const keyPrefix = key.slice(0, 12);
database
.insert(apiKeys)
.values({
id,
userId,
name,
keyHash,
keyPrefix,
createdAt: new Date()
})
.run();
return { id, key, name, keyPrefix };
}
export function validateApiKey(
key: string,
database: Db = db
): { valid: boolean; userId: number | null } {
if (!key || !key.startsWith(PREFIX)) return { valid: false, userId: null };
const keyHash = hashKey(key);
const row = database.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get();
if (!row) return { valid: false, userId: null };
database
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, row.id))
.run();
return { valid: true, userId: row.userId };
}
export function getUserForApiKey(
userId: number,
database: Db = db
): App.Locals['user'] | null {
const user = database
.select({
id: users.id,
email: users.email,
displayName: users.displayName,
role: users.role
})
.from(users)
.where(eq(users.id, userId))
.get();
return user ? (user as App.Locals['user']) : null;
}
export function listApiKeys(userId: number, database: Db = db) {
return database
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId))
.all();
}
export function deleteApiKey(id: string, userId: number, database: Db = db): boolean {
const existing = database
.select()
.from(apiKeys)
.where(eq(apiKeys.id, id))
.get();
if (!existing || existing.userId !== userId) return false;
const result = database.delete(apiKeys).where(eq(apiKeys.id, id)).run();
return result.changes > 0;
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, users } from './db/schema.js';
import type { Db } from './db/index.js';
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
const STRANGER_ID = 3;
function seedUsers() {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
function shareNote(noteId: string) {
db.insert(noteCollaborators)
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('canAccessNote', () => {
it('should allow owner to access their note', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', OWNER_ID);
expect(result).toEqual({ canAccess: true, isOwner: true });
});
it('should allow collaborator to access shared note', () => {
seedNote('n1');
shareNote('n1');
const result = canAccessNote(db, 'n1', COLLAB_ID);
expect(result).toEqual({ canAccess: true, isOwner: false });
});
it('should deny non-collaborator access', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', STRANGER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
it('should return false for non-existent note', () => {
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
});
describe('requireNoteAccess', () => {
it('should throw 404 for unauthorized user', () => {
seedNote('n1');
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
});
it('should return isOwner for authorized owner', () => {
seedNote('n1');
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
});
});
describe('requireNoteOwnership', () => {
it('should throw 404 for collaborator', () => {
seedNote('n1');
shareNote('n1');
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
});
it('should not throw for owner', () => {
seedNote('n1');
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
});
});
describe('getUserId', () => {
it('should throw 401 when no user in locals', () => {
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
});
it('should return user id from locals', () => {
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
expect(id).toBe(42);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { error } from '@sveltejs/kit';
import type { Db } from './db/index.js';
import { notes, noteCollaborators } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
type EventWithLocals = {
locals: App.Locals;
};
export function getUserId(event: EventWithLocals): number {
if (!event.locals.user) throw error(401, 'Unauthorized');
return event.locals.user.id;
}
export function requireAdmin(event: EventWithLocals) {
const user = event.locals.user;
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
return user;
}
/**
* Check if a user can access a note (as owner or collaborator).
*/
export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note) return { canAccess: false, isOwner: false };
if (note.userId === userId) return { canAccess: true, isOwner: true };
const collab = db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.get();
return { canAccess: !!collab, isOwner: false };
}
/**
* Require that a user can access a note. Throws 404 if no access.
*/
export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } {
const { canAccess, isOwner } = canAccessNote(db, noteId, userId);
if (!canAccess) throw error(404, 'Not found');
return { isOwner };
}
/**
* Require that a user is the owner of a note. Throws 404 if not owner.
*/
export function requireNoteOwnership(db: Db, noteId: string, userId: number): void {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note || note.userId !== userId) throw error(404, 'Not found');
}
+68 -9
View File
@@ -2,9 +2,10 @@ import { existsSync, mkdirSync } from 'fs';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
const DATA_DIR = process.env.DATA_DIR || './data';
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
@@ -15,8 +16,11 @@ if (!existsSync(ATTACHMENTS_DIR)) {
}
export async function saveAttachment(
db: Db,
noteId: string,
file: File
file: File,
userId: number,
thumbnail?: File | Blob | null
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
const ext = file.name.split('.').pop() || 'bin';
@@ -26,15 +30,25 @@ export async function saveAttachment(
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
let thumbnailPath: string | null = null;
if (thumbnail) {
const thumbFilename = `${id}_thumb.webp`;
thumbnailPath = join(ATTACHMENTS_DIR, thumbFilename);
const thumbBuffer = Buffer.from(await thumbnail.arrayBuffer());
await writeFile(thumbnailPath, thumbBuffer);
}
const [attachment] = await db
.insert(attachments)
.values({
id,
userId,
noteId,
filename: file.name,
mimeType: file.type,
size: file.size,
path: filePath,
thumbnailPath,
createdAt: new Date()
})
.returning();
@@ -42,22 +56,67 @@ export async function saveAttachment(
return attachment;
}
export async function getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
/**
* Batch fetch attachments for multiple notes in a single query.
* Returns a Map of noteId → Attachment[].
*/
export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map<string, Attachment[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select()
.from(attachments)
.where(inArray(attachments.noteId, noteIds))
.all();
const map = new Map<string, Attachment[]>();
for (const row of rows) {
const existing = map.get(row.noteId);
if (existing) {
existing.push(row as Attachment);
} else {
map.set(row.noteId, [row as Attachment]);
}
}
return map;
}
export async function getAttachmentsByNote(noteId: string) {
export async function getAttachment(db: Db, id: string, noteId: string) {
return db
.select()
.from(attachments)
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.get();
}
export async function getAttachmentsByNote(db: Db, noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function deleteAttachment(id: string) {
const attachment = await getAttachment(id);
export async function updateAttachment(db: Db, id: string, noteId: string, data: { featured: boolean }) {
const [updated] = await db
.update(attachments)
.set({ featured: data.featured })
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.returning();
return updated ?? null;
}
export async function deleteAttachment(db: Db, id: string, noteId: string) {
const attachment = await getAttachment(db, id, noteId);
if (attachment) {
try {
await unlink(attachment.path);
} catch {
// File may already be deleted
}
await db.delete(attachments).where(eq(attachments.id, id));
if (attachment.thumbnailPath) {
try {
await unlink(attachment.thumbnailPath);
} catch {
// Thumbnail may already be deleted
}
}
await db.delete(attachments).where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)));
}
}
+12 -5
View File
@@ -41,18 +41,25 @@ describe('Auth - User Setup', () => {
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'admin@test.com',
displayName: 'Admin',
role: 'admin',
passwordHash: hash,
createdAt: new Date()
});
const user = await db.select().from(users).get();
expect(user).toBeDefined();
expect(user!.email).toBe('admin@test.com');
expect(user!.role).toBe('admin');
expect(user!.passwordHash).not.toBe(password);
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
expect(await argon2.verify(user!.passwordHash!, password)).toBe(true);
});
it('should only allow one user', async () => {
it('should only allow one admin setup', async () => {
await db.insert(users).values({
email: 'admin@test.com',
role: 'admin',
passwordHash: 'hash1',
createdAt: new Date()
});
@@ -66,7 +73,7 @@ describe('Auth - Sessions', () => {
it('should create and validate a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -85,7 +92,7 @@ describe('Auth - Sessions', () => {
it('should detect expired sessions', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -103,7 +110,7 @@ describe('Auth - Sessions', () => {
it('should delete a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
+289 -34
View File
@@ -1,65 +1,320 @@
import { db } from './db/index.js';
import { db, sqlite } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq, lt } from 'drizzle-orm';
import { eq, lt, and } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import type { User } from '$lib/types/index.js';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type SessionUser = { id: number; email: string; displayName: string; role: 'admin' | 'user' };
function toUser(row: typeof users.$inferSelect): User {
return {
id: row.id,
email: row.email,
displayName: row.displayName,
role: row.role,
authProvider: row.authProvider,
providerId: row.providerId,
createdAt: row.createdAt
};
}
export async function isSetupComplete(): Promise<boolean> {
const user = await db.select().from(users).get();
const user = db.select().from(users).get();
return !!user;
}
export async function setupUser(password: string): Promise<boolean> {
const existing = await db.select().from(users).get();
if (existing) return false; // Already set up
export async function setupUser(
email: string,
password: string,
displayName?: string
): Promise<User | null> {
const existing = db.select().from(users).get();
if (existing) return null; // Already set up
const hash = await argon2.hash(password);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
return true;
const result = db
.insert(users)
.values({
email,
displayName: displayName || email.split('@')[0] || 'Admin',
role: 'admin',
passwordHash: hash,
authProvider: 'password',
createdAt: new Date()
})
.returning()
.get();
return toUser(result);
}
export async function verifyPassword(password: string): Promise<boolean> {
const user = await db.select().from(users).get();
if (!user) return false;
return argon2.verify(user.passwordHash, password);
export async function verifyPassword(
email: string,
password: string
): Promise<User | null> {
const user = db
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.authProvider, 'password')))
.get();
if (!user || !user.passwordHash) return null;
const valid = await argon2.verify(user.passwordHash, password);
return valid ? toUser(user) : null;
}
export async function createSession(): Promise<string> {
const user = await db.select().from(users).get();
if (!user) throw new Error('No user exists');
export async function createSession(
userId: number,
meta?: { userAgent?: string; ip?: string }
): Promise<string> {
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
});
const now = new Date();
db.insert(sessions)
.values({
id: token,
userId,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
createdAt: now,
userAgent: meta?.userAgent || null,
ip: meta?.ip || null,
lastUsedAt: now
})
.run();
return token;
}
export async function validateSession(token: string): Promise<boolean> {
if (!token) return false;
export async function validateSession(
token: string
): Promise<{ valid: boolean; user?: SessionUser }> {
if (!token) return { valid: false };
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) return false;
// Join sessions with users to get user data in one query
const result = sqlite
.prepare(
`SELECT s.id as session_id, s.expires_at, u.id, u.email, u.display_name, u.role
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ?`
)
.get(token) as
| {
session_id: string;
expires_at: number;
id: number;
email: string;
display_name: string;
role: 'admin' | 'user';
}
| undefined;
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, token));
return false;
if (!result) return { valid: false };
if (result.expires_at * 1000 < Date.now()) {
db.delete(sessions).where(eq(sessions.id, token)).run();
return { valid: false };
}
return true;
// Update lastUsedAt
db.update(sessions)
.set({ lastUsedAt: new Date() })
.where(eq(sessions.id, token))
.run();
return {
valid: true,
user: {
id: result.id,
email: result.email,
displayName: result.display_name,
role: result.role
}
};
}
export async function deleteSession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, token));
db.delete(sessions).where(eq(sessions.id, token)).run();
}
export async function cleanExpiredSessions(): Promise<void> {
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run();
}
export type SessionInfo = {
id: string;
createdAt: Date | null;
userAgent: string | null;
ip: string | null;
lastUsedAt: Date | null;
expiresAt: Date;
isCurrent: boolean;
};
export function listUserSessions(userId: number, currentToken: string): SessionInfo[] {
return db
.select()
.from(sessions)
.where(eq(sessions.userId, userId))
.all()
.map((s) => ({
id: s.id,
createdAt: s.createdAt,
userAgent: s.userAgent,
ip: s.ip,
lastUsedAt: s.lastUsedAt,
expiresAt: s.expiresAt,
isCurrent: s.id === currentToken
}));
}
export function revokeSession(userId: number, sessionId: string): boolean {
const session = db
.select()
.from(sessions)
.where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId)))
.get();
if (!session) return false;
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return true;
}
export function revokeAllSessions(userId: number, exceptToken?: string): void {
if (exceptToken) {
sqlite
.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?')
.run(userId, exceptToken);
} else {
db.delete(sessions).where(eq(sessions.userId, userId)).run();
}
}
// --- User CRUD (admin operations) ---
export async function createUser(
email: string,
displayName: string,
password: string | null,
role: 'admin' | 'user' = 'user'
): Promise<User> {
const hash = password ? await argon2.hash(password) : null;
const result = db
.insert(users)
.values({
email,
displayName,
role,
passwordHash: hash,
authProvider: password ? 'password' : 'none',
createdAt: new Date()
})
.returning()
.get();
return toUser(result);
}
export function listUsers(): User[] {
return db
.select()
.from(users)
.all()
.map(toUser);
}
export function getUser(userId: number): User | null {
const row = db.select().from(users).where(eq(users.id, userId)).get();
return row ? toUser(row) : null;
}
export async function deleteUser(userId: number): Promise<void> {
// Cascade: delete user's data in dependency order
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
sqlite
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
.run(userId, userId);
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
sqlite
.prepare(
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
db.delete(users).where(eq(users.id, userId)).run();
}
export async function changePassword(
userId: number,
currentPassword: string,
newPassword: string
): Promise<boolean> {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user || !user.passwordHash) return false;
const valid = await argon2.verify(user.passwordHash, currentPassword);
if (!valid) return false;
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
return true;
}
export async function resetPassword(userId: number, newPassword: string): Promise<void> {
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
}
export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
db.update(users).set({ role }).where(eq(users.id, userId)).run();
}
/**
* Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user.
*/
export function findOrLinkOAuthUser(
provider: string,
providerId: string,
email: string,
name: string
): User | null {
// First try exact provider+providerId match
const byProvider = db
.select()
.from(users)
.where(and(eq(users.authProvider, provider), eq(users.providerId, providerId)))
.get();
if (byProvider) return toUser(byProvider);
// Try matching by email (invite-only: user must already exist)
const byEmail = db.select().from(users).where(eq(users.email, email)).get();
if (!byEmail) return null; // No matching user — not invited
// Link this OAuth provider to the existing user
db.update(users)
.set({ authProvider: provider, providerId, displayName: byEmail.displayName || name })
.where(eq(users.id, byEmail.id))
.run();
return toUser({ ...byEmail, authProvider: provider, providerId });
}
export function updateUserProfile(
userId: number,
data: { displayName?: string; email?: string }
): void {
const updates: Record<string, string> = {};
if (data.displayName !== undefined) updates.displayName = data.displayName;
if (data.email !== undefined) updates.email = data.email;
if (Object.keys(updates).length > 0) {
db.update(users).set(updates).where(eq(users.id, userId)).run();
}
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('fetchCollaboratorsForNotes', () => {
it('should return Map with user details for shared notes', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
const map = fetchCollaboratorsForNotes(db, ['n1']);
const collabs = map.get('n1');
expect(collabs).toHaveLength(1);
expect(collabs![0].userId).toBe(COLLAB_ID);
expect(collabs![0].displayName).toBe('Collab');
expect(collabs![0].email).toBe('collab@test.com');
});
it('should return empty Map for notes with no collaborators', () => {
seedNote('n1');
const map = fetchCollaboratorsForNotes(db, ['n1']);
expect(map.size).toBe(0);
});
});
describe('addCollaborator', () => {
it('should insert collaborator record', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
const rows = db.select().from(noteCollaborators).all();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(COLLAB_ID);
expect(rows[0].addedBy).toBe(OWNER_ID);
});
});
describe('removeCollaborator', () => {
it('should clean up noteUserState on removal', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 })
.run();
removeCollaborator(db, 'n1', COLLAB_ID);
const collabs = db.select().from(noteCollaborators).all();
expect(collabs).toHaveLength(0);
const states = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
.all();
expect(states).toHaveLength(0);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { Db } from './db/index.js';
import { noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Collaborator } from '$lib/types/index.js';
/**
* Batch fetch collaborators for multiple notes.
* Returns a Map of noteId → Collaborator[].
*/
export function fetchCollaboratorsForNotes(db: Db, noteIds: string[]): Map<string, Collaborator[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select({
noteId: noteCollaborators.noteId,
userId: noteCollaborators.userId,
displayName: users.displayName,
email: users.email,
addedAt: noteCollaborators.addedAt
})
.from(noteCollaborators)
.innerJoin(users, eq(noteCollaborators.userId, users.id))
.where(inArray(noteCollaborators.noteId, noteIds))
.all();
const map = new Map<string, Collaborator[]>();
for (const row of rows) {
const collab: Collaborator = {
userId: row.userId,
displayName: row.displayName,
email: row.email,
addedAt: row.addedAt
};
const existing = map.get(row.noteId);
if (existing) {
existing.push(collab);
} else {
map.set(row.noteId, [collab]);
}
}
return map;
}
/**
* Add a collaborator to a note.
*/
export function addCollaborator(db: Db, noteId: string, userId: number, addedBy: number): void {
db.insert(noteCollaborators)
.values({ noteId, userId, addedBy, addedAt: new Date() })
.run();
}
/**
* Remove a collaborator from a note. Also cleans up their per-user state.
*/
export function removeCollaborator(db: Db, noteId: string, userId: number): void {
db.delete(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.run();
db.delete(noteUserState)
.where(and(eq(noteUserState.noteId, noteId), eq(noteUserState.userId, userId)))
.run();
}
/**
* Get all collaborator user IDs for a note.
*/
export function getCollaboratorIds(db: Db, noteId: string): number[] {
return db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(eq(noteCollaborators.noteId, noteId))
.all()
.map((r) => r.userId);
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './schema.js';
import { eq, and } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
function seedTwoUsers(db: BetterSQLite3Database<typeof schema>) {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(db: BetterSQLite3Database<typeof schema>, id: string, userId: number) {
const now = new Date();
db.insert(notes).values({ id, userId, title: 'Test', content: '', createdAt: now, updatedAt: now }).run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedTwoUsers(db);
});
describe('noteCollaborators table', () => {
it('should add a collaborator to a note', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(1);
expect(result[0].userId).toBe(2);
expect(result[0].addedBy).toBe(1);
});
it('should enforce unique constraint on (noteId, userId)', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
expect(() =>
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run()
).toThrow();
});
it('should allow multiple collaborators per note', () => {
seedNote(db, 'n1', 1);
// Add a third user
db.insert(users)
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(noteCollaborators).values([
{ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() },
{ noteId: 'n1', userId: 3, addedBy: 1, addedAt: new Date() }
]).run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(2);
});
it('should remove a collaborator', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
db.delete(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, 'n1'), eq(noteCollaborators.userId, 2)))
.run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(0);
});
});
describe('noteUserState table', () => {
it('should store per-user pin/archive/sortOrder state', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 5 })
.run();
const result = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
.get();
expect(result).toBeDefined();
expect(result!.pinned).toBe(true);
expect(result!.archived).toBe(false);
expect(result!.sortOrder).toBe(5);
});
it('should enforce unique constraint on (noteId, userId)', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: false, archived: false, sortOrder: 0 })
.run();
expect(() =>
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 0 })
.run()
).toThrow();
});
it('should default to false/false/0 for new state', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2 })
.run();
const result = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
.get();
expect(result!.pinned).toBe(false);
expect(result!.archived).toBe(false);
expect(result!.sortOrder).toBe(0);
});
it('should allow independent state per user per note', () => {
seedNote(db, 'n1', 1);
db.insert(users)
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(noteUserState).values([
{ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 1 },
{ noteId: 'n1', userId: 3, pinned: false, archived: true, sortOrder: 2 }
]).run();
const user2State = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2))).get();
const user3State = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 3))).get();
expect(user2State!.pinned).toBe(true);
expect(user2State!.archived).toBe(false);
expect(user3State!.pinned).toBe(false);
expect(user3State!.archived).toBe(true);
});
});
+199 -6
View File
@@ -16,24 +16,36 @@ const sqlite = new Database(DATABASE_URL);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Auto-create tables on first run
// Disable foreign keys during schema setup to avoid constraint issues during migration
sqlite.pragma('foreign_keys = OFF');
// Auto-create tables on first run (new installs get full schema)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
expires_at INTEGER NOT NULL,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
@@ -50,9 +62,9 @@ sqlite.exec(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -61,28 +73,209 @@ sqlite.exec(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
thumbnail_path TEXT,
featured INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id),
user_id INTEGER NOT NULL DEFAULT 0,
note_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
added_by INTEGER NOT NULL REFERENCES users(id),
added_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS note_user_state (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
email TEXT NOT NULL,
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS shared_notes (
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER
);
CREATE TABLE IF NOT EXISTS note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
checklist_mode INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT 'default',
created_at INTEGER NOT NULL,
UNIQUE(note_id, version)
);
`);
// Migration: detect old schema (no email column on users) and add columns
const userColumns = sqlite
.prepare("PRAGMA table_info('users')")
.all() as { name: string }[];
const hasEmail = userColumns.some((col) => col.name === 'email');
if (!hasEmail) {
sqlite.exec(`
ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'password';
ALTER TABLE users ADD COLUMN provider_id TEXT;
`);
}
// Ensure user_id column exists on data tables (migration from old schema)
const notesColumns = sqlite
.prepare("PRAGMA table_info('notes')")
.all() as { name: string }[];
if (!notesColumns.some((col) => col.name === 'user_id')) {
sqlite.exec(`
ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE attachments ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
`);
}
// Backfill: promote existing user to admin and assign all data to them
if (!hasEmail) {
const existingUser = sqlite.prepare('SELECT id FROM users LIMIT 1').get() as
| { id: number }
| undefined;
if (existingUser) {
const uid = existingUser.id;
sqlite.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', uid);
for (const table of ['notes', 'tags', 'attachments', 'sync_log']) {
sqlite.prepare(`UPDATE ${table} SET user_id = ? WHERE user_id = 0`).run(uid);
}
}
}
// Migration: add session tracking columns
const sessionColumns = sqlite
.prepare("PRAGMA table_info('sessions')")
.all() as { name: string }[];
if (!sessionColumns.some((col) => col.name === 'created_at')) {
sqlite.exec(`
ALTER TABLE sessions ADD COLUMN created_at INTEGER;
ALTER TABLE sessions ADD COLUMN user_agent TEXT;
ALTER TABLE sessions ADD COLUMN ip TEXT;
ALTER TABLE sessions ADD COLUMN last_used_at INTEGER;
`);
}
// Create indexes AFTER migration ensures columns exist
sqlite.exec(`
CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id);
CREATE INDEX IF NOT EXISTS notes_trashed_archived_idx ON notes(trashed, archived);
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
CREATE INDEX IF NOT EXISTS note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key);
CREATE INDEX IF NOT EXISTS note_versions_note_id_idx ON note_versions(note_id);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
`);
// Replace old tags unique index with compound index including user_id
try {
sqlite.exec(`DROP INDEX IF EXISTS tags_name_unique`);
} catch {
// ignore
}
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS tags_name_user_unique ON tags(name, user_id)`
);
// Idempotent migrations: add columns that may be missing from older DBs
try {
sqlite.exec(`ALTER TABLE attachments ADD COLUMN thumbnail_path TEXT;`);
} catch {
// Column already exists
}
try {
sqlite.exec(`ALTER TABLE attachments ADD COLUMN featured INTEGER NOT NULL DEFAULT 0;`);
} catch {
// Column already exists
}
// Migration: make password_hash nullable (for OAuth-only users)
const pwCol = (
sqlite.prepare("PRAGMA table_info('users')").all() as { name: string; notnull: number }[]
).find((col) => col.name === 'password_hash');
if (pwCol && pwCol.notnull) {
sqlite.exec(`
CREATE TABLE __new_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
DROP TABLE users;
ALTER TABLE __new_users RENAME TO users;
`);
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export type Db = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<typeof schema>;
export { sqlite };
+20 -7
View File
@@ -9,7 +9,7 @@ import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => {
const testDb = createTestDb();
const testDb = createTestDb({ seedUser: true });
db = testDb.db;
});
@@ -20,6 +20,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Test Note',
content: 'Hello world',
createdAt: now,
@@ -45,6 +46,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Colored Note',
content: '',
color: 'coral',
@@ -62,16 +64,17 @@ describe('Notes CRUD', () => {
it('should read all active notes (not archived, not trashed)', async () => {
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
{ id: 'n1', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now },
{
id: 'n2',
userId: 1,
title: 'Archived',
content: '',
archived: true,
createdAt: now,
updatedAt: now
},
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
{ id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
]);
const active = await db
@@ -89,6 +92,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Original',
content: '',
createdAt: now,
@@ -113,6 +117,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Trash',
content: '',
createdAt: now,
@@ -132,6 +137,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Delete',
content: '',
createdAt: now,
@@ -149,6 +155,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Archive',
content: '',
createdAt: now,
@@ -170,6 +177,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Pin Me',
content: '',
createdAt: now,
@@ -193,6 +201,7 @@ describe('Notes with Tags', () => {
await db.insert(notes).values({
id: noteId,
userId: 1,
title: 'Tagged Note',
content: '',
createdAt: now,
@@ -200,8 +209,8 @@ describe('Notes with Tags', () => {
});
// Create tags
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning();
// Associate
await db.insert(noteTags).values([
@@ -226,16 +235,18 @@ describe('Notes with Tags', () => {
await db.insert(notes).values({
id: noteId,
userId: 1,
title: '',
content: '',
createdAt: now,
updatedAt: now
});
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
const [tag] = await db.insert(tags).values({ userId: 1, name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note
// Delete note_tags first (FK cascade is disabled in test DB), then note
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
await db.delete(notes).where(eq(notes.id, noteId));
// note_tags should be gone
@@ -257,6 +268,7 @@ describe('Note ordering', () => {
await db.insert(notes).values([
{
id: 'unpinned',
userId: 1,
title: 'Unpinned',
content: '',
pinned: false,
@@ -265,6 +277,7 @@ describe('Note ordering', () => {
},
{
id: 'pinned',
userId: 1,
title: 'Pinned',
content: '',
pinned: true,
+134 -3
View File
@@ -2,7 +2,12 @@ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqli
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
passwordHash: text('password_hash').notNull(),
email: text('email').notNull().default(''),
displayName: text('display_name').notNull().default(''),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
passwordHash: text('password_hash'),
authProvider: text('auth_provider').notNull().default('password'),
providerId: text('provider_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
@@ -11,13 +16,21 @@ export const sessions = sqliteTable('sessions', {
userId: integer('user_id')
.references(() => users.id)
.notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }),
userAgent: text('user_agent'),
ip: text('ip'),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
});
export const notes = sqliteTable(
'notes',
{
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
title: text('title').default('').notNull(),
content: text('content').default('').notNull(),
color: text('color').default('default').notNull(),
@@ -32,6 +45,7 @@ export const notes = sqliteTable(
version: integer('version').default(1).notNull()
},
(table) => [
index('notes_user_id_idx').on(table.userId),
index('notes_trashed_archived_idx').on(table.trashed, table.archived),
index('notes_updated_at_idx').on(table.updatedAt)
]
@@ -41,9 +55,13 @@ export const tags = sqliteTable(
'tags',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
(table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)]
);
export const noteTags = sqliteTable(
@@ -64,6 +82,10 @@ export const noteTags = sqliteTable(
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
@@ -71,13 +93,31 @@ export const attachments = sqliteTable('attachments', {
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
path: text('path').notNull(),
thumbnailPath: text('thumbnail_path'),
featured: integer('featured', { mode: 'boolean' }).default(false).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
export const apiKeys = sqliteTable('api_keys', {
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
name: text('name').notNull(),
keyHash: text('key_hash').notNull(),
keyPrefix: text('key_prefix').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
});
export const syncLog = sqliteTable(
'sync_log',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
noteId: text('note_id')
.references(() => notes.id)
.notNull(),
@@ -87,3 +127,94 @@ export const syncLog = sqliteTable(
},
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
);
export const noteCollaborators = sqliteTable(
'note_collaborators',
{
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
addedBy: integer('added_by')
.references(() => users.id)
.notNull(),
addedAt: integer('added_at', { mode: 'timestamp' }).notNull()
},
(table) => [
uniqueIndex('note_collaborators_unique').on(table.noteId, table.userId),
index('note_collaborators_user_id_idx').on(table.userId)
]
);
export const noteUserState = sqliteTable(
'note_user_state',
{
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull()
},
(table) => [uniqueIndex('note_user_state_unique').on(table.noteId, table.userId)]
);
export const loginAttempts = sqliteTable(
'login_attempts',
{
id: integer('id').primaryKey({ autoIncrement: true }),
ip: text('ip').notNull(),
email: text('email').notNull(),
success: integer('success', { mode: 'boolean' }).notNull(),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull()
},
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
);
export const userPreferences = sqliteTable(
'user_preferences',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
key: text('key').notNull(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
},
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
);
export const sharedNotes = sqliteTable('shared_notes', {
noteId: text('note_id')
.primaryKey()
.references(() => notes.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' })
});
export const noteVersions = sqliteTable(
'note_versions',
{
id: text('id').primaryKey(),
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
version: integer('version').notNull(),
title: text('title').notNull().default(''),
content: text('content').notNull().default(''),
checklistMode: integer('checklist_mode', { mode: 'boolean' }).notNull().default(false),
color: text('color').notNull().default('default'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
},
(table) => [
index('note_versions_note_id_idx').on(table.noteId),
uniqueIndex('note_versions_note_id_version_unique').on(table.noteId, table.version)
]
);
+6 -6
View File
@@ -8,18 +8,18 @@ import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(async () => {
const testDb = createTestDb();
const testDb = createTestDb({ seedUser: true });
db = testDb.db;
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
{ id: 'n1', userId: 1, title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
{ id: 'n2', userId: 1, title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
{ id: 'n3', userId: 1, title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
{ id: 'n4', userId: 1, title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
]);
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
});
+104 -6
View File
@@ -6,27 +6,37 @@ import * as schema from './schema.js';
* Create an in-memory SQLite database for testing.
* Each test gets a fresh database.
*/
export function createTestDb() {
export function createTestDb(options?: { seedUser?: boolean }) {
const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('foreign_keys = OFF');
// Create tables
// Create tables matching the multi-user schema
sqlite.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
expires_at INTEGER NOT NULL,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
@@ -40,37 +50,125 @@ export function createTestDb() {
updated_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX notes_user_id_idx ON notes(user_id);
CREATE INDEX notes_trashed_archived_idx ON notes(trashed, archived);
CREATE INDEX notes_updated_at_idx ON notes(updated_at);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
name TEXT NOT NULL
);
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
CREATE UNIQUE INDEX tags_name_user_unique ON tags(name, user_id);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
added_by INTEGER NOT NULL REFERENCES users(id),
added_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX note_collaborators_unique ON note_collaborators(note_id, user_id);
CREATE INDEX note_collaborators_user_id_idx ON note_collaborators(user_id);
CREATE TABLE note_user_state (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX note_user_state_unique ON note_user_state(note_id, user_id);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
thumbnail_path TEXT,
featured INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
CREATE INDEX sync_log_timestamp_idx ON sync_log(timestamp);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
email TEXT NOT NULL,
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
CREATE TABLE shared_notes (
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER
);
CREATE TABLE note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
checklist_mode INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT 'default',
created_at INTEGER NOT NULL,
UNIQUE(note_id, version)
);
CREATE INDEX note_versions_note_id_idx ON note_versions(note_id);
`);
const db = drizzle(sqlite, { schema });
if (options?.seedUser) {
db.insert(schema.users).values({
email: 'test@test.com',
displayName: 'Test User',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
}).run();
}
return { db, sqlite };
}
+353
View File
@@ -0,0 +1,353 @@
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
let transporter: Transporter | null = null;
/**
* Check if SMTP email is configured via environment variables.
*/
export function isEmailConfigured(): boolean {
return !!process.env.SMTP_HOST;
}
/**
* Get or create the nodemailer transport (lazy singleton).
*/
function getTransporter(): Transporter {
if (!transporter) {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_PORT === '465',
auth:
process.env.SMTP_USER
? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS || ''
}
: undefined
});
}
return transporter;
}
/**
* Send an email. Best-effort: logs errors but does not throw.
*/
async function sendEmail(to: string, subject: string, html: string): Promise<void> {
try {
const transport = getTransporter();
await transport.sendMail({
from: process.env.SMTP_FROM || 'Crumbs <noreply@localhost>',
to,
subject,
html
});
} catch (err) {
console.error('[email] Failed to send:', err);
}
}
/**
* Send a share notification email to a collaborator.
*/
export async function sendShareNotification(
toEmail: string,
toName: string,
fromName: string,
noteTitle: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = `${fromName} shared a note with you`;
const html = buildShareEmailHtml(toName, fromName, noteTitle, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a welcome email to a newly created user.
*/
export async function sendWelcomeEmail(
toEmail: string,
toName: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = 'Welcome to Crumbs';
const html = buildWelcomeEmailHtml(toName, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a password reset notification to a user (triggered by admin reset).
*/
export async function sendPasswordResetEmail(
toEmail: string,
toName: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = 'Your Crumbs password has been reset';
const html = buildPasswordResetEmailHtml(toName, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send an account deletion confirmation email.
*/
export async function sendAccountDeletedEmail(toEmail: string, toName: string): Promise<void> {
if (!toEmail) return;
const subject = 'Your Crumbs account has been deleted';
const html = buildAccountDeletedEmailHtml(toName);
await sendEmail(toEmail, subject, html);
}
/**
* Send a notification to the old email when the account email address changes.
*/
export async function sendEmailChangedEmail(
oldEmail: string,
toName: string,
newEmail: string
): Promise<void> {
if (!oldEmail) return;
const subject = 'Your Crumbs email address has been changed';
const html = buildEmailChangedEmailHtml(toName, newEmail);
await sendEmail(oldEmail, subject, html);
}
/**
* Send a role change notification to a user.
*/
export async function sendRoleChangedEmail(
toEmail: string,
toName: string,
newRole: 'admin' | 'user',
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = 'Your Crumbs account role has been updated';
const html = buildRoleChangedEmailHtml(toName, newRole, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a notification to a collaborator that they have been removed from a note.
*/
export async function sendCollaboratorRemovedEmail(
toEmail: string,
toName: string,
ownerName: string,
noteTitle: string,
appUrl: string
): Promise<void> {
if (!toEmail) return;
const subject = `You've been removed from a shared note`;
const html = buildCollaboratorRemovedEmailHtml(toName, ownerName, noteTitle, appUrl);
await sendEmail(toEmail, subject, html);
}
/**
* Send a notification to collaborators that a shared note has been permanently deleted.
*/
export async function sendNotePermanentlyDeletedEmail(
toEmail: string,
toName: string,
noteTitle: string
): Promise<void> {
if (!toEmail) return;
const subject = `A shared note has been deleted`;
const html = buildNotePermanentlyDeletedEmailHtml(toName, noteTitle);
await sendEmail(toEmail, subject, html);
}
/**
* Send a security alert to a user when their account is locked due to too many failed logins.
*/
export async function sendAccountLockedEmail(
toEmail: string,
toName: string,
retryAfterMinutes: number
): Promise<void> {
if (!toEmail) return;
const subject = 'Crumbs account temporarily locked';
const html = buildAccountLockedEmailHtml(toName, retryAfterMinutes);
await sendEmail(toEmail, subject, html);
}
function buildShareEmailHtml(
toName: string,
fromName: string,
noteTitle: string,
appUrl: string
): string {
const displayName = toName || 'there';
const escapedTitle = escapeHtml(noteTitle);
const escapedFrom = escapeHtml(fromName || 'Someone');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${escapeHtml(displayName)},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
<strong>${escapedFrom}</strong> shared a note with you: <strong>&ldquo;${escapedTitle}&rdquo;</strong>
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because someone shared a note with you on Crumbs.');
}
function buildEmailLayout(content: string, footer: string): string {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background-color:#f0e6d3;font-family:'JetBrains Mono',monospace;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0e6d3;padding:40px 20px;">
<tr><td align="center">
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
<tr><td style="padding:32px;">
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
${content}
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">${footer}</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
function buildOpenButton(appUrl: string, label = 'Open Crumbs'): string {
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">${escapeHtml(label)}</a>
</td></tr>
</table>`;
}
function buildWelcomeEmailHtml(toName: string, appUrl: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Welcome to Crumbs</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
An admin has created an account for you on Crumbs. You can log in and start taking notes right away.
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because an admin created an account for you on Crumbs.');
}
function buildPasswordResetEmailHtml(toName: string, appUrl: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Password reset</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
An admin has reset your Crumbs password. Please log in and change it to something only you know.
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because an admin reset your password on Crumbs. If you didn&rsquo;t expect this, please contact your admin.');
}
function buildAccountDeletedEmailHtml(toName: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account deleted</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Your Crumbs account and all associated data have been permanently deleted.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this as confirmation that your Crumbs account was deleted.');
}
function buildEmailChangedEmailHtml(toName: string, newEmail: string): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Email address changed</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Your Crumbs account email address has been changed to <strong>${escapeHtml(newEmail)}</strong>. If you did not make this change, please contact your admin immediately.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this security notice because your Crumbs account email was updated.');
}
function buildRoleChangedEmailHtml(toName: string, newRole: 'admin' | 'user', appUrl: string): string {
const displayName = escapeHtml(toName || 'there');
const roleLabel = newRole === 'admin' ? 'Admin' : 'User';
const detail = newRole === 'admin'
? 'You now have admin privileges, including the ability to manage users and settings.'
: 'Your account no longer has admin privileges.';
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Role updated: ${roleLabel}</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Your Crumbs account role has been updated to <strong>${escapeHtml(roleLabel)}</strong>. ${escapeHtml(detail)}
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because your account role was changed on Crumbs.');
}
function buildCollaboratorRemovedEmailHtml(
toName: string,
ownerName: string,
noteTitle: string,
appUrl: string
): string {
const displayName = escapeHtml(toName || 'there');
const escapedOwner = escapeHtml(ownerName || 'Someone');
const escapedTitle = escapeHtml(noteTitle);
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Removed from shared note</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
<strong>${escapedOwner}</strong> has removed you from the shared note <strong>&ldquo;${escapedTitle}&rdquo;</strong>. You no longer have access to this note.
</p>
${buildOpenButton(appUrl)}`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because you were removed from a shared note on Crumbs.');
}
function buildNotePermanentlyDeletedEmailHtml(toName: string, noteTitle: string): string {
const displayName = escapeHtml(toName || 'there');
const escapedTitle = escapeHtml(noteTitle);
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Shared note deleted</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
The shared note <strong>&ldquo;${escapedTitle}&rdquo;</strong> has been permanently deleted by its owner. You no longer have access to it.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this because a shared note you had access to was deleted on Crumbs.');
}
function buildAccountLockedEmailHtml(toName: string, retryAfterMinutes: number): string {
const displayName = escapeHtml(toName || 'there');
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account temporarily locked</h1>
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Hey ${displayName},
</p>
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
Too many failed login attempts were made on your Crumbs account. For your security, login has been temporarily blocked for <strong>${retryAfterMinutes} minute${retryAfterMinutes === 1 ? '' : 's'}</strong>. If this wasn&rsquo;t you, please change your password once you regain access.
</p>`;
return buildEmailLayout(content, 'You&rsquo;re receiving this security alert because of repeated failed login attempts on your Crumbs account.');
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// We need to re-import for each test to get a fresh module state
let checkIpRateLimit: (ip: string) => boolean;
beforeEach(async () => {
vi.resetModules();
const mod = await import('./ip-rate-limit.js');
checkIpRateLimit = mod.checkIpRateLimit;
});
describe('checkIpRateLimit', () => {
it('allows requests under the limit', () => {
for (let i = 0; i < 60; i++) {
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
}
});
it('blocks requests over the limit', () => {
for (let i = 0; i < 60; i++) {
checkIpRateLimit('1.2.3.4');
}
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
});
it('tracks different IPs independently', () => {
for (let i = 0; i < 60; i++) {
checkIpRateLimit('1.1.1.1');
}
expect(checkIpRateLimit('1.1.1.1')).toBe(false);
expect(checkIpRateLimit('2.2.2.2')).toBe(true);
});
it('resets after the window expires', () => {
vi.useFakeTimers();
for (let i = 0; i < 60; i++) {
checkIpRateLimit('1.2.3.4');
}
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
// Advance past the 1-minute window
vi.advanceTimersByTime(61_000);
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
vi.useRealTimers();
});
});
+30
View File
@@ -0,0 +1,30 @@
const store = new Map<string, { count: number; windowStart: number }>();
const WINDOW_MS = 60_000; // 1 minute
const MAX_REQUESTS = 60;
const MAX_ENTRIES = 10_000;
let cleanupCounter = 0;
export function checkIpRateLimit(ip: string): boolean {
const now = Date.now();
const entry = store.get(ip);
if (!entry || now - entry.windowStart > WINDOW_MS) {
store.set(ip, { count: 1, windowStart: now });
pruneIfNeeded(now);
return true;
}
entry.count++;
return entry.count <= MAX_REQUESTS;
}
function pruneIfNeeded(now: number) {
cleanupCounter++;
if (cleanupCounter < 100 || store.size < MAX_ENTRIES) {
return;
}
cleanupCounter = 0;
for (const [key, entry] of store) {
if (now - entry.windowStart > WINDOW_MS) {
store.delete(key);
}
}
}
+344
View File
@@ -0,0 +1,344 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { NoteFilter } from '$lib/types/index.js';
import {
listNotes,
getNote,
createNote,
updateNote,
deleteNote,
searchNotes,
listAllTags,
reorderNotes
} from '../notes-service.js';
import { saveAttachment } from '../attachments.js';
import { db } from '../db/index.js';
const NOTE_COLORS = [
'default',
'coral',
'peach',
'sand',
'mint',
'sage',
'fog',
'storm',
'dusk',
'blossom',
'clay',
'chalk'
] as const;
export function createMcpServer(userId: number): McpServer {
const server = new McpServer({
name: 'crumbs',
version: '0.7.2'
});
server.tool(
'list_notes',
'List notes with optional filter (all, archived, trashed) and tag filter',
{
filter: z
.enum(['all', 'archived', 'trashed'])
.optional()
.describe('Filter notes by status'),
tag: z.string().optional().describe('Filter by tag name')
},
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
let result = listNotes(db, userId, filter as NoteFilter);
if (tag) {
result = result.filter((n) =>
(n.tags ?? []).some((t: string) => t.toLowerCase() === tag.toLowerCase())
);
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
};
}
);
server.tool(
'get_note',
'Get a single note by ID with its tags and attachments',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const note = getNote(db, userId, id);
if (!note) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
};
}
);
server.tool(
'create_note',
'Create a new note',
{
title: z.string().optional().describe('Note title'),
content: z.string().optional().describe('Note content (supports markdown)'),
color: z.enum(NOTE_COLORS).optional().describe('Note color'),
checklistMode: z.boolean().optional().describe('Enable checklist mode')
},
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
const note = createNote(db, userId, { title, content, color, checklistMode });
return {
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
};
}
);
server.tool(
'update_note',
'Update an existing note',
{
id: z.string().describe('Note ID'),
title: z.string().optional().describe('New title'),
content: z.string().optional().describe('New content'),
color: z.enum(NOTE_COLORS).optional().describe('New color'),
pinned: z.boolean().optional().describe('Pin/unpin'),
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
},
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
const result = updateNote(db, userId, id, updates);
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
};
}
);
server.tool(
'trash_note',
'Move a note to trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(db, userId, id, { trashed: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" trashed` }]
};
}
);
server.tool(
'restore_note',
'Restore a note from trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(db, userId, id, { trashed: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" restored` }]
};
}
);
server.tool(
'archive_note',
'Archive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(db, userId, id, { archived: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" archived` }]
};
}
);
server.tool(
'unarchive_note',
'Unarchive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(db, userId, id, { archived: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [{ type: 'text' as const, text: `Note "${result.title}" unarchived` }]
};
}
);
server.tool(
'delete_note',
'Permanently delete a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const existing = getNote(db, userId, id);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
deleteNote(db, userId, id);
return {
content: [
{
type: 'text' as const,
text: `Note "${existing.title}" permanently deleted`
}
]
};
}
);
server.tool(
'search_notes',
'Search notes by title, content, or tag name',
{ query: z.string().describe('Search query') },
async ({ query }: { query: string }) => {
const results = searchNotes(db, userId, query);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
};
}
);
server.tool('list_tags', 'List all tags', {}, async () => {
const allTags = listAllTags(db, userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
};
});
server.tool(
'pin_note',
'Pin or unpin a note',
{
id: z.string().describe('Note ID'),
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
},
async ({ id, pinned }: { id: string; pinned: boolean }) => {
const result = updateNote(db, userId, id, { pinned });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
return {
content: [
{
type: 'text' as const,
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
}
]
};
}
);
server.tool(
'reorder_notes',
'Reorder notes by setting sort orders',
{
orders: z
.array(
z.object({
id: z.string().describe('Note ID'),
sortOrder: z.number().describe('New sort order')
})
)
.describe('Array of note IDs with their new sort orders')
},
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
reorderNotes(db, userId, orders);
return {
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
};
}
);
server.tool(
'upload_image',
'Attach an image to a note by fetching it from a URL',
{
noteId: z.string().describe('Note ID to attach the image to'),
imageUrl: z.string().url().describe('URL of the image to fetch')
},
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
const existing = getNote(db, userId, noteId);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
isError: true
};
}
try {
const response = await fetch(imageUrl);
if (!response.ok) {
return {
content: [
{
type: 'text' as const,
text: `Failed to fetch image: ${response.status} ${response.statusText}`
}
],
isError: true
};
}
const contentType = response.headers.get('content-type') || 'image/png';
const buffer = Buffer.from(await response.arrayBuffer());
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
const filename = `upload_${Date.now()}.${ext}`;
const file = new File([buffer], filename, { type: contentType });
const attachment = await saveAttachment(db, noteId, file, userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Failed to upload image: ${err instanceof Error ? err.message : String(err)}`
}
],
isError: true
};
}
}
);
return server;
}
+336
View File
@@ -0,0 +1,336 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, noteVersions, users } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Db } from './db/index.js';
import {
listNotes,
getNote,
createNote,
updateNote,
deleteNote,
searchNotes,
reorderNotes
} from './notes-service.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users)
.values([
{
email: 'owner@test.com',
displayName: 'Owner',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
},
{
email: 'collab@test.com',
displayName: 'Collab',
role: 'user',
authProvider: 'password',
createdAt: new Date()
}
])
.run();
}
function seedNote(
id: string,
overrides: Partial<typeof notes.$inferInsert> = {}
) {
const now = new Date();
db.insert(notes)
.values({
id,
userId: OWNER_ID,
title: '',
content: '',
createdAt: now,
updatedAt: now,
...overrides
})
.run();
return id;
}
function shareNote(noteId: string, userId = COLLAB_ID) {
db.insert(noteCollaborators)
.values({
noteId,
userId,
addedBy: OWNER_ID,
addedAt: new Date()
})
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('listNotes', () => {
it('should return only active owned notes (excludes archived/trashed)', () => {
seedNote('active', { title: 'Active' });
seedNote('archived', { title: 'Archived', archived: true });
seedNote('trashed', { title: 'Trashed', trashed: true });
const result = listNotes(db, OWNER_ID);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Active');
});
it('should include shared notes with per-user state overlay', () => {
seedNote('shared', { title: 'Shared Note' });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 })
.run();
const result = listNotes(db, COLLAB_ID);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Shared Note');
expect(result[0].pinned).toBeTruthy();
expect(result[0].sortOrder).toBe(5);
});
it('should sort pinned first, then by updatedAt desc', () => {
const old = new Date('2024-01-01');
const recent = new Date('2024-01-02');
seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old });
seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent });
seedNote('old', { title: 'Old', updatedAt: old, createdAt: old });
const result = listNotes(db, OWNER_ID);
expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']);
});
it('should return only archived notes with archived filter', () => {
seedNote('active', { title: 'Active' });
seedNote('archived', { title: 'Archived', archived: true });
seedNote('shared', { title: 'Shared Archived' });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 })
.run();
// Owner sees their archived note
const ownerResult = listNotes(db, OWNER_ID, 'archived');
expect(ownerResult).toHaveLength(1);
expect(ownerResult[0].title).toBe('Archived');
// Collaborator sees shared note they archived
const collabResult = listNotes(db, COLLAB_ID, 'archived');
expect(collabResult).toHaveLength(1);
expect(collabResult[0].title).toBe('Shared Archived');
});
it('should exclude shared notes from trashed filter', () => {
seedNote('trashed', { title: 'Trashed', trashed: true });
seedNote('shared', { title: 'Shared' });
shareNote('shared');
const result = listNotes(db, COLLAB_ID, 'trashed');
expect(result).toHaveLength(0);
});
});
describe('getNote', () => {
it('should return null for non-existent note', () => {
expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull();
});
it('should return null if user has no access', () => {
seedNote('private', { title: 'Private' });
expect(getNote(db, COLLAB_ID, 'private')).toBeNull();
});
it('should overlay collaborator per-user state', () => {
seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 })
.run();
const note = getNote(db, COLLAB_ID, 'shared');
expect(note).not.toBeNull();
expect(note!.pinned).toBeFalsy();
expect(note!.archived).toBeTruthy();
expect(note!.sortOrder).toBe(5);
});
});
describe('createNote', () => {
it('should generate UUID and set version=1', () => {
const note = createNote(db, OWNER_ID, { title: 'Test' });
expect(note.id).toBeDefined();
expect(note.id.length).toBe(36); // UUID format
expect(note.version).toBe(1);
});
it('should use provided ID when given', () => {
const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' });
expect(note.id).toBe('custom-id');
});
it('should extract tags from content', () => {
const note = createNote(db, OWNER_ID, {
title: 'My #todo note',
content: 'Some #important content'
});
expect(note.tags).toContain('todo');
expect(note.tags).toContain('important');
});
});
describe('updateNote', () => {
it('should allow owner to update shared fields directly', () => {
seedNote('n1', { title: 'Original' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' });
expect(updated).not.toBeNull();
expect(updated!.title).toBe('Updated');
});
it('should allow collaborator to edit content', () => {
seedNote('n1', { title: 'Original', content: 'Old' });
shareNote('n1');
const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' });
expect(updated).not.toBeNull();
expect(updated!.content).toBe('New content');
});
it('should route collaborator per-user fields to noteUserState', () => {
seedNote('n1', { title: 'Note', pinned: false });
shareNote('n1');
updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 });
// Owner's note pinned should be unchanged
const ownerView = getNote(db, OWNER_ID, 'n1');
expect(ownerView!.pinned).toBeFalsy();
// Collaborator sees their per-user state
const collabView = getNote(db, COLLAB_ID, 'n1');
expect(collabView!.pinned).toBeTruthy();
expect(collabView!.sortOrder).toBe(7);
});
it('should return null when collaborator tries to trash', () => {
seedNote('n1', { title: 'Note' });
shareNote('n1');
const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true });
expect(result).toBeNull();
});
it('should increment version on update', () => {
seedNote('n1', { title: 'V1' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' });
expect(updated!.version).toBe(2);
});
it('should create a snapshot when content changes', () => {
seedNote('n1', { title: 'Original', content: 'Hello', color: 'default', checklistMode: false });
updateNote(db, OWNER_ID, 'n1', { title: 'Changed' });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(1);
});
it('should not create a snapshot when no content field actually changed', () => {
seedNote('n1', { title: 'Same', content: 'Same', color: 'default', checklistMode: false });
// Send the same values — nothing actually changes
updateNote(db, OWNER_ID, 'n1', { title: 'Same', content: 'Same' });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(0);
});
it('should not create a snapshot when only non-content fields change (pinned, archived)', () => {
seedNote('n1', { title: 'Note', pinned: false });
updateNote(db, OWNER_ID, 'n1', { pinned: true });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(0);
});
it('should extract and sync tags on title/content change', () => {
seedNote('n1', { title: 'Original' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
expect(updated!.tags).toContain('newtag');
});
});
describe('deleteNote', () => {
it('should allow owner to permanently delete', () => {
seedNote('n1', { title: 'Delete me' });
const result = deleteNote(db, OWNER_ID, 'n1');
expect(result).toBe(true);
expect(getNote(db, OWNER_ID, 'n1')).toBeNull();
});
it('should return false for non-existent or unauthorized', () => {
seedNote('n1', { title: 'Private' });
expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false);
expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false);
});
});
describe('searchNotes', () => {
it('should match title and content', () => {
seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' });
seedNote('n2', { title: 'Shopping list', content: 'Buy milk' });
const results = searchNotes(db, OWNER_ID, 'budget');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n1');
});
it('should include shared notes in results', () => {
seedNote('shared', { title: 'Shared doc', content: 'Collaboration' });
shareNote('shared');
const results = searchNotes(db, COLLAB_ID, 'Collaboration');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('shared');
});
it('should return empty array for empty query', () => {
seedNote('n1', { title: 'Test' });
expect(searchNotes(db, OWNER_ID, '')).toEqual([]);
});
});
describe('reorderNotes', () => {
it('should update owner sortOrder in notes table', () => {
seedNote('n1', { title: 'A' });
seedNote('n2', { title: 'B' });
reorderNotes(db, OWNER_ID, [
{ id: 'n1', sortOrder: 2 },
{ id: 'n2', sortOrder: 1 }
]);
const n1 = getNote(db, OWNER_ID, 'n1');
const n2 = getNote(db, OWNER_ID, 'n2');
expect(n1!.sortOrder).toBe(2);
expect(n2!.sortOrder).toBe(1);
});
it('should update collaborator sortOrder in noteUserState', () => {
seedNote('n1', { title: 'Note', sortOrder: 0 });
shareNote('n1');
reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]);
// Owner's sortOrder unchanged
const ownerView = getNote(db, OWNER_ID, 'n1');
expect(ownerView!.sortOrder).toBe(0);
// Collaborator sees their sortOrder
const collabView = getNote(db, COLLAB_ID, 'n1');
expect(collabView!.sortOrder).toBe(5);
});
});
+472
View File
@@ -0,0 +1,472 @@
import type { Db } from './db/index.js';
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { fetchSharesForNotes } from './shares-service.js';
import { canAccessNote } from './api-utils.js';
import { createSnapshot } from './versions-service.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import type { NoteFilter } from '$lib/types/index.js';
interface NoteRow {
id: string;
userId: number;
title: string;
content: string;
color: string;
pinned: boolean;
archived: boolean;
trashed: boolean;
trashedAt: Date | null;
checklistMode: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
version: number;
}
function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
const noteIds = noteRows.map((n) => n.id);
const tagMap = fetchTagsForNotes(db, noteIds);
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
const shareMap = fetchSharesForNotes(db, noteIds);
return noteRows.map((note) => {
const collaborators = collabMap.get(note.id) ?? [];
const isOwner = note.userId === userId;
const isShared = collaborators.length > 0;
return {
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? [],
collaborators,
isOwner,
isShared,
shareToken: shareMap.get(note.id) ?? undefined
};
});
}
/**
* Get shared notes for a user with per-user state overlay.
*/
function getSharedNotes(db: Db, userId: number, filter: 'all' | 'archived'): NoteRow[] {
const rows = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
filter === 'archived'
? sql`COALESCE(${noteUserState.archived}, 0) = 1`
: sql`COALESCE(${noteUserState.archived}, 0) = 0`
)
)
.all();
return rows as NoteRow[];
}
export function listNotes(db: Db, userId: number, filter: NoteFilter = 'all') {
// Owned notes
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
// Collaborators never see trashed notes — only owner's trashed notes
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const ownedNotes = db
.select()
.from(notes)
.where(conditions)
.all() as NoteRow[];
// Shared notes (not for trashed filter — collaborators don't see trash)
let sharedNotes: NoteRow[] = [];
if (filter !== 'trashed') {
sharedNotes = getSharedNotes(db, userId, filter === 'archived' ? 'archived' : 'all');
}
const combined = [...ownedNotes, ...sharedNotes];
combined.sort((a, b) => {
// Pinned first, then by updatedAt descending
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
return hydrateNotes(db, combined, userId);
}
export function getNote(db: Db, userId: number, id: string) {
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) return null;
const note = db.select().from(notes).where(eq(notes.id, id)).get();
if (!note) return null;
let effectiveNote = note as NoteRow;
// For collaborators, overlay per-user state
if (!isOwner) {
const userState = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
effectiveNote = {
...note,
pinned: userState?.pinned ?? false,
archived: userState?.archived ?? false,
sortOrder: userState?.sortOrder ?? 0
};
}
const result = hydrateNotes(db, [effectiveNote], userId);
return result[0] ?? null;
}
export interface CreateNoteInput {
id?: string;
title?: string;
content?: string;
color?: string;
pinned?: boolean;
checklistMode?: boolean;
sortOrder?: number;
}
export function createNote(db: Db, userId: number, input: CreateNoteInput) {
const now = new Date();
const id = input.id || uuidv4();
const newNote = {
id,
userId,
title: input.title || '',
content: input.content || '',
color: input.color || 'default',
pinned: input.pinned || false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: input.checklistMode || false,
sortOrder: input.sortOrder || 0,
createdAt: now,
updatedAt: now,
version: 1
};
const content = `${newNote.title} ${newNote.content}`;
const extractedTags = extractTags(content);
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(db, id, extractedTags, userId);
return { ...newNote, tags: extractedTags };
}
export interface UpdateNoteInput {
title?: string;
content?: string;
color?: string;
pinned?: boolean;
archived?: boolean;
trashed?: boolean;
checklistMode?: boolean;
sortOrder?: number;
baseVersion?: number;
}
/** Per-user fields that go to noteUserState for collaborators */
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
export function updateNote(db: Db, userId: number, id: string, input: UpdateNoteInput) {
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) return null;
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
if (!existing) return null;
// Collaborators cannot trash notes
if (!isOwner && input.trashed !== undefined) {
return null;
}
const now = new Date();
// Shared fields: update the notes table (all participants can edit content)
const sharedUpdates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
let hasSharedUpdates = false;
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
if (input.content !== undefined) {
// 3-way merge to preserve concurrent edits from other users.
// Only attempt merge when there's a concurrent edit: the client's
// baseVersion is behind the current version, meaning someone else
// saved between the client's load and this save.
const hasConcurrentEdit = input.baseVersion !== undefined
&& input.baseVersion < existing.version;
if (hasConcurrentEdit && input.content !== existing.content) {
const baseSnapshot = db.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion!)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (baseSnapshot && baseSnapshot.content !== existing.content) {
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
} else {
sharedUpdates.content = input.content;
}
} else {
sharedUpdates.content = input.content;
}
hasSharedUpdates = true;
}
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
if (isOwner) {
// Owner: per-user fields go to notes table directly
if (input.pinned !== undefined) { sharedUpdates.pinned = input.pinned; hasSharedUpdates = true; }
if (input.archived !== undefined) { sharedUpdates.archived = input.archived; hasSharedUpdates = true; }
if (input.trashed !== undefined) {
sharedUpdates.trashed = input.trashed;
sharedUpdates.trashedAt = input.trashed ? now : null;
hasSharedUpdates = true;
}
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
}
if (hasSharedUpdates || isOwner) {
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
}
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUserFields = PER_USER_FIELDS.some((f) => input[f] !== undefined);
if (hasPerUserFields) {
const userState = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
if (userState) {
const stateUpdates: Record<string, unknown> = {};
if (input.pinned !== undefined) stateUpdates.pinned = input.pinned;
if (input.archived !== undefined) stateUpdates.archived = input.archived;
if (input.sortOrder !== undefined) stateUpdates.sortOrder = input.sortOrder;
db.update(noteUserState)
.set(stateUpdates)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.run();
} else {
db.insert(noteUserState)
.values({
noteId: id,
userId,
pinned: input.pinned ?? false,
archived: input.archived ?? false,
sortOrder: input.sortOrder ?? 0
})
.run();
}
}
}
if (input.title !== undefined || input.content !== undefined) {
const updated = db.select().from(notes).where(eq(notes.id, id)).get();
if (updated) {
const content = `${updated.title} ${updated.content}`;
const extractedTags = extractTags(content);
syncNoteTags(db, id, extractedTags, existing.userId);
}
}
// Create a version snapshot only when at least one content field actually changed
const hasActualContentChange =
(input.title !== undefined && input.title !== existing.title) ||
(input.content !== undefined && input.content !== existing.content) ||
(input.color !== undefined && input.color !== existing.color) ||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.color
});
}
return getNote(db, userId, id);
}
export function deleteNote(db: Db, userId: number, id: string): boolean {
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
if (!existing) return false;
db.delete(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
return true;
}
export function searchNotes(db: Db, userId: number, query: string) {
if (!query) return [];
const pattern = `%${query}%`;
// Owned notes matching query
const ownedResults = db
.select()
.from(notes)
.where(
and(
eq(notes.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
)
.all();
// Shared notes matching query
const sharedResults = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
)
.all();
// Tag search for owned notes
const tagResults = db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set([...ownedResults.map((n) => n.id), ...sharedResults.map((n) => n.id)]);
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
(id) => !resultIds.has(id)
);
let extraNotes: typeof ownedResults = [];
if (extraNoteIds.length > 0) {
extraNotes = db
.select()
.from(notes)
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.all();
}
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
return hydrateNotes(db, combined, userId);
}
export function listAllTags(db: Db, userId: number) {
return db.select().from(tags).where(eq(tags.userId, userId)).all();
}
export function reorderNotes(db: Db, userId: number, orders: { id: string; sortOrder: number }[]) {
const now = new Date();
for (const { id, sortOrder } of orders) {
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) continue;
if (isOwner) {
db.update(notes)
.set({ sortOrder, updatedAt: now })
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
} else {
// Collaborator: upsert into noteUserState
const existing = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
if (existing) {
db.update(noteUserState)
.set({ sortOrder })
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.run();
} else {
db.insert(noteUserState)
.values({ noteId: id, userId, pinned: false, archived: false, sortOrder })
.run();
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import * as arctic from 'arctic';
import { getGithubClient } from './providers.js';
export function createGithubAuthUrl(): { url: URL; state: string } | null {
const github = getGithubClient();
if (!github) return null;
const state = arctic.generateState();
const scopes = ['user:email'];
const url = github.createAuthorizationURL(state, scopes);
return { url, state };
}
export async function validateGithubCallback(
code: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const github = getGithubClient();
if (!github) return null;
const tokens = await github.validateAuthorizationCode(code);
const accessToken = tokens.accessToken();
// Fetch user profile
const userRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
});
if (!userRes.ok) return null;
const user = (await userRes.json()) as { id: number; login: string; name?: string; email?: string };
// GitHub may not return email in profile — fetch from emails endpoint
let email = user.email;
if (!email) {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
});
if (emailsRes.ok) {
const emails = (await emailsRes.json()) as { email: string; primary: boolean; verified: boolean }[];
const primary = emails.find((e) => e.primary && e.verified);
email = primary?.email || emails.find((e) => e.verified)?.email || undefined;
}
}
if (!email) return null;
return {
email,
name: user.name || user.login,
providerId: String(user.id)
};
}
+38
View File
@@ -0,0 +1,38 @@
import * as arctic from 'arctic';
import { getGoogleClient } from './providers.js';
export function createGoogleAuthUrl(): { url: URL; state: string; codeVerifier: string } | null {
const google = getGoogleClient();
if (!google) return null;
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
const scopes = ['openid', 'profile', 'email'];
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
return { url, state, codeVerifier };
}
export async function validateGoogleCallback(
code: string,
codeVerifier: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const google = getGoogleClient();
if (!google) return null;
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
const idToken = tokens.idToken();
const claims = arctic.decodeIdToken(idToken) as {
sub: string;
email?: string;
name?: string;
};
if (!claims.email) return null;
return {
email: claims.email,
name: claims.name || claims.email.split('@')[0],
providerId: claims.sub
};
}
+119
View File
@@ -0,0 +1,119 @@
import * as arctic from 'arctic';
let oidcClient: arctic.OAuth2Client | null = null;
let oidcConfig: {
authorizationEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
} | null = null;
function getOidcEnv() {
return {
issuer: process.env.AUTH_OIDC_ISSUER || '',
clientId: process.env.AUTH_OIDC_CLIENT_ID || '',
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET || ''
};
}
function getBaseUrl(): string {
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
}
async function ensureOidcClient(): Promise<arctic.OAuth2Client | null> {
const env = getOidcEnv();
if (!env.issuer || !env.clientId || !env.clientSecret) return null;
if (oidcClient && oidcConfig) return oidcClient;
// Discover OIDC endpoints
const discoveryUrl = env.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
const res = await fetch(discoveryUrl);
if (!res.ok) return null;
const config = (await res.json()) as {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
};
oidcConfig = {
authorizationEndpoint: config.authorization_endpoint,
tokenEndpoint: config.token_endpoint,
userinfoEndpoint: config.userinfo_endpoint
};
const redirectURI = `${getBaseUrl()}/api/auth/oauth/oidc/callback`;
oidcClient = new arctic.OAuth2Client(env.clientId, env.clientSecret, redirectURI);
return oidcClient;
}
export async function createOidcAuthUrl(): Promise<{ url: URL; state: string; codeVerifier: string } | null> {
const client = await ensureOidcClient();
if (!client || !oidcConfig) return null;
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
const scopes = ['openid', 'profile', 'email'];
const url = client.createAuthorizationURLWithPKCE(
oidcConfig.authorizationEndpoint,
state,
arctic.CodeChallengeMethod.S256,
codeVerifier,
scopes
);
return { url, state, codeVerifier };
}
export async function validateOidcCallback(
code: string,
codeVerifier: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const client = await ensureOidcClient();
if (!client || !oidcConfig) return null;
const tokens = await client.validateAuthorizationCode(
oidcConfig.tokenEndpoint,
code,
codeVerifier
);
// Try to decode ID token first
const idToken = tokens.idToken();
if (idToken) {
const claims = arctic.decodeIdToken(idToken) as {
sub: string;
email?: string;
name?: string;
};
if (claims.email) {
return {
email: claims.email,
name: claims.name || claims.email.split('@')[0],
providerId: claims.sub
};
}
}
// Fallback to userinfo endpoint
const accessToken = tokens.accessToken();
const userRes = await fetch(oidcConfig.userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!userRes.ok) return null;
const userInfo = (await userRes.json()) as {
sub: string;
email?: string;
name?: string;
};
if (!userInfo.email) return null;
return {
email: userInfo.email,
name: userInfo.name || userInfo.email.split('@')[0],
providerId: userInfo.sub
};
}

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