Compare commits

..
Author SHA1 Message Date
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
github-actions[bot] b8a87d1626 chore: bump version to 0.3.3 2026-03-07 07:04:44 +00:00
Frederic CabassutandClaude Opus 4.6 b7b9bca29a fix: update docker-compose only after Docker image is pushed
Move docker-compose.yml update from release.sh (pre-build) to a
post-release job in both bump.yml and release.yml. This ensures the
image tag in docker-compose only appears on main after the image
exists on ghcr.io, preventing Dokploy from trying to pull a
non-existent image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:04:18 +01:00
github-actions[bot] bd5819da61 chore: bump version to 0.3.2 2026-03-07 06:56:57 +00:00
Frederic CabassutandClaude Opus 4.6 96e0212d55 fix: include tags in sync pull response and update API spec
- GET /api/sync now attaches tags to notes (matching GET /api/notes
  and GET /api/search behavior)
- Add trashedAt field to Note schema in OpenAPI spec
- Add optional id field to NoteInput schema
- Add 400 error response for POST /api/sync
- Add response schema for GET attachments list
- Regenerate API.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:52:36 +01:00
Frederic CabassutandClaude Opus 4.6 5cccaa911a docs: update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md
- FEATURES.md: add rich text editor section (Tiptap, formatting toolbar,
  raw markdown mode) and expand text formatting list
- DEPLOYMENT.md: fix docker-compose example to use image from ghcr.io
  instead of local build
- CLAUDE.md: add documentation maintenance instructions, update CI/CD
  section to reflect consolidated bump.yml workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:50:10 +01:00
github-actions[bot] 7d2c545ac5 chore: bump version to 0.3.1 2026-03-07 06:45:06 +00:00
Frederic CabassutandClaude Opus 4.6 ba4f37d413 fix: consolidate release pipeline into bump workflow
- Update release.sh to also bump docker-compose.yml image tag in the
  same commit, keeping the tagged commit in sync
- Add CI and Docker release jobs to bump.yml so it's self-contained
  (GITHUB_TOKEN pushes don't trigger other workflows)
- Remove "Pin docker-compose" post-step from release.yml since it's
  now handled by release.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:44:15 +01:00
github-actions[bot] 6f7560abd1 chore: bump version to 0.3.0 2026-03-07 06:08:01 +00:00
Frederic CabassutandClaude Opus 4.6 5e46545dc6 feat: replace pretzel emoji with favicon across the app
Use the new favicon SVG in the header, login, setup, empty state,
and error pages. Keep the pretzel emoji in the footer tagline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:05:59 +01:00
Frederic CabassutandClaude Opus 4.6 f459f39ae6 feat: add proper favicon set from RealFaviconGenerator
Replace inline emoji favicon with full cross-platform favicon support
including SVG, PNG, ICO, Apple touch icon, and PWA manifest icons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:05:59 +01:00
Frederic CabassutandClaude Opus 4.6 b71bdf63e6 rename "Horizontal rule" to "Divider" in formatting toolbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:05:59 +01:00
github-actions[bot] 14f7c1deaa chore: bump version to 0.2.3 2026-03-07 05:32:22 +00:00
Frederic CabassutandClaude Opus 4.6 56b0510789 fix: update docker-compose image tag after CI builds the image
Move docker-compose version pinning from release script to the release
workflow so it runs after the image is pushed to GHCR. Dokploy will
always find the referenced image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:48:28 +01:00
Frederic Cabassut bd9762600e chore: bump version to 0.2.2 2026-03-06 23:46:32 +01:00
Frederic CabassutandClaude Opus 4.6 f1ee1744e7 chore: pin docker-compose image to version tag on release
Release script now updates docker-compose.yml with the exact version
tag so Dokploy always pulls the correct image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:45:47 +01:00
Frederic Cabassut 40e32ae8fc chore: bump version to 0.2.1 2026-03-06 23:38:13 +01:00
Frederic CabassutandClaude Opus 4.6 c1c4fe5056 chore: switch to GHCR image in docker-compose, remove dokploy webhook
- docker-compose.yml now uses pre-built ghcr.io image instead of build
- ORIGIN is configurable via env var for deployment
- Remove deploy job from release workflow (Dokploy pulls compose from repo)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:35:08 +01:00
Frederic CabassutandClaude Opus 4.6 e54e706b65 fix: make E2E tests resilient to shared DB in parallel execution
- Add noteCard() helper to target specific cards by title instead of
  generic getByTestId('note-card') which fails with multiple matches
- Scope action button clicks (trash, pin, archive) within their card
- Scope tag-chip filter clicks to the tag-filter container
- Wait for networkidle after auth to ensure full hydration
- Remove "empty notes list" test incompatible with shared DB
- Use toHaveCount(0) instead of not.toBeVisible for search empty state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:32:42 +01:00
Frederic Cabassut 66182d1645 chore: bump version to 0.2.0 2026-03-06 23:13:34 +01:00
Frederic CabassutandClaude Opus 4.6 f4bd2c12d3 refactor: isolate auth layout, unify tags, add editor animation and pin indicator
- Create (auth) and (app) layout groups to isolate login/setup pages
  from the main app chrome (header, sidebar, footer)
- Use window.location.href instead of goto() after login/setup to
  ensure clean full-page load across layout groups
- Add reusable TagChip component, used in TagFilter and NoteCard
- Add fade-in/pop-in entrance animation to note editor overlay
- Add persistent clickable pin indicator on pinned cards (top-right
  bookmark icon, replaces hover-only pin button for pinned notes)
- Improve "Add a crumb..." button with + icon and dashed border
- Update CLAUDE.md with retro parchment design system guidelines
- Add /.claude/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 eb18242f5a style: unify color system and add retro card shadows
Replace hardcoded colors (gray-500, #b44, red-300/red-50/red-600,
green-600) with CSS variables for theme consistency. Add --destructive,
--error-*, --success-*, and --card-shadow variables to :root. Soften
default card borders from --border to --border-subtle and add 8-bit
style hard-offset shadows with hover effects. Apply accent-color for
themed checkboxes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 c33e81257b fix: prevent "No crumbs yet" flash on page refresh
Add notesLoaded store flag set after IDB read completes. Gate the empty
state message behind it so it only shows once we know the store is empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 cacc5feebc style: update toggle icons to show destination mode
Checklist toggle: SquareCheck (→ checklist) / Type (→ text).
Markdown toggle: FileCode (→ markdown) / FileText (→ rich text).
Icons now show what you'll switch to, not the current state.
Remove active color highlight since icon swap communicates state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 66232c9798 fix: color palette opens upward on mobile to avoid overflow
Change color picker popup from top-full to bottom-full so it appears
above the toolbar instead of overflowing below the editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 f652f97e0f fix: close sidebar when tapping outside on mobile
Add a semi-transparent backdrop overlay (lg:hidden) behind the sidebar
on mobile. Clicking/tapping the backdrop closes the sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 cce29705a2 fix: toolbar dropdowns open upward and no longer overflow on mobile
Remove scroll from toolbar, move undo/redo into More dropdown to fit
single line. All dropdowns now open upward (bottom-full) so they don't
overflow below the editor on small screens. Use Ellipsis icon for More.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 22ebabfaf7 fix: prevent note content overflow on mobile
Wrap editor content in a scrollable container with max-h-[60vh] so long
notes scroll within the editor instead of pushing it off-screen. Also make
the overlay itself scrollable with bottom padding for small viewports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 9b1b8b0464 style: close sidebar by default on mobile
Use window.matchMedia to check viewport width on mount. Sidebar defaults
to closed, then opens automatically on desktop (>= 1024px).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 0a2901572c chore: add todo.md to gitignore and remove from tracking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 9947b3478b style: collapse formatting toolbar into single line with More dropdown on mobile
Reorganize toolbar: inline formatting (bold/italic/strike/underline) as
primary buttons, move blockquote/code/alignment/HR into a "More" dropdown.
Use overflow-x-auto instead of flex-wrap for single-line scrollable toolbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 f1ab4d0e82 style: swap icons based on toggle state for markdown and checklist modes
Markdown toggle: Eye (rich text) / Code (raw MD, highlighted).
Checklist toggle: List (text mode) / ListChecks (checklist, highlighted).
Icons now change on toggle so the active state is visually clear.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 77337e9ac1 fix: prevent color picker overflow on mobile
Constrain color picker popup width to viewport minus padding with a
max-width, preventing horizontal overflow on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 369742fff4 style: add subtle outline to unselected color swatches
Use --border-subtle for unselected swatches instead of transparent,
making each color option visually distinct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 a9642f4a3e style: collapse search bar into icon on mobile
On mobile, show a search icon that expands into a full-width search bar
with a back arrow to close. Desktop keeps the always-visible search bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 f2fd2acfff style: replace all inline SVGs with Lucide icons
Swap every inline SVG across Header, SearchBar, NoteCard, NoteEditor,
Checklist, and ImageUpload for Lucide components. Use Palette for the
color picker icon instead of the paintbrush. Zero inline SVGs remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 e1d529a660 style: use theme colors for sync status icon
Replace green/blue/yellow/red with palette colors: --primary for synced,
--primary with opacity for syncing, --text-muted for offline, muted red
for error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 71befde16e feat: offline-first note loading from IDB cache
Load notes from IndexedDB first for instant display, then sync from
server in background. All mutations now persist to IDB when online,
keeping the cache always current. Eliminates empty state flash on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 6269a9ac99 feat: deep link to open notes via URL hash
Opening a note sets the URL to /#note-id, closing clears it.
On page load, the hash is read and the corresponding note is opened.
Uses replaceState to avoid polluting browser history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 c53ebfacf6 fix: allow sidebar toggle on desktop via menu button
Remove lg:translate-x-0 override that forced sidebar open on desktop.
Main content and footer margin now conditional on sidebar state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 eb49c6f292 style: remove redundant pin indicator from note card
The hover action button already provides pin/unpin functionality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 5ca214e60d feat: allow checking/unchecking checklist items directly from the note card
Checkboxes in the card preview are now interactive — clicking one toggles
the item without opening the editor. Uses stopPropagation to prevent
the card click handler from firing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 85c0709ee2 fix: extract tags client-side for offline notes and server-side on sync
- Import extractTags in notes store to populate tags on optimistic notes
- Re-extract tags on offline update when title or content changes
- Run extractTags + syncNoteTags in processSyncPush after transaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 cd1032dd0c style: remove bretzel emoji from header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Frederic CabassutandClaude Opus 4.6 f684e38134 fix: handle offline note editing with sync queue, toasts, and live sync indicator
- Extract toast store to standalone module so notes store can show toasts
- Add try/catch to all note mutations with optimistic updates + IDB queue on network error
- Wire up startSync/stopSync in layout to enable online/offline event listeners
- Reload notes from server after successful sync push to prevent data loss
- Connect SyncIndicator to real sync status with Lucide cloud icons
- Add 300ms minimum display time for status transitions
- Add e2e tests for offline create, edit, trash, and sync-on-reconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:12:36 +01:00
Fred Cabassut df3a65ab08 Merge pull request #1 from bretzel-app/add-claude-github-actions-1772829898393
Add Claude Code GitHub Workflow
2026-03-06 21:45:29 +01:00
Fred Cabassut 93123cb0d5 "Claude Code Review workflow" 2026-03-06 21:45:01 +01:00
Fred Cabassut 702a9cd708 "Claude PR Assistant workflow" 2026-03-06 21:45:00 +01:00
Frederic CabassutandClaude Opus 4.6 46e8b2e529 ci: add automated version bump, tag, and release script
Add scripts/release.sh that infers bump type from conventional commits
(major/minor/patch), bumps package.json, commits, tags, and pushes.
Includes safety checks for branch, clean tree, and remote sync.

Also adds a workflow_dispatch GitHub Actions workflow (bump.yml) and
Makefile targets (release, release-patch, release-minor, release-major).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:40:48 +01:00
Frederic CabassutandClaude Opus 4.6 e93bbda136 chore: bump version to 0.1.8
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:21:04 +01:00
Frederic CabassutandClaude Opus 4.6 ef3ba163dd fix: resolve N+1 queries, add indexes, and improve DB operations
- Batch-fetch tags for notes in a single query instead of per-note loops
- Add composite indexes on notes, note_tags, and sync_log tables
- Wrap sync push in a transaction for atomicity
- Replace O(n) cleanExpiredSessions with single DELETE query
- Use SQL WHERE in getChangesSince instead of loading all notes
- Extract shared syncNoteTags utility to eliminate duplication
- Add root +error.svelte boundary matching app theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:12:39 +01:00
Frederic CabassutandClaude Opus 4.6 02e6b57764 chore: add agent skills for Svelte 5, SvelteKit, TypeScript, and Drizzle
Install project-level skills via skills.sh CLI:
- svelte5-best-practices (runes, snippets, events, migration)
- sveltekit-structure (routing, layouts, error boundaries)
- sveltekit-data-flow (load functions, form actions, serialization)
- typescript-best-practices (type-first development, discriminated unions)
- drizzle-migrations (SQLite schema, query patterns)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:11:36 +01:00
Frederic CabassutandClaude Opus 4.6 0894c10c95 style: redesign UI with parchment theme and JetBrains Mono font
Replace the default dark/white theme with a warm parchment aesthetic
using CSS custom properties, retro pixel overlay, and JetBrains Mono
monospace font across all components. Bump version to 0.1.7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:51:10 +01:00
Frederic CabassutandClaude Opus 4.6 a9779c93aa fix: pass app version via server load instead of Vite define
The Svelte 5 SSR compiler was optimizing away the __APP_VERSION__
constant set via Vite's define, causing the version to be missing
from the production footer. Use a +layout.server.ts load function
to pass the version from package.json instead.

Also bumps version to 0.1.6.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:18:59 +01:00
Frederic CabassutandClaude Opus 4.6 3b3a1b4ff0 chore: bump version to 0.1.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:55:03 +01:00
Frederic CabassutandClaude Opus 4.6 c73061a74e fix: run Dokploy redeploy after Docker image push
Move the Dokploy webhook into a separate deploy job that depends on
the release job, ensuring the image is fully pushed before redeploying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:36:56 +01:00
Frederic CabassutandClaude Opus 4.6 e56016a5b2 fix: resolve __APP_VERSION__ type error in svelte-check
Move the global type declaration inside declare global block and
reference the constant via a local variable in the script block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:14:59 +01:00
Frederic CabassutandClaude Opus 4.6 ca8362d151 feat: display app version in footer
Inject version from package.json at build time via Vite's define and
show it in the layout footer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:05:25 +01:00
Frederic CabassutandClaude Opus 4.6 4fcb21d191 feat: enable PWA offline support with service worker
Add SvelteKitPWA plugin to generate a service worker that precaches
static assets and uses NetworkFirst for API routes, enabling the app
to load offline. Generate pretzel emoji PWA icons and remove the old
static manifest (now generated by the plugin).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:03:34 +01:00
Frederic CabassutandClaude Opus 4.6 cc92652358 feat: trigger Dokploy redeploy after release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:55:45 +01:00
Frederic CabassutandClaude Opus 4.6 c27b391a72 fix: add .dockerignore to prevent host node_modules in image
Excludes node_modules, build artifacts, and test data from the Docker
build context. Fixes invalid ELF header error when building on macOS
for Linux containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:29:10 +01:00
Frederic CabassutandClaude Opus 4.6 931e106da8 fix: add workflow_call trigger to CI for reuse by release workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:25:54 +01:00
Frederic CabassutandClaude Opus 4.6 679599c721 chore: bump version to 0.1.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:24:59 +01:00
Frederic CabassutandClaude Opus 4.6 bf8aeb32fa fix: type error on tiptap-markdown storage access
Cast editor storage to Record<string, any> since tiptap-markdown
does not ship type declarations for its storage extension.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:24:25 +01:00
Frederic CabassutandClaude Opus 4.6 60b2347c91 feat: push Docker image to GitHub Container Registry on release
Replace generic registry secrets with ghcr.io using the built-in
GITHUB_TOKEN. Use docker/metadata-action for automatic semver tagging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:19:12 +01:00
Frederic CabassutandClaude Opus 4.6 3bde75592f feat: rename "notes" to "crumbs" throughout the UI
Replace all user-facing "note(s)" with "crumb(s)" — sidebar, search
placeholder, editor placeholders, empty states. Use Lucide icons
(StickyNote, Archive, Trash2, Tag) in sidebar for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:17:30 +01:00
Frederic CabassutandClaude Opus 4.6 1adb58c6b0 feat: add sticky footer with branding
Add "Crumbs by Bretzel — made with 🥨 in Strasbourg" footer pinned
to the bottom of the page using flexbox layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:08:51 +01:00
Frederic CabassutandClaude Opus 4.6 c619515ca6 feat: remove dark mode support
Remove ThemeToggle component, theme store, dark: Tailwind variants,
and dark color values from NOTE_COLORS. Simplify color logic to use
a single bg property. Delete dark mode e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:07:18 +01:00
Frederic CabassutandClaude Opus 4.6 6f3f7c7e03 fix: undo/redo buttons now reactively update on editor changes
Add a tick counter that increments on every TipTap transaction,
propagated from TiptapEditor through NoteEditor to FormattingToolbar.
This forces Svelte 5 to re-evaluate canUndo/canRedo state in the
template since plain object method calls aren't tracked reactively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:00:58 +01:00
Frederic CabassutandClaude Opus 4.6 26b3ae26c3 feat: icon-based formatting toolbar with dropdowns and link popover
Replace plain text toolbar labels with Lucide SVG icons. Group heading,
list, and alignment options into dropdown menus to reduce toolbar width.
Replace window.prompt link insertion with an inline popover (input, apply,
open, remove). Add underline and text-align TipTap extensions. Remove
task list from TipTap editor (broken styling). Fix task list bullet
rendering in note card preview. Increase editor min-height for markdown
mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:50:52 +01:00
Frederic CabassutandClaude Opus 4.6 6a2e54497f feat: replace textarea with TipTap rich text editor
- Add TipTap WYSIWYG editor (headless @tiptap/core) with StarterKit,
  TaskList, TaskItem, Link, Placeholder, and tiptap-markdown extensions
- Always-visible formatting toolbar with undo/redo, H1-H3, bold, italic,
  strikethrough, inline code, code block, blockquote, bullet/ordered/task
  lists, horizontal rule, and link
- Raw markdown toggle for power users
- Render rich text (HTML) on note cards instead of stripped plain text
- Update all e2e tests for TipTap contenteditable editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:24:33 +01:00
Frederic CabassutandClaude Opus 4.6 19c2c7435a fix: render checklist items with checkboxes on note cards
Checklist-mode notes now display as a visual checklist preview (up to
8 items with overflow indicator) instead of raw markdown checkbox syntax.
Regular notes keep the truncated plain-text preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:32:53 +01:00
Frederic CabassutandClaude Opus 4.6 cbe2a12f7c fix: flaky search e2e test due to duplicate title across test suites
Use a unique title "Grocery Errands" instead of "Shopping List" which
collided with the checklist test suite in the shared test database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:26:54 +01:00
Frederic CabassutandClaude Opus 4.6 26cae27857 docs: deduplicate README, add OpenAPI spec with auto-generated API docs
- Slim README to essentials, link to docs/ for details
- Add docs/openapi.yaml as single source of truth for the API
- Add scripts/generate-api-docs.ts to produce docs/API.md from the spec
- Add pnpm docs:api / make docs-api target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:24:29 +01:00
Claude 66c91df983 feat: Crumbs by Bretzel — self-hostable notes app MVP
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:16:45 +01:00
210 changed files with 1253 additions and 21813 deletions
+1 -25
View File
@@ -1,32 +1,8 @@
# Database path (relative to project root or absolute)
DATABASE_URL=./data/crumbs.db
# Server origin (required for CSRF protection and OAuth redirect URLs)
# Server origin (required for CSRF protection)
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>
-5
View File
@@ -70,9 +70,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
type=raw,value=latest
- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -87,11 +84,9 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
+17 -41
View File
@@ -46,24 +46,11 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- 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
- run: pnpm test:unit
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.0-noble
steps:
- uses: actions/checkout@v4
@@ -78,43 +65,32 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Build (with coverage instrumentation)
run: pnpm build
env:
DATABASE_URL: ./data/test-crumbs.db
NODE_ENV: test
VITE_COVERAGE: 'true'
- 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
- 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: always()
if: failure()
with:
name: playwright-report
path: |
playwright-report/
test-results/
path: playwright-report/
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
-5
View File
@@ -30,9 +30,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -47,11 +44,9 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
cache-from: type=gha
cache-to: type=gha,mode=max
-47
View File
@@ -1,47 +0,0 @@
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
-11
View File
@@ -32,19 +32,8 @@ 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
@@ -1,7 +0,0 @@
{
"report-dir": "coverage/e2e",
"temp-dir": "coverage/e2e/raw",
"reporter": ["text", "html", "lcov", "json-summary"],
"include": ["src/**"],
"exclude": ["node_modules", "tests"]
}
+3 -30
View File
@@ -2,7 +2,7 @@
## Project overview
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
## Tech stack
@@ -10,7 +10,7 @@ Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password au
- **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**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
- **Auth**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
- **Package manager**: pnpm
@@ -55,7 +55,6 @@ 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
@@ -75,12 +74,7 @@ src/
(app)/ # Layout group: main app (header, sidebar, footer)
+layout.svelte # App shell with Header, Sidebar, Toast
+page.svelte # Main notes view
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/auth/ # Login, logout, setup endpoints
api/notes/ # CRUD + attachments
api/search/ # Full-text search
api/sync/ # Offline sync
@@ -98,10 +92,6 @@ 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
@@ -134,14 +124,6 @@ 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:
@@ -163,13 +145,6 @@ 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.
@@ -187,7 +162,6 @@ 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
@@ -227,6 +201,5 @@ 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`) |
+1 -8
View File
@@ -1,5 +1,4 @@
.PHONY: help dev build preview check test test-unit test-e2e \
test-unit-coverage test-e2e-coverage lint \
.PHONY: help dev build preview check test test-unit test-e2e 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
@@ -28,12 +27,6 @@ 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
+4 -62
View File
@@ -9,13 +9,9 @@
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
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="website/assets/screenshot-grid.png" alt="Crumbs notes grid view" width="720">
</p>
## Install
## Quick Start
### Docker (Recommended)
@@ -23,56 +19,7 @@ A self-hostable, offline-first note-taking app that respects your privacy. Part
docker compose up -d
```
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
```
Open http://localhost:3000 and set your password on first visit.
### Development
@@ -94,12 +41,8 @@ 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
- 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
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
- Single-user password auth (Argon2)
- Docker deployment with a single command
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
@@ -125,7 +68,6 @@ 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 |
+1 -16
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.23.0
image: ghcr.io/bretzel-app/crumbs:0.5.0
container_name: crumbs
ports:
- "3000:3000"
@@ -11,21 +11,6 @@ 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,17 +111,6 @@ 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"
}
]
}
]
@@ -168,17 +157,6 @@ 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"
}
]
}
```
@@ -214,17 +192,6 @@ 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"
}
]
}
```
@@ -280,17 +247,6 @@ 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"
}
]
}
```
@@ -321,231 +277,6 @@ 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`
@@ -560,7 +291,6 @@ 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`
@@ -572,7 +302,6 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
@@ -601,7 +330,6 @@ Upload an image attachment
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"thumbnailPath": "thumbnailPath",
"createdAt": "2025-01-01T00:00:00.000Z"
}
```
@@ -665,17 +393,6 @@ 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"
}
]
}
]
@@ -734,17 +451,6 @@ 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"
}
]
}
]
@@ -792,92 +498,3 @@ 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
---
+7 -62
View File
@@ -49,14 +49,6 @@ 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
@@ -83,79 +75,32 @@ For a single-user app with multiple devices:
- New v4 uses Vite plugin (faster builds)
### Argon2
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
- Current best practice for password hashing
- Memory-hard (resists GPU attacks)
- Winner of the Password Hashing Competition
## Database Schema
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - User accounts (password hash, OAuth provider link, role)
- `users` - Single user (password hash)
- `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 (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)
- `attachments` - Image file metadata
- `sync_log` - Sync operation history
### 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, notes-service)
src/lib/server/mcp/ # MCP server + tool definitions
src/lib/server/ # Server-only code (DB, auth, attachments)
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 + MCP endpoints
src/routes/api/ # REST API endpoints
src/routes/ # Page routes (SvelteKit file-based routing)
```
-209
View File
@@ -1,209 +0,0 @@
# 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 |
+3 -47
View File
@@ -100,8 +100,6 @@ node build
## Environment Variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
@@ -110,24 +108,6 @@ 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`:
@@ -136,34 +116,10 @@ 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
- 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)
- Sessions expire after 30 days
- Passwords are hashed with Argon2
+12 -80
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
@@ -42,13 +42,7 @@
- Upload images via file picker or drag-and-drop
- Supports JPEG, PNG, GIF, WebP, SVG
- Max file size: 10MB
- **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
- Thumbnail previews in note cards
- Remove attachments from editor
### Tag Organization
@@ -59,17 +53,11 @@
- Tags inside code blocks and URLs are ignored
### Color-Coded Notes
12 note colors:
12 colors matching Google Keep's palette:
- Default, Coral, Peach, Sand, Mint, Sage
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- 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
- Pinned section separated from unpinned notes
@@ -86,84 +74,28 @@
- 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
### 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
### 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
### 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
### PWA / Offline-First
- Installable as standalone app
- Web app manifest with icons
- 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)
- Service worker for asset caching
- IndexedDB for local data storage
- Background sync every 30 seconds
- 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
@@ -1,142 +0,0 @@
# 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,202 +168,6 @@ 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
@@ -381,12 +185,6 @@ 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)
@@ -411,10 +209,6 @@ 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
@@ -521,144 +315,14 @@ 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:
@@ -704,10 +368,6 @@ components:
type: array
items:
type: string
attachments:
type: array
items:
$ref: '#/components/schemas/Attachment'
NoteInput:
type: object
@@ -755,10 +415,6 @@ 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
@@ -778,35 +434,3 @@ 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
@@ -1,781 +0,0 @@
# 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"
```
@@ -1,190 +0,0 @@
# 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)
@@ -1,232 +0,0 @@
# 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
@@ -1,322 +0,0 @@
# 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
@@ -1,67 +0,0 @@
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
@@ -1,37 +0,0 @@
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
@@ -1,114 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,465 +0,0 @@
{
"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
@@ -1,684 +0,0 @@
{
"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
@@ -1,34 +0,0 @@
{
"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
}
]
}
+4 -28
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.23.0",
"version": "0.6.1",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -12,18 +12,14 @@
},
"scripts": {
"dev": "vite dev",
"build": "npx tsx scripts/generate-openapi.ts && vite build",
"openapi": "npx tsx scripts/generate-openapi.ts",
"build": "vite build",
"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",
@@ -33,36 +29,25 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@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.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.1.0",
"zod": "^4.3.6",
"zxcvbn": "^4.4.2"
"uuid": "^11.1.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
@@ -72,24 +57,16 @@
"@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",
"@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",
"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": {
@@ -101,8 +78,7 @@
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0",
"hono": ">=4.12.7"
"cookie": ">=0.7.0"
}
}
}
+10 -14
View File
@@ -1,41 +1,37 @@
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: 0,
workers: process.env.CI ? 4 : undefined,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
globalSetup: './tests/e2e/global-setup.ts',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
serviceWorkers: 'block'
screenshot: 'only-on-failure'
},
projects: [
{
name: 'auth-setup',
testMatch: 'auth.spec.ts',
fullyParallel: false,
name: 'setup',
testMatch: /auth\.spec\.ts/,
use: { ...devices['Desktop Chrome'] }
},
{
name: 'app',
testIgnore: 'auth.spec.ts',
dependencies: ['auth-setup'],
name: 'chromium',
testIgnore: /auth\.spec\.ts/,
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'pnpm preview --port 4173',
command: 'pnpm build && pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
DATABASE_URL: './data/test-crumbs.db',
NODE_ENV: 'test'
DATABASE_URL: './data/test-crumbs.db'
}
}
});
+2 -1888
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
#!/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
@@ -1,147 +0,0 @@
/**
* 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`);
-110
View File
@@ -1,110 +0,0 @@
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
@@ -1,103 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,271 +0,0 @@
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');
}
+1 -124
View File
@@ -20,25 +20,6 @@
--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 {
@@ -52,7 +33,7 @@ body::before {
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
@@ -91,107 +72,3 @@ 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: center;
gap: 0.375rem;
margin: 0;
padding: 0;
}
.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;
}
/* 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;
}
+1 -11
View File
@@ -1,19 +1,9 @@
/// <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 {
user: {
id: number;
email: string;
displayName: string;
role: 'admin' | 'user';
} | null;
}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+1 -15
View File
@@ -4,21 +4,6 @@
<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" />
@@ -26,6 +11,7 @@
<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>
+3 -49
View File
@@ -1,56 +1,17 @@
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',
'/api/auth/oauth',
'/s/',
'/api/shared'
];
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
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) {
@@ -60,16 +21,9 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// Validate session and populate user
// Validate session
const sessionToken = event.cookies.get('session');
if (sessionToken) {
const result = await validateSession(sessionToken);
if (result.valid && result.user) {
event.locals.user = result.user;
}
}
if (!event.locals.user) {
if (!sessionToken || !(await validateSession(sessionToken))) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
-5
View File
@@ -1,5 +0,0 @@
// 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
@@ -1,11 +0,0 @@
/**
* 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 => ({});
+12 -17
View File
@@ -19,7 +19,6 @@
let { content, onChange }: Props = $props();
// svelte-ignore state_referenced_locally
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
@@ -73,12 +72,9 @@
items.splice(afterIndex + 1, 0, newItem);
items = [...items];
emitChange();
// Focus by unique ID — positional indexing breaks when checked items
// are interleaved in items[] (e.g. reopened notes) because the DOM
// only renders active items as inputs
const newId = newItem.id;
setTimeout(() => {
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[afterIndex + 1]?.focus();
}, 0);
}
@@ -116,16 +112,16 @@
}
</script>
<div class="space-y-0.5" data-testid="checklist">
<div class="space-y-1" data-testid="checklist">
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-0.5"
class="space-y-1"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<div class="group flex items-center gap-2" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
<input
@@ -140,14 +136,13 @@
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)]'}"
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"
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"
class="opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
@@ -159,7 +154,7 @@
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-add"
>
<Plus class="h-4 w-4" />
@@ -178,9 +173,9 @@
</button>
{#if doneExpanded}
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
<div class="mt-1 space-y-1 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5">
<div class="group flex items-center gap-2">
<input
type="checkbox"
checked={item.checked}
@@ -191,7 +186,7 @@
<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"
class="opacity-0 group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
@@ -1,68 +0,0 @@
<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,6 +1,5 @@
<script lang="ts">
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
import type { NoteColor } from '$lib/types/index.js';
interface Props {
@@ -13,10 +12,11 @@
<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: {getNoteColor(option.value, getIsDarkMode())}"
style="background-color: {colors.bg}"
title={option.label}
data-testid="color-{option.value}"
></button>
+85 -182
View File
@@ -6,7 +6,6 @@
Heading,
List,
ListOrdered,
ListChecks,
TextQuote,
CodeXml,
Bold,
@@ -24,10 +23,7 @@
AlignJustify,
Minus,
ChevronDown,
Table2,
BetweenHorizontalEnd,
BetweenVerticalEnd,
RemoveFormatting
Ellipsis
} from 'lucide-svelte';
interface Props {
@@ -39,9 +35,6 @@
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('');
@@ -117,35 +110,13 @@
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
class="flex 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(isActive('bold'))}
class={btnClass(editor?.isActive('bold') ?? false)}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
@@ -153,7 +124,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(isActive('italic'))}
class={btnClass(editor?.isActive('italic') ?? false)}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
@@ -161,7 +132,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(isActive('strike'))}
class={btnClass(editor?.isActive('strike') ?? false)}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
@@ -169,7 +140,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(isActive('underline'))}
class={btnClass(editor?.isActive('underline') ?? false)}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
@@ -182,7 +153,7 @@
<div class="relative" data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
class={dropdownBtnClass(isActive('heading'))}
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
title="Heading"
data-testid="format-heading"
>
@@ -193,7 +164,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(isActive('heading', { level: 1 }))}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
@@ -201,7 +172,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
@@ -209,7 +180,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
@@ -223,7 +194,7 @@
<div class="relative" data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
title="Lists"
data-testid="format-list"
>
@@ -234,7 +205,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(isActive('bulletList'))}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
data-testid="format-bullet-list"
>
<List size={iconSize} />
@@ -242,20 +213,12 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('orderedList'))}
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
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>
@@ -264,7 +227,7 @@
<div class="relative" data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
class={btnClass(isActive('link'))}
class={btnClass(editor?.isActive('link') ?? false)}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
@@ -296,7 +259,7 @@
<button
type="button"
onclick={openLink}
disabled={!getAttrs('link').href}
disabled={!editor?.getAttributes('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"
@@ -306,7 +269,7 @@
<button
type="button"
onclick={removeLink}
disabled={!isActive('link')}
disabled={!editor?.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"
@@ -318,123 +281,99 @@
{/if}
</div>
<!-- Table dropdown -->
<div class="relative" data-dropdown="table">
<button
onclick={() => toggleDropdown('table')}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
>
<Table2 size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#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().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-insert"
>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
{#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>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Alignment dropdown -->
<div class="relative" data-dropdown="align">
<!-- More dropdown -->
<div class="relative" data-dropdown="more">
<button
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
title="Text alignment"
data-testid="format-align"
onclick={() => toggleDropdown('more')}
class={dropdownBtnClass(false)}
title="More options"
data-testid="format-more"
>
{#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} />
<Ellipsis size={iconSize} />
</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">
{#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">
<button
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
disabled={!canUndo()}
class={dropdownItemClass()}
data-testid="format-undo"
>
<Undo2 size={iconSize} />
<span>Undo</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>
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
<span>Align left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
<span>Align center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
<span>Align right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
@@ -443,40 +382,4 @@
</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
@@ -1,58 +0,0 @@
<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>
+35 -145
View File
@@ -1,83 +1,28 @@
<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, onToggleFeatured, showDropZone = true }: Props = $props();
let { noteId, attachments = [], onUpload, onRemove }: 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', new File([optimized], file.name, { type: optimized.type || file.type }));
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
formData.append('file', file);
try {
const res = await fetch(`/api/notes/${noteId}/attachments`, {
@@ -87,16 +32,12 @@
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) {
@@ -116,38 +57,16 @@
{#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">
<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; }}
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded-sm object-cover"
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 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
@@ -159,58 +78,29 @@
{/if}
<!-- Upload area -->
{#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
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>
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
+52 -37
View File
@@ -1,14 +1,15 @@
<script lang="ts">
import { page } from '$app/stores';
import { allTags } from '$lib/stores/notes.js';
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
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';
interface Props {
open: boolean;
onClose?: () => void;
appVersion?: string;
}
let { open, onClose }: Props = $props();
let { open, onClose, appVersion }: Props = $props();
function closeMobile() {
if (window.matchMedia('(max-width: 1023px)').matches) {
@@ -16,11 +17,17 @@
}
}
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' }
];
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
loadNotes(filter);
closeMobile();
}
function selectTag(tag: string | null) {
selectedTag.set(tag);
loadNotes('all');
closeMobile();
}
</script>
{#if open}
@@ -37,18 +44,33 @@
>
<nav class="p-2">
<ul class="space-y-1">
{#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}
<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>
</ul>
{#if $allTags.length > 0}
@@ -57,29 +79,22 @@
<ul class="mt-2 space-y-1">
{#each $allTags as tag}
<li>
<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)]'}"
<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)]'}"
>
<Tag size={16} />
#{tag}
</a>
</button>
</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>
{#if appVersion}
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)] px-6 py-3 text-right text-xs text-[var(--text-muted)]">
v{appVersion}
</div>
{/if}
</aside>
+71 -87
View File
@@ -1,17 +1,14 @@
<script lang="ts">
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
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 { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
import TagChip from './TagChip.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';
interface ChecklistItem {
text: string;
@@ -27,12 +24,11 @@
let { note, onEdit, fullHeight = false }: Props = $props();
$effect(() => {
cardStyle = `background-color: ${getNoteColor(note.color, getIsDarkMode())}`;
const colors = NOTE_COLORS[note.color];
cardStyle = `background-color: ${colors.bg}`;
});
let cardStyle = $state('');
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
const renderedContent = $derived(renderMarkdown(note.content));
@@ -49,8 +45,6 @@
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();
@@ -58,11 +52,24 @@
};
}
function toggleChecklistItem(item: ChecklistItem) {
const lines = note.content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const text = line.replace(/^- \[[ x]\] /, '');
if (text === item.text && line.startsWith(item.checked ? '- [x] ' : '- [ ] ')) {
lines[i] = item.checked
? `- [ ] ${text}`
: `- [x] ${text}`;
break;
}
}
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 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' : ''}"
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)] {fullHeight ? 'h-full' : ''}"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
@@ -71,71 +78,33 @@
data-testid="note-card"
data-note-id={note.id}
>
<!-- 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 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>
{/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"
title="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-2 mb-6" data-testid="note-checklist-preview">
<ul class="space-y-1" data-testid="note-checklist-preview">
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<input
type="checkbox"
checked={item.checked}
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
data-testid="card-checklist-checkbox"
/>
<span class="break-words min-w-0">{item.text}</span>
<span class="truncate">{item.text}</span>
</li>
{/each}
{#if checklistItems.length > 8}
@@ -148,6 +117,14 @@
</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 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
@@ -197,30 +174,37 @@
<Archive class="h-4 w-4" />
</button>
{/if}
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="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"
title="Delete"
data-testid="trash-btn"
>
<Trash2 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}
</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>
+17 -302
View File
@@ -1,29 +1,17 @@
<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 ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.svelte';
import NoteHistory from './NoteHistory.svelte';
import { updateNote, createNote } 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 { NOTE_COLORS } from '$lib/utils/colors.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import type { Note, NoteColor } 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';
interface Props {
note: Note | null;
@@ -32,227 +20,44 @@
}
const { note, isNew = 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 ?? prefs.defaultNoteColor);
let color = $state<NoteColor>(note?.color ?? 'default');
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let rawMarkdownMode = $state(note?.checklistMode ?? false);
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
// 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);
});
let showShareDialog = $state(false);
let showHistory = $state(false);
// 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())}`;
const colors = NOTE_COLORS[color];
bgStyle = `background-color: ${colors.bg}`;
});
// 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() {
async function save() {
if (!title.trim() && !content.trim()) {
onClose();
return;
}
if (currentlyNew) {
if (isNew) {
await createNote({ title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
} else if (note) {
await updateNote(note.id, { 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') {
saveAndClose();
save();
}
}
@@ -272,15 +77,15 @@
<!-- 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]"
onpointerdown={handleOverlayPointerdown}
onpointerup={handleOverlayPointerup}
onclick={save}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
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]"
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]"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
@@ -291,13 +96,6 @@
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 -->
@@ -326,32 +124,13 @@
{/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}
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
</div>
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
{/if}
<!-- Toolbar -->
<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 justify-between border-t border-[var(--border-subtle)] px-2 py-2">
<div class="flex items-center gap-1">
<!-- Color picker toggle -->
<div class="relative">
@@ -384,16 +163,6 @@
{/if}
</button>
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Image attachments"
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</button>
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}
@@ -408,44 +177,10 @@
<FileCode class="h-5 w-5" />
{/if}
</button>
<!-- Share button (owner only for new shares, all for viewing) -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
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)]" title="Shared note">
<Users class="h-5 w-5" />
</span>
{/if}
<!-- History button (only for saved notes) -->
{#if !currentlyNew}
<button
onclick={toggleHistory}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Version history"
data-testid="history-toggle"
>
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
</button>
{/if}
</div>
<button
onclick={saveAndClose}
onclick={save}
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="close-editor-btn"
>
@@ -454,23 +189,3 @@
</div>
</div>
</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}
+12 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import NoteCard from './NoteCard.svelte';
import { dndzone, type DndEvent } from 'svelte-dnd-action';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import type { Note } from '$lib/types/index.js';
@@ -43,12 +44,20 @@
<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 }}
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each localItems as note (note.id)}
<div class="h-full outline-none" animate:flip={{ duration: flipDurationMs }}>
<div class="relative h-full" animate:flip={{ duration: flipDurationMs }}>
<div
use:dragHandle
aria-label="drag handle for {note.title || 'note'}"
class="absolute top-2 right-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
data-testid="note-drag-handle"
>
<GripVertical class="h-4 w-4" />
</div>
<NoteCard {note} {onEdit} fullHeight />
</div>
{/each}
-224
View File
@@ -1,224 +0,0 @@
<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>
-127
View File
@@ -1,127 +0,0 @@
<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';
interface Props {
filter: NoteFilter;
tag?: string | null;
}
let { filter, tag = null }: Props = $props();
let editingNote: Note | null = $state(null);
let showNewNote = $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;
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">
<button
onclick={() => (showNewNote = true)}
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
</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} onClose={closeEditor} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
@@ -1,46 +0,0 @@
<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
@@ -1,297 +0,0 @@
<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>
@@ -1,95 +0,0 @@
<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 bottom-full z-20 mb-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>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Clock from 'lucide-svelte/icons/clock';
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
import Hand from 'lucide-svelte/icons/hand';
import { sortMode, type SortMode } from '$lib/stores/sort.js';
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
{ value: 'updated', label: 'Updated', icon: Clock },
{ value: 'created', label: 'Created', icon: CalendarPlus },
{ value: 'custom', label: 'Custom', icon: Hand }
];
</script>
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
{#each modes as mode}
<button
onclick={() => sortMode.set(mode.value)}
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
{$sortMode === mode.value
? 'bg-[var(--primary)] text-white'
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
data-testid="sort-{mode.value}"
aria-label="Sort by {mode.label}"
>
<mode.icon class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{mode.label}</span>
</button>
{/each}
</div>
+3 -8
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
import { getSyncStatus, onSyncStatusChange, 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,12 +44,7 @@
});
</script>
<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"
>
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
{#if displayStatus === 'synced'}
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
@@ -59,4 +54,4 @@
{:else}
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
{/if}
</button>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
{#if onclick}
<button
{onclick}
class="whitespace-nowrap rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
class="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"
+4 -21
View File
@@ -1,33 +1,16 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { allTags } from '$lib/stores/notes.js';
import { allTags, selectedTag } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
function toggleTag(tag: string) {
const isActive = $page.url.pathname === `/tag/${tag}`;
goto(isActive ? '/' : `/tag/${tag}`);
}
function isTagActive(tag: string, pathname: string): boolean {
return pathname === `/tag/${tag}`;
selectedTag.update((current) => (current === tag ? null : tag));
}
</script>
{#if $allTags.length > 0}
<!-- 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">
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
{#each $allTags as tag}
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
{/each}
</div>
{/if}
<style>
.tag-scroll {
scrollbar-width: none;
}
.tag-scroll::-webkit-scrollbar {
display: none;
}
</style>
+2 -19
View File
@@ -6,12 +6,6 @@
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 {
@@ -36,12 +30,6 @@
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Markdown
],
content,
@@ -69,21 +57,16 @@
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none 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;
flex: 1;
min-height: 80px;
}
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
-125
View File
@@ -1,125 +0,0 @@
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
@@ -1,105 +0,0 @@
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
@@ -1,100 +0,0 @@
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
@@ -1,52 +0,0 @@
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');
}
+9 -68
View File
@@ -2,10 +2,9 @@ import { existsSync, mkdirSync } from 'fs';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import type { Db } from './db/index.js';
import { db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
import { eq } from 'drizzle-orm';
const DATA_DIR = process.env.DATA_DIR || './data';
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
@@ -16,11 +15,8 @@ if (!existsSync(ATTACHMENTS_DIR)) {
}
export async function saveAttachment(
db: Db,
noteId: string,
file: File,
userId: number,
thumbnail?: File | Blob | null
file: File
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
const ext = file.name.split('.').pop() || 'bin';
@@ -30,25 +26,15 @@ 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();
@@ -56,67 +42,22 @@ export async function saveAttachment(
return attachment;
}
/**
* 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 getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
}
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) {
export async function getAttachmentsByNote(noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
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);
export async function deleteAttachment(id: string) {
const attachment = await getAttachment(id);
if (attachment) {
try {
await unlink(attachment.path);
} catch {
// File may already be deleted
}
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)));
await db.delete(attachments).where(eq(attachments.id, id));
}
}
+5 -12
View File
@@ -41,25 +41,18 @@ 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 admin setup', async () => {
it('should only allow one user', async () => {
await db.insert(users).values({
email: 'admin@test.com',
role: 'admin',
passwordHash: 'hash1',
createdAt: new Date()
});
@@ -73,7 +66,7 @@ describe('Auth - Sessions', () => {
it('should create and validate a session', async () => {
const [user] = await db
.insert(users)
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -92,7 +85,7 @@ describe('Auth - Sessions', () => {
it('should detect expired sessions', async () => {
const [user] = await db
.insert(users)
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -110,7 +103,7 @@ describe('Auth - Sessions', () => {
it('should delete a session', async () => {
const [user] = await db
.insert(users)
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
+34 -289
View File
@@ -1,320 +1,65 @@
import { db, sqlite } from './db/index.js';
import { db } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq, lt, and } from 'drizzle-orm';
import { eq, lt } 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 = db.select().from(users).get();
const user = await db.select().from(users).get();
return !!user;
}
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
export async function setupUser(password: string): Promise<boolean> {
const existing = await db.select().from(users).get();
if (existing) return false; // Already set up
const hash = await argon2.hash(password);
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);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
return true;
}
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 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 createSession(
userId: number,
meta?: { userAgent?: string; ip?: string }
): Promise<string> {
export async function createSession(): Promise<string> {
const user = await db.select().from(users).get();
if (!user) throw new Error('No user exists');
const token = randomBytes(32).toString('hex');
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();
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
});
return token;
}
export async function validateSession(
token: string
): Promise<{ valid: boolean; user?: SessionUser }> {
if (!token) return { valid: false };
export async function validateSession(token: string): Promise<boolean> {
if (!token) 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;
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) 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 };
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, token));
return false;
}
// 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
}
};
return true;
}
export async function deleteSession(token: string): Promise<void> {
db.delete(sessions).where(eq(sessions.id, token)).run();
await db.delete(sessions).where(eq(sessions.id, token));
}
export async function cleanExpiredSessions(): Promise<void> {
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();
}
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
}
-83
View File
@@ -1,83 +0,0 @@
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
@@ -1,75 +0,0 @@
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
@@ -1,154 +0,0 @@
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);
});
});
+6 -199
View File
@@ -16,36 +16,24 @@ const sqlite = new Database(DATABASE_URL);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// 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)
// Auto-create tables on first run
sqlite.exec(`
CREATE TABLE IF NOT EXISTS 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,
password_hash TEXT NOT NULL,
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,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
expires_at INTEGER NOT NULL
);
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',
@@ -62,9 +50,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,
@@ -73,209 +61,28 @@ 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,
user_id INTEGER NOT NULL DEFAULT 0,
note_id TEXT NOT NULL,
note_id TEXT NOT NULL REFERENCES notes(id),
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 };
+7 -20
View File
@@ -9,7 +9,7 @@ import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => {
const testDb = createTestDb({ seedUser: true });
const testDb = createTestDb();
db = testDb.db;
});
@@ -20,7 +20,6 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Test Note',
content: 'Hello world',
createdAt: now,
@@ -46,7 +45,6 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Colored Note',
content: '',
color: 'coral',
@@ -64,17 +62,16 @@ 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', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now },
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
{
id: 'n2',
userId: 1,
title: 'Archived',
content: '',
archived: true,
createdAt: now,
updatedAt: now
},
{ id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
]);
const active = await db
@@ -92,7 +89,6 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Original',
content: '',
createdAt: now,
@@ -117,7 +113,6 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Trash',
content: '',
createdAt: now,
@@ -137,7 +132,6 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Delete',
content: '',
createdAt: now,
@@ -155,7 +149,6 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'To Archive',
content: '',
createdAt: now,
@@ -177,7 +170,6 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({
id,
userId: 1,
title: 'Pin Me',
content: '',
createdAt: now,
@@ -201,7 +193,6 @@ describe('Notes with Tags', () => {
await db.insert(notes).values({
id: noteId,
userId: 1,
title: 'Tagged Note',
content: '',
createdAt: now,
@@ -209,8 +200,8 @@ describe('Notes with Tags', () => {
});
// Create tags
const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning();
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
// Associate
await db.insert(noteTags).values([
@@ -235,18 +226,16 @@ 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({ userId: 1, name: 'temp' }).returning();
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note_tags first (FK cascade is disabled in test DB), then note
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
// Delete note
await db.delete(notes).where(eq(notes.id, noteId));
// note_tags should be gone
@@ -268,7 +257,6 @@ describe('Note ordering', () => {
await db.insert(notes).values([
{
id: 'unpinned',
userId: 1,
title: 'Unpinned',
content: '',
pinned: false,
@@ -277,7 +265,6 @@ describe('Note ordering', () => {
},
{
id: 'pinned',
userId: 1,
title: 'Pinned',
content: '',
pinned: true,
+3 -134
View File
@@ -2,12 +2,7 @@ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqli
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
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'),
passwordHash: text('password_hash').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
@@ -16,21 +11,13 @@ export const sessions = sqliteTable('sessions', {
userId: integer('user_id')
.references(() => users.id)
.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' })
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
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(),
@@ -45,7 +32,6 @@ 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)
]
@@ -55,13 +41,9 @@ 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_user_unique').on(table.name, table.userId)]
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
);
export const noteTags = sqliteTable(
@@ -82,10 +64,6 @@ 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(),
@@ -93,31 +71,13 @@ 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(),
@@ -127,94 +87,3 @@ 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({ seedUser: true });
const testDb = createTestDb();
db = testDb.db;
const now = new Date();
await db.insert(notes).values([
{ 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 }
{ 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 }
]);
const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
});
+6 -104
View File
@@ -6,37 +6,27 @@ import * as schema from './schema.js';
* Create an in-memory SQLite database for testing.
* Each test gets a fresh database.
*/
export function createTestDb(options?: { seedUser?: boolean }) {
export function createTestDb() {
const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = OFF');
sqlite.pragma('foreign_keys = ON');
// Create tables matching the multi-user schema
// Create tables
sqlite.exec(`
CREATE TABLE 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,
password_hash TEXT NOT NULL,
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,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
expires_at INTEGER NOT NULL
);
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',
@@ -50,125 +40,37 @@ export function createTestDb(options?: { seedUser?: boolean }) {
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_user_unique ON tags(name, user_id);
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
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
@@ -1,353 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,30 +0,0 @@
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
@@ -1,344 +0,0 @@
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
@@ -1,336 +0,0 @@
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);
});
});
-473
View File
@@ -1,473 +0,0 @@
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
if (input.content !== existing.content) {
const baseSnapshot = input.baseVersion !== undefined
? 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()
: db.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, id))
.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
@@ -1,51 +0,0 @@
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
@@ -1,38 +0,0 @@
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
@@ -1,119 +0,0 @@
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
};
}
-50
View File
@@ -1,50 +0,0 @@
import * as arctic from 'arctic';
export type OAuthProviderInfo = {
id: string;
name: string;
icon?: string;
};
let googleClient: arctic.Google | null = null;
let githubClient: arctic.GitHub | null = null;
function getBaseUrl(): string {
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
}
export function getGoogleClient(): arctic.Google | null {
if (googleClient) return googleClient;
const clientId = process.env.AUTH_GOOGLE_CLIENT_ID;
const clientSecret = process.env.AUTH_GOOGLE_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
googleClient = new arctic.Google(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/google/callback`);
return googleClient;
}
export function getGithubClient(): arctic.GitHub | null {
if (githubClient) return githubClient;
const clientId = process.env.AUTH_GITHUB_CLIENT_ID;
const clientSecret = process.env.AUTH_GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
githubClient = new arctic.GitHub(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/github/callback`);
return githubClient;
}
export function getEnabledProviders(): OAuthProviderInfo[] {
const providers: OAuthProviderInfo[] = [];
if (getGoogleClient()) providers.push({ id: 'google', name: 'Google' });
if (getGithubClient()) providers.push({ id: 'github', name: 'GitHub' });
const oidcIssuer = process.env.AUTH_OIDC_ISSUER;
const oidcClientId = process.env.AUTH_OIDC_CLIENT_ID;
const oidcClientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
if (oidcIssuer && oidcClientId && oidcClientSecret) {
providers.push({
id: 'oidc',
name: process.env.AUTH_OIDC_DISPLAY_NAME || 'SSO'
});
}
return providers;
}
-62
View File
@@ -1,62 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import type { Db } from './db/index.js';
import { getPreferences, upsertPreferences } from './preferences.js';
let db: Db;
const USER_ID = 1;
beforeEach(() => {
const testDb = createTestDb({ seedUser: true });
db = testDb.db;
});
describe('getPreferences', () => {
it('should return empty object for user with no preferences', () => {
const prefs = getPreferences(db, USER_ID);
expect(prefs).toEqual({});
});
});
describe('upsertPreferences', () => {
it('should insert new preferences', () => {
const result = upsertPreferences(db, USER_ID, {
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
expect(result).toEqual({
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
});
it('should update existing preferences', () => {
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' });
expect(result.defaultNoteMode).toBe('richtext');
});
it('should handle partial updates without affecting other keys', () => {
upsertPreferences(db, USER_ID, {
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' });
expect(result).toEqual({
defaultNoteMode: 'markdown',
hideFooter: 'false'
});
});
it('should keep preferences isolated between users', () => {
// Create a second user
db.run(
/* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})`
);
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
upsertPreferences(db, 2, { defaultNoteMode: 'richtext' });
expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown');
expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext');
});
});
-42
View File
@@ -1,42 +0,0 @@
import type { Db } from './db/index.js';
import { userPreferences } from './db/schema.js';
import { eq } from 'drizzle-orm';
/**
* Get all preferences for a user as a key-value record.
*/
export function getPreferences(db: Db, userId: number): Record<string, string> {
const rows = db
.select({ key: userPreferences.key, value: userPreferences.value })
.from(userPreferences)
.where(eq(userPreferences.userId, userId))
.all();
const result: Record<string, string> = {};
for (const row of rows) {
result[row.key] = row.value;
}
return result;
}
/**
* Upsert multiple preferences for a user.
* Uses INSERT OR REPLACE with the unique (user_id, key) index.
*/
export function upsertPreferences(
db: Db,
userId: number,
prefs: Record<string, string>
): Record<string, string> {
const now = new Date();
for (const [key, value] of Object.entries(prefs)) {
db.insert(userPreferences)
.values({ userId, key, value, updatedAt: now })
.onConflictDoUpdate({
target: [userPreferences.userId, userPreferences.key],
set: { value, updatedAt: now }
})
.run();
}
return getPreferences(db, userId);
}
-100
View File
@@ -1,100 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { loginAttempts } from './db/schema.js';
import type { Db } from './db/index.js';
import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js';
let db: Db;
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
});
const IP = '192.168.1.1';
const EMAIL = 'test@test.com';
function addFailedAttempts(count: number, now: Date) {
for (let i = 0; i < count; i++) {
recordLoginAttempt(db, IP, EMAIL, false, now);
}
}
describe('checkRateLimit', () => {
it('should allow first attempt', () => {
const result = checkRateLimit(db, IP, EMAIL);
expect(result.allowed).toBe(true);
});
it('should allow up to 4 failed attempts', () => {
const now = new Date();
addFailedAttempts(4, now);
const result = checkRateLimit(db, IP, EMAIL, now);
expect(result.allowed).toBe(true);
});
it('should block after 5 failed attempts within window', () => {
const now = new Date();
addFailedAttempts(5, now);
const result = checkRateLimit(db, IP, EMAIL, now);
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeDefined();
expect(result.retryAfter!).toBeGreaterThan(0);
});
it('should return correct retryAfter value', () => {
const now = new Date();
addFailedAttempts(5, now);
const result = checkRateLimit(db, IP, EMAIL, now);
// Lockout is 15 minutes (900 seconds)
expect(result.retryAfter).toBe(900);
});
it('should allow again after lockout expires', () => {
const attemptTime = new Date('2024-01-01T00:00:00Z');
addFailedAttempts(5, attemptTime);
// Check at attempt time — should be blocked
const blocked = checkRateLimit(db, IP, EMAIL, attemptTime);
expect(blocked.allowed).toBe(false);
// Check 16 minutes later — should be allowed
const later = new Date(attemptTime.getTime() + 16 * 60 * 1000);
const allowed = checkRateLimit(db, IP, EMAIL, later);
expect(allowed.allowed).toBe(true);
});
});
describe('recordLoginAttempt', () => {
it('should insert attempt record', () => {
recordLoginAttempt(db, IP, EMAIL, false);
const rows = db.select().from(loginAttempts).all();
expect(rows).toHaveLength(1);
expect(rows[0].ip).toBe(IP);
expect(rows[0].email).toBe(EMAIL);
});
it('should record success/failure flag correctly', () => {
recordLoginAttempt(db, IP, EMAIL, true);
recordLoginAttempt(db, IP, EMAIL, false);
const rows = db.select().from(loginAttempts).all();
expect(rows[0].success).toBe(true);
expect(rows[1].success).toBe(false);
});
});
describe('clearOldAttempts', () => {
it('should remove attempts older than 2x lockout', () => {
const old = new Date('2024-01-01T00:00:00Z');
const recent = new Date('2024-01-01T01:00:00Z');
recordLoginAttempt(db, IP, EMAIL, false, old);
recordLoginAttempt(db, IP, EMAIL, false, recent);
// Clear at a time where old is > 2x lockout (30 min) ago but recent is not
const clearTime = new Date(old.getTime() + 31 * 60 * 1000);
clearOldAttempts(db, clearTime);
const rows = db.select().from(loginAttempts).all();
expect(rows).toHaveLength(1);
});
});
-77
View File
@@ -1,77 +0,0 @@
import type { Db } from './db/index.js';
import { loginAttempts } from './db/schema.js';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 1000; // 1 minute
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
export function checkRateLimit(
db: Db,
ip: string,
email: string,
now?: Date
): { allowed: boolean; retryAfter?: number } {
const currentTime = now ?? new Date();
const windowStart = new Date(currentTime.getTime() - LOCKOUT_MS);
// Count failed attempts from this IP in the lockout window
const result = db
.select({ count: sql<number>`count(*)` })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ip, ip),
eq(loginAttempts.success, false),
gte(loginAttempts.timestamp, windowStart)
)
)
.get();
const failedCount = result?.count ?? 0;
if (failedCount >= MAX_ATTEMPTS) {
// Find the most recent failed attempt to calculate retry-after
const latest = db
.select({ timestamp: loginAttempts.timestamp })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ip, ip),
eq(loginAttempts.success, false),
gte(loginAttempts.timestamp, windowStart)
)
)
.orderBy(sql`${loginAttempts.timestamp} DESC`)
.limit(1)
.get();
if (latest) {
const retryAfter = Math.ceil(
(latest.timestamp.getTime() + LOCKOUT_MS - currentTime.getTime()) / 1000
);
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
}
return { allowed: false, retryAfter: 900 };
}
return { allowed: true };
}
export function recordLoginAttempt(db: Db, ip: string, email: string, success: boolean, now?: Date): void {
db.insert(loginAttempts)
.values({
ip,
email,
success,
timestamp: now ?? new Date()
})
.run();
}
export function clearOldAttempts(db: Db, now?: Date): void {
const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2);
db.delete(loginAttempts)
.where(lt(loginAttempts.timestamp, cutoff))
.run();
}
-167
View File
@@ -1,167 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, users, attachments } from './db/schema.js';
import type { Db } from './db/index.js';
import {
fetchSharesForNotes,
createShare,
revokeShare,
getShareByNoteId,
getSharedNote
} from './shares-service.js';
let db: Db;
const OWNER_ID = 1;
function seedUsers() {
db.insert(users)
.values([
{
email: 'owner@test.com',
displayName: 'Owner',
role: 'admin',
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: 'Test Note',
content: 'Test content',
createdAt: now,
updatedAt: now,
...overrides
})
.run();
return id;
}
beforeEach(() => {
const result = createTestDb();
db = result.db;
seedUsers();
});
describe('createShare', () => {
it('creates a share and returns a token', () => {
seedNote('note-1');
const { token } = createShare(db, 'note-1', OWNER_ID);
expect(token).toBeDefined();
expect(token.length).toBe(21);
});
it('returns the same token on duplicate creation (idempotent)', () => {
seedNote('note-1');
const first = createShare(db, 'note-1', OWNER_ID);
const second = createShare(db, 'note-1', OWNER_ID);
expect(first.token).toBe(second.token);
});
it('throws 404 for non-existent note', () => {
expect(() => createShare(db, 'nonexistent', OWNER_ID)).toThrow();
});
});
describe('revokeShare', () => {
it('removes the share token', () => {
seedNote('note-1');
createShare(db, 'note-1', OWNER_ID);
revokeShare(db, 'note-1', OWNER_ID);
const share = getShareByNoteId(db, 'note-1');
expect(share).toBeNull();
});
});
describe('getShareByNoteId', () => {
it('returns null when no share exists', () => {
seedNote('note-1');
expect(getShareByNoteId(db, 'note-1')).toBeNull();
});
it('returns share when one exists', () => {
seedNote('note-1');
const { token } = createShare(db, 'note-1', OWNER_ID);
const share = getShareByNoteId(db, 'note-1');
expect(share).not.toBeNull();
expect(share!.token).toBe(token);
});
});
describe('getSharedNote', () => {
it('returns note data for valid token', () => {
seedNote('note-1', { title: 'Shared Title', content: 'Shared content' });
const { token } = createShare(db, 'note-1', OWNER_ID);
const data = getSharedNote(db, token);
expect(data).not.toBeNull();
expect(data!.title).toBe('Shared Title');
expect(data!.content).toBe('Shared content');
});
it('returns null for trashed note', () => {
seedNote('note-1', { trashed: true });
const { token } = createShare(db, 'note-1', OWNER_ID);
expect(getSharedNote(db, token)).toBeNull();
});
it('returns null for invalid token', () => {
expect(getSharedNote(db, 'invalid-token')).toBeNull();
});
it('includes attachments', () => {
seedNote('note-1');
db.insert(attachments)
.values({
id: 'att-1',
userId: OWNER_ID,
noteId: 'note-1',
filename: 'image.png',
mimeType: 'image/png',
size: 1024,
path: '/data/attachments/att-1.png',
createdAt: new Date()
})
.run();
const { token } = createShare(db, 'note-1', OWNER_ID);
const data = getSharedNote(db, token);
expect(data!.attachments).toHaveLength(1);
expect(data!.attachments[0].filename).toBe('image.png');
// Verify server paths are not exposed
expect(data!.attachments[0]).not.toHaveProperty('path');
expect(data!.attachments[0]).not.toHaveProperty('thumbnailPath');
});
});
describe('fetchSharesForNotes', () => {
it('returns empty map for no notes', () => {
const result = fetchSharesForNotes(db, []);
expect(result.size).toBe(0);
});
it('returns tokens for shared notes', () => {
seedNote('note-1');
seedNote('note-2');
seedNote('note-3');
const { token: token1 } = createShare(db, 'note-1', OWNER_ID);
const { token: token2 } = createShare(db, 'note-2', OWNER_ID);
const result = fetchSharesForNotes(db, ['note-1', 'note-2', 'note-3']);
expect(result.size).toBe(2);
expect(result.get('note-1')).toBe(token1);
expect(result.get('note-2')).toBe(token2);
expect(result.get('note-3')).toBeUndefined();
});
});
-117
View File
@@ -1,117 +0,0 @@
import type { Db } from './db/index.js';
import { sharedNotes, notes, attachments } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import { requireNoteOwnership } from './api-utils.js';
import type { SharedNoteData } from '$lib/types/index.js';
/**
* Batch fetch share tokens for multiple notes.
* Returns a Map of noteId → token.
*/
export function fetchSharesForNotes(db: Db, noteIds: string[]): Map<string, string> {
if (noteIds.length === 0) return new Map();
const rows = db
.select({ noteId: sharedNotes.noteId, token: sharedNotes.token })
.from(sharedNotes)
.where(inArray(sharedNotes.noteId, noteIds))
.all();
const map = new Map<string, string>();
for (const row of rows) {
map.set(row.noteId, row.token);
}
return map;
}
/**
* Create a public share link for a note. Idempotent — returns existing token if already shared.
*/
export function createShare(db: Db, noteId: string, userId: number): { token: string } {
requireNoteOwnership(db, noteId, userId);
const existing = db
.select({ token: sharedNotes.token })
.from(sharedNotes)
.where(eq(sharedNotes.noteId, noteId))
.get();
if (existing) return { token: existing.token };
const token = nanoid(21);
db.insert(sharedNotes)
.values({ noteId, token, createdAt: new Date() })
.run();
return { token };
}
/**
* Revoke a public share link for a note.
*/
export function revokeShare(db: Db, noteId: string, userId: number): void {
requireNoteOwnership(db, noteId, userId);
db.delete(sharedNotes)
.where(eq(sharedNotes.noteId, noteId))
.run();
}
/**
* Get share info for a specific note.
*/
export function getShareByNoteId(db: Db, noteId: string) {
return db
.select()
.from(sharedNotes)
.where(eq(sharedNotes.noteId, noteId))
.get() ?? null;
}
/**
* Get a shared note by its public token. Returns null if note is trashed or token is invalid.
*/
export function getSharedNote(db: Db, token: string): (SharedNoteData & { noteId: string }) | null {
const share = db
.select()
.from(sharedNotes)
.where(eq(sharedNotes.token, token))
.get();
if (!share) return null;
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, share.noteId), eq(notes.trashed, false)))
.get();
if (!note) return null;
const noteAttachments = db
.select()
.from(attachments)
.where(eq(attachments.noteId, share.noteId))
.all();
const publicAttachments = noteAttachments.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
featured: a.featured,
createdAt: a.createdAt
}));
return {
noteId: note.id,
title: note.title,
content: note.content,
checklistMode: note.checklistMode,
color: note.color as SharedNoteData['color'],
attachments: publicAttachments,
createdAt: note.createdAt,
updatedAt: note.updatedAt
};
}
-89
View File
@@ -1,89 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, tags, noteTags, users } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
let db: Db;
const USER_ID = 1;
function seedUser() {
db.insert(users)
.values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() })
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUser();
});
describe('syncNoteTags', () => {
it('should create new tags and associations', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID);
const tagRows = db.select().from(tags).all();
expect(tagRows).toHaveLength(2);
const associations = db.select().from(noteTags).all();
expect(associations).toHaveLength(2);
});
it('should remove old associations and add new ones', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['old-tag'], USER_ID);
syncNoteTags(db, 'n1', ['new-tag'], USER_ID);
const map = fetchTagsForNotes(db, ['n1']);
expect(map.get('n1')).toEqual(['new-tag']);
});
it('should handle empty tag list (removes all)', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID);
syncNoteTags(db, 'n1', [], USER_ID);
const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all();
expect(associations).toHaveLength(0);
});
it('should deduplicate existing tags', () => {
seedNote('n1');
seedNote('n2');
syncNoteTags(db, 'n1', ['shared-tag'], USER_ID);
syncNoteTags(db, 'n2', ['shared-tag'], USER_ID);
// Should reuse the same tag row
const tagRows = db.select().from(tags).all();
expect(tagRows).toHaveLength(1);
});
});
describe('fetchTagsForNotes', () => {
it('should return Map of noteId to tag names', () => {
seedNote('n1');
seedNote('n2');
syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID);
syncNoteTags(db, 'n2', ['gamma'], USER_ID);
const map = fetchTagsForNotes(db, ['n1', 'n2']);
expect(map.get('n1')).toHaveLength(2);
expect(map.get('n1')).toContain('alpha');
expect(map.get('n1')).toContain('beta');
expect(map.get('n2')).toEqual(['gamma']);
});
it('should return empty map for empty noteIds', () => {
const map = fetchTagsForNotes(db, []);
expect(map.size).toBe(0);
});
});
+6 -10
View File
@@ -1,12 +1,12 @@
import type { Db } from './db/index.js';
import { db } from './db/index.js';
import { noteTags, tags } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
/**
* Batch fetch tags for multiple notes in a single query.
* Returns a Map of noteId → tag names.
*/
export function fetchTagsForNotes(db: Db, noteIds: string[]): Map<string, string[]> {
export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
if (noteIds.length === 0) return new Map();
const rows = db
@@ -32,17 +32,13 @@ export function fetchTagsForNotes(db: Db, noteIds: string[]): Map<string, string
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
* Should be called within a transaction for atomicity.
*/
export function syncNoteTags(db: Db, noteId: string, tagNames: string[], userId: number) {
export function syncNoteTags(noteId: string, tagNames: string[]) {
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
for (const name of tagNames) {
let tagRow = db
.select()
.from(tags)
.where(and(eq(tags.name, name), eq(tags.userId, userId)))
.get();
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = db.insert(tags).values({ name, userId }).returning().all();
const result = db.insert(tags).values({ name }).returning().all();
tagRow = result[0];
}
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
-207
View File
@@ -1,207 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { users, notes, noteVersions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
let db: Db;
const OWNER_ID = 1;
const NOTE_ID = 'note-history-test';
function seedUser() {
db.insert(users)
.values({
id: OWNER_ID,
email: 'owner@test.com',
displayName: 'Owner',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
})
.run();
}
function seedNote(overrides: Partial<typeof notes.$inferInsert> = {}) {
const now = new Date();
db.insert(notes)
.values({
id: NOTE_ID,
userId: OWNER_ID,
title: 'Test Note',
content: 'Test content',
createdAt: now,
updatedAt: now,
version: 1,
...overrides
})
.run();
}
beforeEach(() => {
({ db } = createTestDb());
seedUser();
});
describe('createSnapshot', () => {
it('should store a snapshot for a note', () => {
seedNote();
createSnapshot(db, NOTE_ID, {
version: 1,
title: 'Test Note',
content: 'Test content',
checklistMode: false,
color: 'default'
});
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe(1);
expect(rows[0].title).toBe('Test Note');
expect(rows[0].content).toBe('Test content');
expect(rows[0].checklistMode).toBe(false);
expect(rows[0].color).toBe('default');
});
it('should not duplicate snapshots for the same version (onConflictDoNothing)', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 1, title: 'A', content: '', checklistMode: false, color: 'default' });
createSnapshot(db, NOTE_ID, { version: 1, title: 'B', content: '', checklistMode: false, color: 'default' });
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(1);
expect(rows[0].title).toBe('A'); // first one wins
});
it('should prune to at most 50 snapshots', () => {
seedNote();
for (let i = 1; i <= 55; i++) {
createSnapshot(db, NOTE_ID, {
version: i,
title: `Version ${i}`,
content: '',
checklistMode: false,
color: 'default'
});
}
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(50);
// Oldest (v1–v5) should be pruned; newest (v6–v55) should remain
const versions = rows.map((r) => r.version);
expect(Math.min(...versions)).toBe(6);
expect(Math.max(...versions)).toBe(55);
});
});
describe('listVersions', () => {
it('should return an empty array when there are no versions', () => {
seedNote();
expect(listVersions(db, NOTE_ID)).toEqual([]);
});
it('should return summaries sorted newest first', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 1, title: 'First', content: 'Hello world', checklistMode: false, color: 'default' });
createSnapshot(db, NOTE_ID, { version: 2, title: 'Second', content: 'Updated content here', checklistMode: false, color: 'mint' });
const summaries = listVersions(db, NOTE_ID);
expect(summaries).toHaveLength(2);
expect(summaries[0].version).toBe(2); // newest first
expect(summaries[1].version).toBe(1);
});
it('should truncate contentPreview to 80 characters', () => {
seedNote();
const longContent = 'a'.repeat(200);
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: longContent, checklistMode: false, color: 'default' });
const summaries = listVersions(db, NOTE_ID);
expect(summaries[0].contentPreview).toHaveLength(80);
});
it('should include correct summary fields', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 3, title: 'My Title', content: 'My content', checklistMode: false, color: 'coral' });
const summaries = listVersions(db, NOTE_ID);
expect(summaries[0]).toMatchObject({
noteId: NOTE_ID,
version: 3,
title: 'My Title',
contentPreview: 'My content'
});
expect(summaries[0].id).toBeTruthy();
expect(summaries[0].createdAt).toBeInstanceOf(Date);
});
});
describe('getVersion', () => {
it('should return null for a non-existent version', () => {
seedNote();
expect(getVersion(db, NOTE_ID, 'non-existent-id')).toBeNull();
});
it('should return the full version content', () => {
seedNote();
createSnapshot(db, NOTE_ID, {
version: 2,
title: 'Full Title',
content: '# Heading\n\nSome content',
checklistMode: true,
color: 'mint'
});
const summaries = listVersions(db, NOTE_ID);
const versionId = summaries[0].id;
const version = getVersion(db, NOTE_ID, versionId);
expect(version).not.toBeNull();
expect(version!.version).toBe(2);
expect(version!.title).toBe('Full Title');
expect(version!.content).toBe('# Heading\n\nSome content');
expect(version!.checklistMode).toBe(true);
expect(version!.color).toBe('mint');
});
it('should not return a version from a different note', () => {
const OTHER_NOTE_ID = 'other-note';
seedNote();
db.insert(notes).values({
id: OTHER_NOTE_ID,
userId: OWNER_ID,
title: 'Other',
content: '',
createdAt: new Date(),
updatedAt: new Date(),
version: 1
}).run();
createSnapshot(db, OTHER_NOTE_ID, { version: 1, title: 'Other', content: 'Other content', checklistMode: false, color: 'default' });
const summaries = listVersions(db, OTHER_NOTE_ID);
const versionId = summaries[0].id;
// Requesting version from wrong noteId returns null
expect(getVersion(db, NOTE_ID, versionId)).toBeNull();
});
});
describe('snapshotCurrentNote', () => {
it('should return false for a non-existent note', () => {
expect(snapshotCurrentNote(db, 'non-existent')).toBe(false);
});
it('should snapshot the current note state', () => {
seedNote({ title: 'Current Title', content: 'Current content', version: 5 });
const result = snapshotCurrentNote(db, NOTE_ID);
expect(result).toBe(true);
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
expect(rows).toHaveLength(1);
expect(rows[0].version).toBe(5);
expect(rows[0].title).toBe('Current Title');
expect(rows[0].content).toBe('Current content');
});
});

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