Compare commits

..
Author SHA1 Message Date
github-actions[bot] 5a9c6d2a36 chore: bump version to 0.19.2 2026-03-10 22:43:26 +00:00
Frederic CabassutandClaude Opus 4.6 7c2dad47cc ci: use Playwright container image instead of downloading browsers
Run E2E tests inside mcr.microsoft.com/playwright container which has
Chromium pre-installed, eliminating the ~30s browser download on each run.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also install missing zxcvbn and arctic dependencies from phase2 merge.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #12

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

Closes #11

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

Closes #9

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

Closes #10

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:07:56 +01:00
github-actions[bot] 2465d1b93a chore: pin docker-compose to v0.3.3 [skip ci] 2026-03-07 07:09:56 +00:00
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
78 changed files with 354 additions and 4114 deletions
-8
View File
@@ -22,11 +22,3 @@ NODE_ENV=development
# 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>
-4
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,7 +84,6 @@ 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 }}
-4
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,7 +44,6 @@ 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 }}
-1
View File
@@ -44,4 +44,3 @@ todo.md
# Worktrees
.worktrees/
package-lock.json
+1 -9
View File
@@ -54,7 +54,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
- **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**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Keep at 16-20px size. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
- **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
@@ -163,13 +163,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 +180,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
-18
View File
@@ -46,21 +46,6 @@ volumes:
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
@@ -90,9 +75,6 @@ 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)
+1 -16
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.22.3
image: ghcr.io/bretzel-app/crumbs:0.19.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:
-14
View File
@@ -114,20 +114,6 @@ node build
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`:
+5 -21
View File
@@ -64,10 +64,10 @@
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to note theme
### Favorite Notes
- Favorite important notes to always appear at top
- Favorites section separated from other notes
- Toggle favorite from note card hover actions
### Pin Notes
- Pin important notes to always appear at top
- Pinned section separated from unpinned notes
- Toggle pin from note card hover actions
### Archive
- Archive notes to declutter main view
@@ -91,22 +91,6 @@
- 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
@@ -138,7 +122,7 @@
### 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; favorite, reorder notes; upload images
- 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
@@ -1,672 +0,0 @@
# Pixelarticons Migration + Favorites Rename — 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:** Replace Lucide icons with pixelarticons via @iconify/svelte across the app (except 4 formatting letterforms), and rename user-facing "pinned" to "favorites" with a heart icon.
**Architecture:** Each component file gets its Lucide imports replaced with a single `Icon` import from `@iconify/svelte`. The `size` prop becomes `width`, and the icon name becomes a `pixelarticons:name` string. The 4 formatting letterforms (Bold, Italic, Strikethrough, Underline) stay as Lucide. The favorites rename touches stores, components, MCP server, and one e2e test.
**Tech Stack:** `@iconify/svelte` v5.2.1, `@iconify-json/pixelarticons` v1.2.4 (both already installed), `lucide-svelte` (kept for 4 icons)
**Spec:** `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`
---
## File Structure
No new files created. All changes are modifications to existing files:
**Icon migration (16 files):** Replace Lucide imports with `@iconify/svelte` Icon component.
**Favorites rename (4 source files + 1 test file):** Rename stores, functions, labels, test IDs.
---
### Task 1: Favorites Rename — Stores
**Files:**
- Modify: `src/lib/stores/notes.ts:38-44,234-236`
- [ ] **Step 1: Rename store exports and function**
In `src/lib/stores/notes.ts`, make these changes:
```typescript
// Line 38-44: Rename stores
export const favoriteNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => n.pinned))
);
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => !n.pinned))
);
// Line 234-236: Rename function
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
return updateNote(id, { pinned: !currentPinned });
}
```
Note: The `pinned` field in updateNote stays — it's the DB column name.
- [ ] **Step 2: Verify no type errors**
Run: `pnpm check 2>&1 | head -30`
Expected: Errors in files that still reference old names (NoteCard, NotesView) — that's expected, we fix those next.
- [ ] **Step 3: Commit**
```bash
git add src/lib/stores/notes.ts
git commit -m "refactor: rename pinned stores to favorites"
```
---
### Task 2: Favorites Rename — NoteCard Component
**Files:**
- Modify: `src/lib/components/NoteCard.svelte:4,110-118,170-178`
- [ ] **Step 1: Update import**
Change line 4:
```typescript
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
```
- [ ] **Step 2: Update favorite indicator (lines 110-118)**
Replace the pinned indicator block:
```svelte
{#if note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unfavorite"
data-testid="favorite-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
```
- [ ] **Step 3: Update favorite button (lines 170-178)**
Replace the pin button block:
```svelte
{#if !note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Favorite"
data-testid="favorite-btn"
>
<Bookmark class="h-4 w-4" />
</button>
{/if}
```
- [ ] **Step 4: Commit**
```bash
git add src/lib/components/NoteCard.svelte
git commit -m "refactor: rename pin to favorite in NoteCard"
```
---
### Task 3: Favorites Rename — NotesView Component
**Files:**
- Modify: `src/lib/components/NotesView.svelte:5,89-104`
- [ ] **Step 1: Update imports (line 5)**
```typescript
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
```
- [ ] **Step 2: Update template references**
Replace all `$pinnedNotes` with `$favoriteNotes`, `$unpinnedNotes` with `$unfavoritedNotes`, and label `"Pinned"` with `"Favorites"`:
Line 89: `{#if $favoriteNotes.length > 0}`
Line 91: `<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />`
Line 96: `notes={$unfavoritedNotes}`
Line 97: `label={$favoriteNotes.length > 0 ? 'Others' : ''}`
Line 104: `{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}`
Note: Keep `dndType="pinned-notes"` unchanged — it's an internal DnD identifier, not user-facing.
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/NotesView.svelte
git commit -m "refactor: rename pinned to favorites in NotesView"
```
---
### Task 4: Favorites Rename — MCP Server
**Files:**
- Modify: `src/lib/server/mcp/server.ts:241-265`
- [ ] **Step 1: Rename MCP tool**
Replace the `pin_note` tool definition (lines 241-265):
```typescript
server.tool(
'favorite_note',
'Favorite or unfavorite a note',
{
id: z.string().describe('Note ID'),
pinned: z.boolean().describe('Whether to favorite (true) or unfavorite (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 ? 'favorited' : 'unfavorited'}`
}
]
};
}
);
```
Note: The `pinned` parameter name stays since it maps to the DB column.
- [ ] **Step 2: Commit**
```bash
git add src/lib/server/mcp/server.ts
git commit -m "refactor: rename pin_note MCP tool to favorite_note"
```
---
### Task 5: Favorites Rename — E2E Test
**Files:**
- Modify: `tests/e2e/organization.spec.ts:4-15`
- [ ] **Step 1: Update test scenario**
Replace lines 4-15:
```typescript
test('Scenario: Favorited note appears under the Favorites section', async ({ authenticatedPage: page }) => {
// Given a note titled "Favorite Me" exists
await createNote(page, 'Favorite Me');
// When the user favorites the note
const favCard = noteCard(page, 'Favorite Me');
await favCard.hover();
await favCard.getByTestId('favorite-btn').first().click({ force: true });
// Then the "Favorites" section is visible
await expect(page.getByText('Favorites')).toBeVisible();
});
```
- [ ] **Step 2: Run check to verify favorites rename is complete**
Run: `pnpm check 2>&1 | head -20`
Expected: PASS (no errors related to old pin names)
- [ ] **Step 3: Commit**
```bash
git add tests/e2e/organization.spec.ts
git commit -m "test: update pin test to favorites"
```
---
### Task 6: Icon Migration — Header + SearchBar
**Files:**
- Modify: `src/lib/components/Layout/Header.svelte:2-5`
- Modify: `src/lib/components/SearchBar.svelte:4-6`
- [ ] **Step 1: Migrate Header.svelte**
Replace imports (lines 2-5):
```typescript
import SearchBar from '../SearchBar.svelte';
import SyncIndicator from '../SyncIndicator.svelte';
import Icon from '@iconify/svelte';
```
Replace icon usage in template:
- `<Menu class="h-6 w-6 text-[var(--text)]" />` → `<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />`
- `<Search class="h-5 w-5 text-[var(--text)]" />` → `<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />`
- [ ] **Step 2: Migrate SearchBar.svelte**
Replace imports (lines 4-6):
```typescript
import Icon from '@iconify/svelte';
```
Replace icon usage:
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<ArrowLeft .../>` → `<Icon icon="pixelarticons:arrow-left" .../>`
Match existing `class` attributes exactly — just change the component and add the `icon` prop.
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/Layout/Header.svelte src/lib/components/SearchBar.svelte
git commit -m "feat: migrate Header and SearchBar to pixelarticons"
```
---
### Task 7: Icon Migration — Sidebar
**Files:**
- Modify: `src/lib/components/Layout/Sidebar.svelte:2-4,19-23,47,65,81`
- [ ] **Step 1: Replace imports and nav items**
Replace the import (line 4):
```typescript
import Icon from '@iconify/svelte';
```
Replace navItems array (lines 19-23):
```typescript
const navItems = [
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
];
```
- [ ] **Step 2: Update template icon usage**
Line 47: `<item.icon size={20} />` → `<Icon icon={item.icon} width={20} />`
Line 65: `<Tag size={16} />` → `<Icon icon="pixelarticons:label" width={16} />`
Line 81: `<Settings size={20} />` → `<Icon icon="pixelarticons:settings-cog" width={20} />`
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/Layout/Sidebar.svelte
git commit -m "feat: migrate Sidebar to pixelarticons"
```
---
### Task 8: Icon Migration — NoteCard
**Files:**
- Modify: `src/lib/components/NoteCard.svelte:5-13,117,159,167,177,187,196,206,215`
- [ ] **Step 1: Replace imports**
Replace lines 5-13:
```typescript
import Icon from '@iconify/svelte';
```
Remove all individual Lucide imports (Undo2, Trash2, Bookmark, Archive, ArchiveRestore, UserMinus).
- [ ] **Step 2: Replace all icon usages in template**
- `<Bookmark class="h-4 w-4 fill-[var(--primary)]" />` (favorite indicator) → `<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />`
- `<Bookmark class="h-4 w-4" />` (favorite button) → `<Icon icon="pixelarticons:heart" class="h-4 w-4" />`
- `<Undo2 class="h-4 w-4" />` (restore) → `<Icon icon="pixelarticons:undo" class="h-4 w-4" />`
- `<Trash2 class="h-4 w-4 text-[var(--destructive)]" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />`
- `<Trash2 class="h-4 w-4" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4" />`
- `<Archive class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
- `<ArchiveRestore class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
- `<UserMinus class="h-4 w-4" />` → `<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />`
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/NoteCard.svelte
git commit -m "feat: migrate NoteCard to pixelarticons"
```
---
### Task 9: Icon Migration — NoteEditor
**Files:**
- Modify: `src/lib/components/NoteEditor.svelte:16-24`
- [ ] **Step 1: Replace imports**
Replace lines 16-24:
```typescript
import Icon from '@iconify/svelte';
```
- [ ] **Step 2: Replace all icon usages**
Search the file for each Lucide component and replace:
- `<Palette .../>` → `<Icon icon="pixelarticons:colors-swatch" .../>`
- `<SquareCheck .../>` → `<Icon icon="pixelarticons:checkbox-on" .../>`
- `<ImageIcon .../>` → `<Icon icon="pixelarticons:image" .../>`
- `<Type .../>` → `<Icon icon="pixelarticons:art-text" .../>`
- `<FileCode .../>` → `<Icon icon="pixelarticons:script" .../>`
- `<FileText .../>` → `<Icon icon="pixelarticons:file-text" .../>`
- `<UserPlus .../>` → `<Icon icon="pixelarticons:user-plus" .../>`
- `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
- `<History .../>` → `<Icon icon="pixelarticons:timeline" .../>`
For each, keep the existing `size` prop but rename to `width`. Keep `class` as-is.
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/NoteEditor.svelte
git commit -m "feat: migrate NoteEditor to pixelarticons"
```
---
### Task 10: Icon Migration — FormattingToolbar
This is the largest file. Bold, Italic, Strikethrough, Underline stay as Lucide. Everything else migrates.
**Files:**
- Modify: `src/lib/components/FormattingToolbar.svelte:2-31,131-482`
- [ ] **Step 1: Replace imports**
Replace the import block (lines 3-31):
```typescript
import {
Bold,
Italic,
Strikethrough,
Underline
} from 'lucide-svelte';
import Icon from '@iconify/svelte';
```
- [ ] **Step 2: Replace icon usages in template**
Replace every non-B/I/S/U icon. The pattern: `<IconName size={iconSize} />` → `<Icon icon="pixelarticons:name" width={iconSize} />`. Full mapping:
- `<Undo2 size={iconSize} />` → `<Icon icon="pixelarticons:undo" width={iconSize} />`
- `<Redo2 size={iconSize} />` → `<Icon icon="pixelarticons:redo" width={iconSize} />`
- `<Heading size={iconSize} />` → `<Icon icon="pixelarticons:heading" width={iconSize} />`
- `<List size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:list" width={iconSize} />`
- `<ListOrdered size={iconSize} />` → `<Icon icon="pixelarticons:bulletlist" width={iconSize} />`
- `<ListChecks size={iconSize} />` → `<Icon icon="pixelarticons:checklist" width={iconSize} />`
- `<Link size={iconSize} />` → `<Icon icon="pixelarticons:link" width={iconSize} />`
- `<CornerDownLeft size={16} />` → `<Icon icon="pixelarticons:corner-down-left" width={16} />`
- `<ExternalLink size={16} />` → `<Icon icon="pixelarticons:external-link" width={16} />`
- `<Trash2 size={16} />` → `<Icon icon="pixelarticons:trash" width={16} />`
- `<Table2 size={iconSize} />` (appears 2x) → `<Icon icon="pixelarticons:table" width={iconSize} />`
- `<ChevronDown size={chevronSize} />` (appears 4x) → `<Icon icon="pixelarticons:chevron-down" width={chevronSize} />`
- `<BetweenHorizontalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-row" width={iconSize} />`
- `<BetweenVerticalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-col" width={iconSize} />`
- `<Minus size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:minus" width={iconSize} />`
- `<RemoveFormatting size={iconSize} />` → `<Icon icon="pixelarticons:remove-box" width={iconSize} />`
- `<Code size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
- `<TextQuote size={iconSize} />` → `<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />`
- `<CodeXml size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
- `<AlignLeft size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-left" width={iconSize} />`
- `<AlignCenter size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-center" width={iconSize} />`
- `<AlignRight size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-right" width={iconSize} />`
- `<AlignJustify size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-justify" width={iconSize} />`
Leave these unchanged: `<Bold size={iconSize} />`, `<Italic size={iconSize} />`, `<Strikethrough size={iconSize} />`, `<Underline size={iconSize} />`
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/FormattingToolbar.svelte
git commit -m "feat: migrate FormattingToolbar to pixelarticons (keep B/I/S/U as Lucide)"
```
---
### Task 11: Icon Migration — SyncIndicator
**Files:**
- Modify: `src/lib/components/SyncIndicator.svelte:4-7,54-61`
- [ ] **Step 1: Replace imports**
Replace lines 4-7:
```typescript
import Icon from '@iconify/svelte';
```
- [ ] **Step 2: Replace icon usages**
Replace the status icon block (lines 53-61):
```svelte
{#if displayStatus === 'synced'}
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
{:else if displayStatus === 'offline'}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
{:else}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
{/if}
```
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/SyncIndicator.svelte
git commit -m "feat: migrate SyncIndicator to pixelarticons"
```
---
### Task 12: Icon Migration — Remaining Small Components
**Files:**
- Modify: `src/lib/components/NoteHistory.svelte:3-5`
- Modify: `src/lib/components/ShareDialog.svelte:3-5`
- Modify: `src/lib/components/Checklist.svelte:2-5`
- Modify: `src/lib/components/CollaboratorPopover.svelte:3`
- Modify: `src/lib/components/ImageLightbox.svelte:2`
- Modify: `src/lib/components/ImageUpload.svelte:2-4`
- Modify: `src/lib/components/NotesView.svelte:8`
- [ ] **Step 1: Migrate NoteHistory.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<RotateCcw .../>` → `<Icon icon="pixelarticons:undo" .../>`
- `<Clock .../>` → `<Icon icon="pixelarticons:clock" .../>`
- [ ] **Step 2: Migrate ShareDialog.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<UserMinus .../>` → `<Icon icon="pixelarticons:user-minus" .../>`
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
- [ ] **Step 3: Migrate Checklist.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
- `<GripVertical .../>` → `<Icon icon="pixelarticons:drag-and-drop" .../>`
- `<ChevronRight .../>` → `<Icon icon="pixelarticons:chevron-right" .../>`
- [ ] **Step 4: Migrate CollaboratorPopover.svelte**
Replace import:
```typescript
import Icon from '@iconify/svelte';
```
Replace: `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
- [ ] **Step 5: Migrate ImageLightbox.svelte**
Replace import:
```typescript
import Icon from '@iconify/svelte';
```
Replace: `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
- [ ] **Step 6: Migrate ImageUpload.svelte**
Replace imports:
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
- `<Star .../>` → `<Icon icon="pixelarticons:pin" .../>`
- `<RefreshCw .../>` → `<Icon icon="pixelarticons:reload" .../>`
- [ ] **Step 7: Migrate NotesView.svelte (Plus icon)**
Replace import (line 8):
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<Plus class="h-4 w-4" />` → `<Icon icon="pixelarticons:plus" class="h-4 w-4" />`
- `<Plus class="h-5 w-5" />` → `<Icon icon="pixelarticons:plus" class="h-5 w-5" />`
- [ ] **Step 8: Commit**
```bash
git add src/lib/components/NoteHistory.svelte src/lib/components/ShareDialog.svelte src/lib/components/Checklist.svelte src/lib/components/CollaboratorPopover.svelte src/lib/components/ImageLightbox.svelte src/lib/components/ImageUpload.svelte src/lib/components/NotesView.svelte
git commit -m "feat: migrate remaining components to pixelarticons"
```
---
### Task 13: Icon Migration — Settings Pages
**Files:**
- Modify: `src/routes/(app)/settings/+layout.svelte:3`
- Modify: `src/routes/(app)/settings/mcp/+page.svelte:2`
- [ ] **Step 1: Migrate settings layout**
Replace import (line 3):
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<SlidersHorizontal size={16} />` → `<Icon icon="pixelarticons:sliders" width={16} />`
- `<User size={16} />` → `<Icon icon="pixelarticons:user" width={16} />`
- `<Cpu size={16} />` → `<Icon icon="pixelarticons:cpu" width={16} />`
- `<ShieldCheck size={16} />` → `<Icon icon="pixelarticons:shield" width={16} />`
- `<Info size={16} />` → `<Icon icon="pixelarticons:info-box" width={16} />`
- [ ] **Step 2: Migrate MCP settings page**
Replace import (line 2):
```typescript
import Icon from '@iconify/svelte';
```
Replace usages:
- `<Key .../>` → `<Icon icon="pixelarticons:lock" .../>`
- `<Copy .../>` → `<Icon icon="pixelarticons:copy" .../>`
- `<Trash2 .../>` → `<Icon icon="pixelarticons:trash" .../>`
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
- `<Check .../>` → `<Icon icon="pixelarticons:check" .../>`
- [ ] **Step 3: Commit**
```bash
git add src/routes/(app)/settings/+layout.svelte src/routes/(app)/settings/mcp/+page.svelte
git commit -m "feat: migrate settings pages to pixelarticons"
```
---
### Task 14: Update docs/FEATURES.md
**Files:**
- Modify: `docs/FEATURES.md:67-70,141`
- [ ] **Step 1: Rename "Pin Notes" section to "Favorite Notes"**
Replace lines 67-70:
```markdown
### Favorite Notes
- Favorite important notes to always appear at top
- Favorites section separated from other notes
- Toggle favorite from note card hover actions
```
- [ ] **Step 2: Update MCP tools description**
Line 141: replace `pin` with `favorite` in the tools list:
```markdown
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
```
- [ ] **Step 3: Commit**
```bash
git add docs/FEATURES.md
git commit -m "docs: rename pin to favorites in FEATURES.md"
```
---
### Task 15: Verification
- [ ] **Step 1: Run type check**
Run: `pnpm check`
Expected: PASS with no errors
- [ ] **Step 2: Run unit tests**
Run: `pnpm test:unit`
Expected: All tests pass (DB/CRDT tests use `pinned` field which is unchanged)
- [ ] **Step 3: Run e2e tests**
Run: `pnpm test:e2e`
Expected: All tests pass including the updated organization spec
- [ ] **Step 4: Update CLAUDE.md icons section**
Add to the "Visual rules" section in CLAUDE.md after the "Icons" line:
```markdown
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
```
- [ ] **Step 5: Commit docs update**
```bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with pixelarticons icon usage"
```
@@ -1,182 +0,0 @@
# Icon Migration: Lucide → Pixelarticons + "Pinned" → "Favorites" Rename
**Date:** 2026-03-20
**Status:** Approved
## Summary
Replace Lucide icons with pixelarticons (via `@iconify/svelte`) across the app to match the retro parchment aesthetic. Keep Lucide only for 4 text-formatting letterform icons (Bold, Italic, Strikethrough, Underline). Additionally, rename the user-facing "pinned" concept to "favorites" with a heart icon, while keeping the `pinned` database column unchanged.
## Motivation
The current Lucide icons have smooth, modern strokes that clash with the 8-bit retro parchment design system. Pixelarticons are drawn on a strict 24×24 pixel grid with no anti-aliasing — a natural fit for the `Press Start 2P` pixel font and hard-offset shadow aesthetic already in use.
## Technical Approach
### Icon Library
- **Package:** `@iconify/svelte` (v5.2.1, already installed) + `@iconify-json/pixelarticons` (v1.2.4, already installed)
- **Usage:** `<Icon icon="pixelarticons:icon-name" width={size} class="..." />`
- **Tree-shaking:** Iconify handles this automatically — only icons used in code are bundled
### Icon Mapping
56 icons migrated to pixelarticons, 4 kept as Lucide:
#### Navigation & UI
| Lucide | Pixelarticons |
|--------|--------------|
| Menu | `menu` |
| Search | `search` |
| X / XIcon | `close` |
| Plus | `plus` |
| Check | `check` |
| ArrowLeft | `arrow-left` |
| ChevronRight | `chevron-right` |
| ChevronDown | `chevron-down` |
| Settings | `settings-cog` |
| ExternalLink | `external-link` |
#### Notes & Content
| Lucide | Pixelarticons | Notes |
|--------|--------------|-------|
| StickyNote | `sticky-note` | |
| Archive | `archive` | |
| ArchiveRestore | `archive` | Context makes it clear |
| Trash2 | `trash` | |
| Bookmark | `heart` | Renamed to "favorite" |
| Clock | `clock` | |
| History | `timeline` | |
| FileText | `file-text` | |
| FileCode | `script` | |
| ImageIcon | `image` | |
| Palette | `colors-swatch` | |
| GripVertical | `drag-and-drop` | |
| Star | `pin` | Used in ImageUpload for "featured image" toggle — not related to favorites |
| SquareCheck | `checkbox-on` | |
| Type | `art-text` | |
#### Text Formatting (toolbar)
| Lucide | Pixelarticons | Notes |
|--------|--------------|-------|
| **Bold** | **Keep Lucide** | Letterform |
| **Italic** | **Keep Lucide** | Letterform |
| **Strikethrough** | **Keep Lucide** | Letterform |
| **Underline** | **Keep Lucide** | Letterform |
| Code | `braces` | |
| CodeXml | `braces` | |
| Heading | `heading` | |
| Link | `link` | |
| TextQuote | `quote-text-inline` | |
| List | `list` | |
| ListOrdered | `bulletlist` | |
| ListChecks | `checklist` | |
| AlignLeft | `text-align-left` | |
| AlignCenter | `text-align-center` | |
| AlignRight | `text-align-right` | |
| AlignJustify | `text-align-justify` | |
| Table2 | `table` | |
| Minus | `minus` | |
| RemoveFormatting | `remove-box` | |
| CornerDownLeft | `corner-down-left` | |
| BetweenHorizontalEnd | `add-row` | |
| BetweenVerticalEnd | `add-col` | |
#### Undo/Redo
| Lucide | Pixelarticons |
|--------|--------------|
| Undo2 | `undo` |
| Redo2 | `redo` |
| RotateCcw | `undo` |
| RefreshCw | `reload` |
#### Sync Status
| Lucide | Pixelarticons | Notes |
|--------|--------------|-------|
| CloudCheck | `cloud-done` | |
| CloudSync | `sync` | |
| CloudOff | `cloud` | Color conveys offline state |
| CloudAlert | `cloud` | Color conveys error state |
#### Users & Admin
| Lucide | Pixelarticons |
|--------|--------------|
| User | `user` |
| Users | `users` |
| UserPlus | `user-plus` |
| UserMinus | `user-minus` |
| Cpu | `cpu` |
| ShieldCheck | `shield` |
| SlidersHorizontal | `sliders` |
| Info | `info-box` |
| Key | `lock` |
| Copy | `copy` |
| Tag | `label` |
### "Pinned" → "Favorites" Rename
**Scope:** User-facing labels, function names, store names, MCP tool names, test IDs. The database column `pinned` stays unchanged to avoid a migration.
**Renames:**
| Current | New |
|---------|-----|
| `pinnedNotes` (store) | `favoriteNotes` |
| `unpinnedNotes` (store) | `unfavoritedNotes` |
| `togglePin()` | `toggleFavorite()` |
| `"Pinned"` label in NotesView | `"Favorites"` |
| `"Pin"` / `"Unpin"` tooltips in NoteCard | `"Favorite"` / `"Unfavorite"` |
| `pin_note` MCP tool | `favorite_note` |
| `pin-btn` / `pin-indicator` test IDs | `favorite-btn` / `favorite-indicator` |
**Not renamed (internal/DB):**
- `pinned` column in `notes` and `noteUserState` tables
- `pinned` field in TypeScript types (`Note`, `NoteUpdate`, `NoteChange`)
- `pinned` references in CRDT sync logic
- `pinned` in server-side sync overlay
## Files to Modify
### Icon migration (16 files)
1. `src/lib/components/Layout/Sidebar.svelte`
2. `src/lib/components/Layout/Header.svelte`
3. `src/lib/components/FormattingToolbar.svelte`
4. `src/lib/components/NoteCard.svelte`
5. `src/lib/components/NoteEditor.svelte`
6. `src/lib/components/NoteHistory.svelte`
7. `src/lib/components/SyncIndicator.svelte`
8. `src/lib/components/SearchBar.svelte`
9. `src/lib/components/ShareDialog.svelte`
10. `src/lib/components/Checklist.svelte`
11. `src/lib/components/CollaboratorPopover.svelte`
12. `src/lib/components/ImageLightbox.svelte`
13. `src/lib/components/ImageUpload.svelte`
14. `src/lib/components/NotesView.svelte`
15. `src/routes/(app)/settings/+layout.svelte`
16. `src/routes/(app)/settings/mcp/+page.svelte`
### Favorites rename (7 files)
1. `src/lib/stores/notes.ts` — store names + `toggleFavorite`
2. `src/lib/components/NoteCard.svelte` — tooltips, test IDs, icon
3. `src/lib/components/NotesView.svelte` — label, store imports
4. `src/lib/server/mcp/server.ts` — tool name + description
5. `docs/FEATURES.md` — update feature description
6. `tests/e2e/organization.spec.ts` — `pin-btn` test ID + `getByText('Pinned')` → `getByText('Favorites')` + scenario names/comments
7. Note: `tests/e2e/sharing.spec.ts` references `pinned` as a data field — does NOT need renaming (DB field stays `pinned`)
### Cleanup
- Remove `lucide-svelte` from `package.json` once only 4 icons remain — or keep it as a dependency for those 4. Decision: keep it, the 4 formatting icons are deeply integrated.
### Documentation
- Update `CLAUDE.md` "Icons" section to document pixelarticons usage
- Update `docs/FEATURES.md` if it mentions pinning
## Testing Strategy
- Run `pnpm check` after migration to catch type errors
- Run `pnpm test:unit` to verify sync/CRDT tests still pass (they use `pinned` field which is unchanged)
- Run `pnpm test:e2e` to verify UI still works — tests referencing `pin-btn`/`pin-indicator` will need updating
- Visual verification: icons render correctly at all sizes (12-20px)
## Rendering Note
Pixelarticons are designed for the 24×24 grid and render sharpest at multiples of 24px. At the sizes used in this app (12-20px), they will still look good because `@iconify/svelte` renders them as SVG (vector), but the pixel-art crispness is maximized at 24px. Consider using `image-rendering: pixelated` on icon SVGs if sub-24px rendering looks blurry, though SVG scaling typically handles this well.
+3 -12
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.22.3",
"version": "0.19.2",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -12,8 +12,7 @@
},
"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",
@@ -33,8 +32,6 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@iconify-json/pixelarticons": "^1.2.4",
"@iconify/svelte": "^5.2.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
@@ -57,8 +54,6 @@
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
"node-diff3": "^3.2.0",
"nodemailer": "^8.0.2",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0",
@@ -73,7 +68,6 @@
"@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",
@@ -87,9 +81,7 @@
"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 +93,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"
}
}
}
+1 -2
View File
@@ -11,8 +11,7 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
serviceWorkers: 'block'
screenshot: 'only-on-failure'
},
projects: [
{
+8 -71
View File
@@ -8,18 +8,11 @@ overrides:
serialize-javascript: '>=7.0.3'
esbuild@<0.25.0: 0.25.12
cookie: '>=0.7.0'
hono: '>=4.12.7'
importers:
.:
dependencies:
'@iconify-json/pixelarticons':
specifier: ^1.2.4
version: 1.2.4
'@iconify/svelte':
specifier: ^5.2.1
version: 5.2.1(svelte@5.53.7)
'@modelcontextprotocol/sdk':
specifier: ^1.27.1
version: 1.27.1(zod@4.3.6)
@@ -86,12 +79,6 @@ importers:
markdown-it:
specifier: ^14.1.1
version: 14.1.1
node-diff3:
specifier: ^3.2.0
version: 3.2.0
nodemailer:
specifier: ^8.0.2
version: 8.0.2
svelte-dnd-action:
specifier: ^0.9.69
version: 0.9.69(svelte@5.53.7)
@@ -129,9 +116,6 @@ importers:
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
@@ -171,15 +155,9 @@ importers:
vite-plugin-istanbul:
specifier: ^7.2.1
version: 7.2.1(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vite-plugin-pwa:
specifier: ^1.2.0
version: 1.2.0(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
workbox-window:
specifier: ^7.4.0
version: 7.4.0
yaml:
specifier: ^2.8.2
version: 2.8.2
@@ -1022,18 +1000,7 @@ packages:
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: '>=4.12.7'
'@iconify-json/pixelarticons@1.2.4':
resolution: {integrity: sha512-nADJKEI3mlxDSmJMLqGvNuvNl1TGa+VbFJIwrtj/8vUA4m15qPNiwxMyw8YxW3gVokuO9MNU462B8uK1hb4rqw==}
'@iconify/svelte@5.2.1':
resolution: {integrity: sha512-zHmsIPmnIhGd5gc95bNN5FL+GifwMZv7M2rlZEpa7IXYGFJm/XGHdWf6PWQa6OBoC+R69WyiPO9NAj5wjfjbow==}
peerDependencies:
svelte: '>5.0.0'
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
hono: ^4
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
@@ -1686,9 +1653,6 @@ packages:
'@types/node@25.3.5':
resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -2529,8 +2493,8 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hono@4.12.7:
resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
hono@4.12.5:
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
engines: {node: '>=16.9.0'}
html-escaper@2.0.2:
@@ -3030,10 +2994,6 @@ packages:
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
engines: {node: ^18 || ^20 || >= 21}
node-diff3@3.2.0:
resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==}
engines: {bun: '>=1.3.0', node: '>=18'}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
@@ -3045,10 +3005,6 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
nodemailer@8.0.2:
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
engines: {node: '>=6.0.0'}
nyc@18.0.0:
resolution: {integrity: sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==}
engines: {node: 20 || >=22}
@@ -4864,20 +4820,9 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@hono/node-server@1.19.11(hono@4.12.7)':
'@hono/node-server@1.19.11(hono@4.12.5)':
dependencies:
hono: 4.12.7
'@iconify-json/pixelarticons@1.2.4':
dependencies:
'@iconify/types': 2.0.0
'@iconify/svelte@5.2.1(svelte@5.53.7)':
dependencies:
'@iconify/types': 2.0.0
svelte: 5.53.7
'@iconify/types@2.0.0': {}
hono: 4.12.5
'@isaacs/cliui@8.0.2':
dependencies:
@@ -4926,7 +4871,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.11(hono@4.12.7)
'@hono/node-server': 1.19.11(hono@4.12.5)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
@@ -4936,7 +4881,7 @@ snapshots:
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.0(express@5.2.1)
hono: 4.12.7
hono: 4.12.5
jose: 6.2.0
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
@@ -5490,10 +5435,6 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 25.3.5
'@types/resolve@1.20.2': {}
'@types/trusted-types@2.0.7': {}
@@ -6378,7 +6319,7 @@ snapshots:
dependencies:
function-bind: 1.1.2
hono@4.12.7: {}
hono@4.12.5: {}
html-escaper@2.0.2: {}
@@ -6821,8 +6762,6 @@ snapshots:
node-addon-api@8.6.0: {}
node-diff3@3.2.0: {}
node-gyp-build@4.8.4: {}
node-preload@0.2.1:
@@ -6831,8 +6770,6 @@ snapshots:
node-releases@2.0.36: {}
nodemailer@8.0.2: {}
nyc@18.0.0:
dependencies:
'@istanbuljs/load-nyc-config': 1.1.0
-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`);
-3
View File
@@ -1,6 +1,3 @@
/// <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 {
+1
View File
@@ -11,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>
+13 -10
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import XIcon from 'lucide-svelte/icons/x';
import Plus from 'lucide-svelte/icons/plus';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
@@ -118,9 +121,9 @@
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
<div class="group flex items-center gap-2 py-1" 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">
<Icon icon="pixelarticons:drag-and-drop" class="h-4 w-4 text-[var(--text-muted)]" />
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
<input
type="checkbox"
@@ -134,7 +137,7 @@
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"
/>
@@ -144,7 +147,7 @@
aria-label="Remove item"
data-testid="checklist-remove"
>
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
@@ -152,10 +155,10 @@
<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"
>
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
<Plus class="h-4 w-4" />
Add item
</button>
@@ -166,14 +169,14 @@
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-toggle-done"
>
<Icon icon="pixelarticons:chevron-right" class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
{doneCount} done
</button>
{#if doneExpanded}
<div class="mt-1 space-y-0.5 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 py-1">
<input
type="checkbox"
checked={item.checked}
@@ -188,7 +191,7 @@
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
import Users from 'lucide-svelte/icons/users';
interface Props {
collaborators: Collaborator[];
@@ -42,7 +42,7 @@
onclick={handleClick}
data-testid="collaborator-indicator"
>
<Icon icon="pixelarticons:users" class="h-4 w-4" />
<Users class="h-4 w-4" />
</button>
{#if showPopover}
+58 -36
View File
@@ -1,12 +1,34 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import {
Undo2,
Redo2,
Heading,
List,
ListOrdered,
ListChecks,
TextQuote,
CodeXml,
Bold,
Italic,
Strikethrough,
Underline
Code,
Underline,
Link,
CornerDownLeft,
ExternalLink,
Trash2,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Minus,
ChevronDown,
Table2,
BetweenHorizontalEnd,
BetweenVerticalEnd,
RemoveFormatting
} from 'lucide-svelte';
import Icon from '@iconify/svelte';
interface Props {
tick?: number;
@@ -106,7 +128,7 @@
title="Undo"
data-testid="format-undo"
>
<Icon icon="pixelarticons:undo" width={iconSize} />
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
@@ -115,7 +137,7 @@
title="Redo"
data-testid="format-redo"
>
<Icon icon="pixelarticons:redo" width={iconSize} />
<Redo2 size={iconSize} />
</button>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
@@ -164,8 +186,8 @@
title="Heading"
data-testid="format-heading"
>
<Icon icon="pixelarticons:heading" width={iconSize} />
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
<Heading size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'heading'}
<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">
@@ -205,8 +227,8 @@
title="Lists"
data-testid="format-list"
>
<Icon icon="pixelarticons:list" width={iconSize} />
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
<List size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'list'}
<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">
@@ -215,7 +237,7 @@
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<Icon icon="pixelarticons:list" width={iconSize} />
<List size={iconSize} />
<span>Bullet list</span>
</button>
<button
@@ -223,7 +245,7 @@
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<Icon icon="pixelarticons:bulletlist" width={iconSize} />
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
@@ -231,7 +253,7 @@
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<Icon icon="pixelarticons:checklist" width={iconSize} />
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
@@ -246,7 +268,7 @@
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
<Icon icon="pixelarticons:link" width={iconSize} />
<Link size={iconSize} />
</button>
{#if openDropdown === 'link'}
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
@@ -268,7 +290,7 @@
title="Apply link"
data-testid="format-link-apply"
>
<Icon icon="pixelarticons:corner-down-left" width={16} />
<CornerDownLeft size={16} />
</button>
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
<button
@@ -279,7 +301,7 @@
title="Open link"
data-testid="format-link-open"
>
<Icon icon="pixelarticons:external-link" width={16} />
<ExternalLink size={16} />
</button>
<button
type="button"
@@ -289,7 +311,7 @@
title="Remove link"
data-testid="format-unlink"
>
<Icon icon="pixelarticons:trash" width={16} />
<Trash2 size={16} />
</button>
</form>
</div>
@@ -304,8 +326,8 @@
title="Table"
data-testid="format-table"
>
<Icon icon="pixelarticons:table" width={iconSize} />
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
<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">
@@ -314,7 +336,7 @@
class={dropdownItemClass()}
data-testid="format-table-insert"
>
<Icon icon="pixelarticons:table" width={iconSize} />
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
{#if isActive('table')}
@@ -324,7 +346,7 @@
class={dropdownItemClass()}
data-testid="format-table-add-row"
>
<Icon icon="pixelarticons:add-row" width={iconSize} />
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
@@ -332,7 +354,7 @@
class={dropdownItemClass()}
data-testid="format-table-add-col"
>
<Icon icon="pixelarticons:add-col" width={iconSize} />
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
@@ -341,7 +363,7 @@
class={dropdownItemClass()}
data-testid="format-table-delete-row"
>
<Icon icon="pixelarticons:minus" width={iconSize} />
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
@@ -349,7 +371,7 @@
class={dropdownItemClass()}
data-testid="format-table-delete-col"
>
<Icon icon="pixelarticons:minus" width={iconSize} />
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
@@ -357,7 +379,7 @@
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-table-delete"
>
<Icon icon="pixelarticons:remove-box" width={iconSize} />
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
@@ -374,15 +396,15 @@
data-testid="format-align"
>
{#if isActive({ textAlign: 'center' })}
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
<AlignCenter size={iconSize} />
{:else if isActive({ textAlign: 'right' })}
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
<AlignRight size={iconSize} />
{:else if isActive({ textAlign: 'justify' })}
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
<AlignJustify size={iconSize} />
{:else}
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
<AlignLeft size={iconSize} />
{/if}
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
@@ -391,7 +413,7 @@
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
<AlignLeft size={iconSize} />
<span>Left</span>
</button>
<button
@@ -399,7 +421,7 @@
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
<AlignCenter size={iconSize} />
<span>Center</span>
</button>
<button
@@ -407,7 +429,7 @@
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
<AlignRight size={iconSize} />
<span>Right</span>
</button>
<button
@@ -415,7 +437,7 @@
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
<AlignJustify size={iconSize} />
<span>Justify</span>
</button>
</div>
@@ -431,7 +453,7 @@
title="Inline code"
data-testid="format-code"
>
<Icon icon="pixelarticons:braces" width={iconSize} />
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
@@ -439,7 +461,7 @@
title="Blockquote"
data-testid="format-blockquote"
>
<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
@@ -447,7 +469,7 @@
title="Code block"
data-testid="format-code-block"
>
<Icon icon="pixelarticons:braces" width={iconSize} />
<CodeXml size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
@@ -455,6 +477,6 @@
title="Divider"
data-testid="format-hr"
>
<Icon icon="pixelarticons:minus" width={iconSize} />
<Minus size={iconSize} />
</button>
</div>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import XIcon from 'lucide-svelte/icons/x';
interface Props {
src: string;
@@ -46,7 +46,7 @@
aria-label="Close lightbox"
data-testid="lightbox-close"
>
<Icon icon="pixelarticons:close" class="h-6 w-6" />
<XIcon class="h-6 w-6" />
</button>
<img
+6 -4
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import Icon from '@iconify/svelte';
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';
@@ -131,7 +133,7 @@
</button>
{#if isPending}
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
<Icon icon="pixelarticons:reload" class="h-3 w-3 text-white" />
<RefreshCw class="h-3 w-3 text-white" />
</div>
{:else if onToggleFeatured}
<button
@@ -140,7 +142,7 @@
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
data-testid="toggle-featured"
>
<Icon icon="pixelarticons:pin" class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
</button>
{/if}
<button
@@ -149,7 +151,7 @@
aria-label="Remove attachment"
data-testid="remove-attachment"
>
<Icon icon="pixelarticons:close" class="h-3 w-3" />
<XIcon class="h-3 w-3" />
</button>
</div>
{/each}
+4 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import SearchBar from '../SearchBar.svelte';
import SyncIndicator from '../SyncIndicator.svelte';
import Icon from '@iconify/svelte';
import Menu from 'lucide-svelte/icons/menu';
import Search from 'lucide-svelte/icons/search';
interface Props {
onMenuToggle: () => void;
@@ -23,7 +24,7 @@
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
aria-label="Toggle sidebar"
>
<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />
<Menu class="h-6 w-6 text-[var(--text)]" />
</button>
<div class="flex items-center gap-2">
@@ -42,7 +43,7 @@
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
aria-label="Search"
>
<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />
<Search class="h-5 w-5 text-[var(--text)]" />
</button>
<div class="flex items-center gap-2">
+7 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { allTags } from '$lib/stores/notes.js';
import Icon from '@iconify/svelte';
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
interface Props {
open: boolean;
@@ -17,9 +17,9 @@
}
const navItems = [
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
];
</script>
@@ -44,7 +44,7 @@
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)]'}"
>
<Icon icon={item.icon} width={20} />
<item.icon size={20} />
{item.label}
</a>
</li>
@@ -62,7 +62,7 @@
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)]'}"
>
<Icon icon="pixelarticons:label" width={16} />
<Tag size={16} />
#{tag}
</a>
</li>
@@ -78,7 +78,7 @@
onclick={closeMobile}
data-testid="settings-link"
>
<Icon icon="pixelarticons:settings-cog" width={20} />
<Settings size={20} />
Settings
</a>
</div>
+40 -21
View File
@@ -1,11 +1,16 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import ImageLightbox from './ImageLightbox.svelte';
import CollaboratorPopover from './CollaboratorPopover.svelte';
import type { Note } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
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;
@@ -53,6 +58,20 @@
};
}
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 -->
@@ -104,12 +123,12 @@
{/if}
{#if note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unfavorite"
data-testid="favorite-indicator"
title="Unpin"
data-testid="pin-indicator"
>
<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
</div>
@@ -119,17 +138,17 @@
{/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}
@@ -151,7 +170,7 @@
title="Restore"
data-testid="restore-btn"
>
<Icon icon="pixelarticons:undo" class="h-4 w-4" />
<Undo2 class="h-4 w-4" />
</button>
<button
onclick={stop(() => deleteNote(note.id))}
@@ -159,17 +178,17 @@
title="Delete forever"
data-testid="delete-forever-btn"
>
<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
</button>
{:else}
{#if !note.pinned}
<button
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Favorite"
data-testid="favorite-btn"
title="Pin"
data-testid="pin-btn"
>
<Icon icon="pixelarticons:heart" class="h-4 w-4" />
<Bookmark class="h-4 w-4" />
</button>
{/if}
{#if $currentFilter === 'archived'}
@@ -179,7 +198,7 @@
title="Unarchive"
data-testid="unarchive-btn"
>
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
<ArchiveRestore class="h-4 w-4" />
</button>
{:else}
<button
@@ -188,7 +207,7 @@
title="Archive"
data-testid="archive-btn"
>
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
<Archive class="h-4 w-4" />
</button>
{/if}
{#if note.isShared && !note.isOwner}
@@ -198,7 +217,7 @@
title="Leave note"
data-testid="leave-btn"
>
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
<UserMinus class="h-4 w-4" />
</button>
{:else}
<button
@@ -207,7 +226,7 @@
title="Delete"
data-testid="trash-btn"
>
<Icon icon="pixelarticons:trash" class="h-4 w-4" />
<Trash2 class="h-4 w-4" />
</button>
{/if}
{/if}
+32 -92
View File
@@ -1,19 +1,24 @@
<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 { NOTE_COLORS } from '$lib/utils/colors.js';
import { getPreferences } from '$lib/stores/preferences.svelte.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
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';
interface Props {
note: Note | null;
@@ -49,17 +54,7 @@
// 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 ?? []);
const isOwner = $derived(note?.isOwner !== false);
@@ -89,37 +84,6 @@
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)) {
@@ -172,19 +136,19 @@
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;
// Track whether mousedown started on the overlay (not inside the editor).
// Prevents closing when the user selects text inside the editor and
// releases the mouse outside — the click event fires on the overlay,
// but we only want to close if the press also started there.
let mousedownOnOverlay = false;
function handleOverlayPointerdown(e: PointerEvent) {
pointerdownOnOverlay = e.target === e.currentTarget;
function handleOverlayMousedown(e: MouseEvent) {
mousedownOnOverlay = e.target === e.currentTarget;
}
function handleOverlayPointerup(e: PointerEvent) {
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
pointerdownOnOverlay = false;
function handleOverlayClick() {
if (mousedownOnOverlay) saveAndClose();
mousedownOnOverlay = false;
}
async function saveAndClose() {
@@ -248,8 +212,8 @@
<!-- 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}
onmousedown={handleOverlayMousedown}
onclick={handleOverlayClick}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
@@ -257,6 +221,7 @@
<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]"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
@@ -318,16 +283,11 @@
<!-- 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">
@@ -337,7 +297,7 @@
title="Background color"
data-testid="color-picker-toggle"
>
<Icon icon="pixelarticons:colors-swatch" class="h-5 w-5 text-[var(--text-muted)]" />
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
</button>
{#if showColorPicker}
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
@@ -354,9 +314,9 @@
data-testid="checklist-toggle"
>
{#if checklistMode}
<Icon icon="pixelarticons:art-text" class="h-5 w-5" />
<Type class="h-5 w-5" />
{:else}
<Icon icon="pixelarticons:checkbox-on" class="h-5 w-5" />
<SquareCheck class="h-5 w-5" />
{/if}
</button>
@@ -367,7 +327,7 @@
title="Image attachments"
data-testid="image-toggle"
>
<Icon icon="pixelarticons:image" class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</button>
<!-- Raw markdown mode toggle -->
@@ -379,9 +339,9 @@
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
<Icon icon="pixelarticons:file-text" class="h-5 w-5" />
<FileText class="h-5 w-5" />
{:else}
<Icon icon="pixelarticons:script" class="h-5 w-5" />
<FileCode class="h-5 w-5" />
{/if}
</button>
@@ -394,28 +354,16 @@
data-testid="share-toggle"
>
{#if isShared}
<Icon icon="pixelarticons:users" class="h-5 w-5 text-[var(--primary)]" />
<Users class="h-5 w-5 text-[var(--primary)]" />
{:else}
<Icon icon="pixelarticons:user-plus" class="h-5 w-5" />
<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">
<Icon icon="pixelarticons:users" class="h-5 w-5" />
<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"
>
<Icon icon="pixelarticons:timeline" class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
</button>
{/if}
</div>
<button
@@ -438,11 +386,3 @@
onUpdate={handleCollaboratorsUpdate}
/>
{/if}
{#if showHistory && noteId}
<NoteHistory
noteId={noteId}
onClose={() => (showHistory = false)}
onRestored={handleHistoryRestored}
/>
{/if}
-222
View File
@@ -1,222 +0,0 @@
<script lang="ts">
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
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">
<Icon icon="pixelarticons: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"
>
<Icon icon="pixelarticons:close" 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"
>
<Icon icon="pixelarticons:undo" 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"
>
<Icon icon="pixelarticons:undo" 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>
+9 -9
View File
@@ -2,10 +2,10 @@
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
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 Icon from '@iconify/svelte';
import Plus from 'lucide-svelte/icons/plus';
interface Props {
filter: NoteFilter;
@@ -60,7 +60,7 @@
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"
>
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
<Plus class="h-4 w-4" />
Add a crumb...
</button>
</div>
@@ -70,7 +70,7 @@
aria-label="Add a crumb"
data-testid="new-note-fab"
>
<Icon icon="pixelarticons:plus" class="h-5 w-5" />
<Plus class="h-5 w-5" />
</button>
{/if}
@@ -86,22 +86,22 @@
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
{/if}
{#if $favoriteNotes.length > 0}
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
</div>
{/if}
<NoteGrid
notes={$unfavoritedNotes}
label={$favoriteNotes.length > 0 ? 'Others' : ''}
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable
dndType="unpinned-notes"
onReorder={handleReorder}
/>
{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}
{#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">
+6 -4
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
interface Props {
onClose?: () => void;
@@ -57,10 +59,10 @@
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
{#if onClose}
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
<Icon icon="pixelarticons:arrow-left" class="h-5 w-5" />
<ArrowLeft class="h-5 w-5" />
</button>
{:else}
<Icon icon="pixelarticons:search" class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
<Search class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
{/if}
<input
bind:this={inputEl}
@@ -73,7 +75,7 @@
/>
{#if query}
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
<Icon icon="pixelarticons:close" class="h-5 w-5" />
<X class="h-5 w-5" />
</button>
{/if}
</div>
+6 -4
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Icon from '@iconify/svelte';
import X from 'lucide-svelte/icons/x';
import UserMinus from 'lucide-svelte/icons/user-minus';
import Search from 'lucide-svelte/icons/search';
import { showToast } from '$lib/stores/toast.js';
interface UserResult {
@@ -117,14 +119,14 @@
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close"
>
<Icon icon="pixelarticons:close" class="h-4 w-4" />
<X class="h-4 w-4" />
</button>
</div>
<!-- Search input -->
<div class="relative px-4 pt-3">
<div class="relative">
<Icon icon="pixelarticons:search" class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
<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..."
@@ -179,7 +181,7 @@
title="Remove"
data-testid="remove-collaborator-btn"
>
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
<UserMinus class="h-4 w-4" />
</button>
</li>
{/each}
+8 -5
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
import Icon from '@iconify/svelte';
import CloudCheck from 'lucide-svelte/icons/cloud-check';
import CloudSync from 'lucide-svelte/icons/cloud-sync';
import CloudOff from 'lucide-svelte/icons/cloud-off';
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
let status: SyncStatus = $state(getSyncStatus());
let displayStatus: SyncStatus = $state(getSyncStatus());
@@ -48,12 +51,12 @@
data-testid="sync-indicator"
>
{#if displayStatus === 'synced'}
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
<CloudSync class="h-5 w-5 text-[var(--primary)] opacity-60" />
{:else if displayStatus === 'offline'}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
<CloudOff class="h-5 w-5 text-[var(--text-muted)]" />
{:else}
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
{/if}
</button>
-6
View File
@@ -228,12 +228,6 @@ export function getUser(userId: number): User | 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
-14
View File
@@ -123,18 +123,6 @@ sqlite.exec(`
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
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
@@ -208,8 +196,6 @@ sqlite.exec(`
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),
-20
View File
@@ -189,23 +189,3 @@ export const userPreferences = sqliteTable(
},
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
);
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)
]
);
-13
View File
@@ -136,19 +136,6 @@ export function createTestDb(options?: { seedUser?: boolean }) {
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
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 });
-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;');
}
+3 -3
View File
@@ -239,8 +239,8 @@ export function createMcpServer(userId: number): McpServer {
});
server.tool(
'favorite_note',
'Favorite or unfavorite a note',
'pin_note',
'Pin or unpin a note',
{
id: z.string().describe('Note ID'),
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
@@ -257,7 +257,7 @@ export function createMcpServer(userId: number): McpServer {
content: [
{
type: 'text' as const,
text: `Note "${result.title}" ${pinned ? 'favorited' : 'unfavorited'}`
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
}
]
};
+1 -26
View File
@@ -1,6 +1,6 @@
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 { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Db } from './db/index.js';
import {
@@ -233,31 +233,6 @@ describe('updateNote', () => {
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' });
+3 -49
View File
@@ -1,14 +1,12 @@
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 { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql } 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 { 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 {
@@ -206,7 +204,6 @@ export interface UpdateNoteInput {
trashed?: boolean;
checklistMode?: boolean;
sortOrder?: number;
baseVersion?: number;
}
/** Per-user fields that go to noteUserState for collaborators */
@@ -234,33 +231,7 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
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.content !== undefined) { 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; }
@@ -322,23 +293,6 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
}
}
// 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);
}
-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');
});
});
-118
View File
@@ -1,118 +0,0 @@
import type { Db } from './db/index.js';
import { noteVersions, notes } from './db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
const MAX_VERSIONS_PER_NOTE = 50;
const CONTENT_PREVIEW_LENGTH = 80;
/**
* Create a snapshot of a note's current state.
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
*/
export function createSnapshot(
db: Db,
noteId: string,
snapshot: {
version: number;
title: string;
content: string;
checklistMode: boolean;
color: string;
}
): void {
const now = new Date();
db.insert(noteVersions)
.values({
id: uuidv4(),
noteId,
version: snapshot.version,
title: snapshot.title,
content: snapshot.content,
checklistMode: snapshot.checklistMode,
color: snapshot.color,
createdAt: now
})
.onConflictDoNothing()
.run();
// Prune: keep only the most recent MAX_VERSIONS_PER_NOTE snapshots
const allVersions = db
.select({ id: noteVersions.id })
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.all();
if (allVersions.length > MAX_VERSIONS_PER_NOTE) {
const toDelete = allVersions.slice(MAX_VERSIONS_PER_NOTE).map((v) => v.id);
for (const id of toDelete) {
db.delete(noteVersions).where(eq(noteVersions.id, id)).run();
}
}
}
/**
* List version summaries for a note, newest first.
*/
export function listVersions(db: Db, noteId: string): NoteVersionSummary[] {
const rows = db
.select()
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.all();
return rows.map((row) => ({
id: row.id,
noteId: row.noteId,
version: row.version,
title: row.title,
contentPreview: row.content.slice(0, CONTENT_PREVIEW_LENGTH),
createdAt: row.createdAt
}));
}
/**
* Get the full content of a specific version.
*/
export function getVersion(db: Db, noteId: string, versionId: string): NoteVersion | null {
const row = db
.select()
.from(noteVersions)
.where(and(eq(noteVersions.id, versionId), eq(noteVersions.noteId, noteId)))
.get();
if (!row) return null;
return {
id: row.id,
noteId: row.noteId,
version: row.version,
title: row.title,
content: row.content,
checklistMode: row.checklistMode,
color: row.color as NoteColor,
createdAt: row.createdAt
};
}
/**
* Snapshot the current state of a note (used before a restore).
*/
export function snapshotCurrentNote(db: Db, noteId: string): boolean {
const currentNote = db.select().from(notes).where(eq(notes.id, noteId)).get();
if (!currentNote) return false;
createSnapshot(db, noteId, {
version: currentNote.version,
title: currentNote.title,
content: currentNote.content,
checklistMode: currentNote.checklistMode,
color: currentNote.color
});
return true;
}
+5 -13
View File
@@ -35,11 +35,11 @@ function sortNotes(list: Note[]): Note[] {
});
}
export const favoriteNotes = derived(filteredNotes, ($notes) =>
export const pinnedNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => n.pinned))
);
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => !n.pinned))
);
@@ -142,19 +142,11 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
}
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
// Read current version for 3-way merge base on the server
let baseVersion: number | undefined;
notes.update((list) => {
const current = list.find((n) => n.id === id);
if (current) baseVersion = current.version;
return list;
});
try {
const res = await fetch(`/api/notes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...updates, baseVersion })
body: JSON.stringify(updates)
});
if (res.ok) {
const updated: Note = await res.json();
@@ -181,7 +173,7 @@ export async function updateNote(id: string, updates: NoteUpdate): Promise<Note
);
if (optimistic) {
await putNote(optimistic);
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now(), baseVersion });
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
showToast('Saved offline — will sync when reconnected', 'info');
}
return optimistic;
@@ -231,7 +223,7 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
return updateNote(id, { archived: false });
}
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
return updateNote(id, { pinned: !currentPinned });
}
+1 -7
View File
@@ -7,18 +7,12 @@ describe('preferences defaults', () => {
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open',
notifyOnShare: true,
notifyOnCollabRemoved: true,
notifyOnNoteDeleted: true
sidebarDefaultState: 'open'
});
});
it('should identify boolean preference keys', () => {
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('notifyOnCollabRemoved')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('notifyOnNoteDeleted')).toBe(true);
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
+1 -17
View File
@@ -100,26 +100,10 @@ async function pullChanges(): Promise<Note[]> {
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
// Build pending-fields map from sync queue to protect unpushed local changes
let pendingFieldsMap = new Map<string, Set<string>>();
try {
const queue = await getSyncQueue();
for (const item of queue) {
if (item.data) {
const fields = pendingFieldsMap.get(item.noteId) || new Set<string>();
Object.keys(item.data).forEach((k) => fields.add(k));
pendingFieldsMap.set(item.noteId, fields);
}
}
} catch {
// Queue unavailable — merge without protection
}
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const pendingFields = pendingFieldsMap.get(remote.id);
const merged = mergeNotes(local, remote, pendingFields);
const merged = mergeNotes(local, remote);
await putNote(merged);
} else {
await putNote(remote);
+14 -24
View File
@@ -1,39 +1,29 @@
import type { Note } from '$lib/types/index.js';
/** Fields that can be individually merged between local and remote notes */
const MUTABLE_FIELDS = ['title', 'content', 'color', 'pinned', 'archived', 'trashed', 'checklistMode', 'sortOrder'] as const;
/**
* Last-Write-Wins (LWW) merge strategy with pending-field protection.
*
* When remote is newer, takes remote values for all fields EXCEPT those
* listed in `pendingFields` — these represent locally-queued changes that
* haven't been pushed yet and must be preserved.
* Last-Write-Wins (LWW) merge strategy per field.
* For a single-user app with multiple devices, this is sufficient.
* Each field is resolved by comparing updatedAt timestamps.
*/
export function mergeNotes(local: Note, remote: Note, pendingFields?: Set<string>): Note {
export function mergeNotes(local: Note, remote: Note): Note {
// If remote is newer overall, take remote
const localTime = new Date(local.updatedAt).getTime();
const remoteTime = new Date(remote.updatedAt).getTime();
const remoteWins = remoteTime > localTime ||
(remoteTime === localTime && remote.version > local.version);
if (!remoteWins) {
return { ...local };
}
// Remote wins — take remote as base, but preserve pending local fields
if (!pendingFields || pendingFields.size === 0) {
if (remoteTime > localTime) {
return { ...remote };
}
const merged = { ...remote };
for (const field of MUTABLE_FIELDS) {
if (pendingFields.has(field)) {
(merged as unknown as Record<string, unknown>)[field] = (local as unknown as Record<string, unknown>)[field];
}
if (localTime > remoteTime) {
return { ...local };
}
return merged;
// Same timestamp - prefer higher version number
if (remote.version > local.version) {
return { ...remote };
}
return { ...local };
}
/**
-1
View File
@@ -48,7 +48,6 @@ export interface SyncQueueItem {
operation: 'create' | 'update' | 'delete';
data?: Partial<Note>;
timestamp: number;
baseVersion?: number;
}
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
+4 -52
View File
@@ -224,69 +224,21 @@ describe('processSyncPush — shared notes', () => {
expect(note.trashed).toBe(false);
});
it('should apply field changes even when timestamp is older (no timestamp gate)', async () => {
it('should reject changes with older timestamp than server note', async () => {
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t2, createdAt: new Date('2024-01-01T00:00:00Z') });
// Collaborator's change has a timestamp BEFORE the server's updatedAt.
// With field-level sync, the title change should still be applied because
// the sync data only contains fields the client explicitly modified.
// Collaborator's change has a timestamp BEFORE the server's updatedAt
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1,
data: { title: 'Offline edit' }
data: { title: 'Stale edit' }
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Offline edit');
// updatedAt should be max of change timestamp and server timestamp
expect(note.updatedAt.getTime()).toBe(t2.getTime());
});
it('should merge checklist content when collaborator pushes concurrent content change', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: t1,
createdAt: t1,
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true
});
// Simulate owner editing content on server (checking Bread)
const t2 = new Date('2024-01-01T12:00:00Z');
db.update(notes)
.set({ content: '- [ ] Milk\n- [x] Bread', updatedAt: t2, version: 2 })
.where(eq(notes.id, 'n1'))
.run();
// Create a version snapshot for the base content (version 1)
const { noteVersions } = await import('../server/db/schema.js');
db.insert(noteVersions).values({
id: 'snap-1',
noteId: 'n1',
version: 1,
title: 'Shared Note',
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true,
color: 'default',
createdAt: t1
}).run();
// Collaborator was offline since version 1, checked Milk
const t3 = new Date('2024-01-01T13:00:00Z').getTime();
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t3,
data: { content: '- [x] Milk\n- [ ] Bread' },
baseVersion: 1
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
// Both changes should be merged: Milk checked by collab, Bread checked by owner
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
expect(note.title).toBe('Shared Note'); // Unchanged
});
it('should not allow non-collaborator to update the note', async () => {
+50 -88
View File
@@ -1,37 +1,10 @@
import type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import { canAccessNote } from '$lib/server/api-utils.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import type { SyncQueueItem } from './idb.js';
/**
* Find the base content for 3-way merge from version snapshots.
* Returns the content from the snapshot at or before the given version,
* or the current note content if no snapshot is found.
*/
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
if (baseVersion !== undefined) {
const snapshot = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (snapshot) return snapshot.content;
}
// Fallback: get the most recent snapshot before the current version
const latest = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return latest?.content ?? null;
}
/**
* Process incoming sync changes from client.
*/
@@ -58,70 +31,59 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
if (!isOwner && !isCollaborator) continue;
// Apply field-level updates unconditionally (no timestamp gate).
// Each sync change only contains the fields the client explicitly modified,
// so applying them won't overwrite other users' changes to different fields.
const sharedData: Record<string, unknown> = {
updatedAt: new Date(Math.max(change.timestamp, note.updatedAt.getTime())),
version: note.version + 1
};
if (change.data.title !== undefined) sharedData.title = change.data.title;
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
if (change.timestamp > note.updatedAt.getTime()) {
// Shared fields: title, content, color, checklistMode
const sharedData: Record<string, unknown> = {
updatedAt: new Date(change.timestamp),
version: note.version + 1
};
if (change.data.title !== undefined) sharedData.title = change.data.title;
if (change.data.content !== undefined) sharedData.content = change.data.content;
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
// Content: 3-way merge to preserve concurrent edits from other users
if (change.data.content !== undefined && change.data.content !== note.content) {
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
if (baseContent !== null && baseContent !== note.content) {
// 3-way merge: base, incoming (local), server current (remote)
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
} else {
// No base or base matches server — take incoming content
sharedData.content = change.data.content;
if (isOwner) {
// Owner: per-user fields go to notes table
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
if (change.data.trashed !== undefined) {
sharedData.trashed = change.data.trashed;
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
}
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
}
}
if (isOwner) {
// Owner: per-user fields go to notes table
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
if (change.data.trashed !== undefined) {
sharedData.trashed = change.data.trashed;
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
}
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
}
tx.update(notes)
.set(sharedData)
.where(eq(notes.id, change.noteId))
.run();
tx.update(notes)
.set(sharedData)
.where(eq(notes.id, change.noteId))
.run();
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUser = change.data.pinned !== undefined ||
change.data.archived !== undefined ||
change.data.sortOrder !== undefined;
if (hasPerUser) {
const existing = tx.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.get();
if (existing) {
const updates: Record<string, unknown> = {};
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
if (change.data.archived !== undefined) updates.archived = change.data.archived;
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
tx.update(noteUserState).set(updates)
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUser = change.data.pinned !== undefined ||
change.data.archived !== undefined ||
change.data.sortOrder !== undefined;
if (hasPerUser) {
const existing = tx.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.run();
} else {
tx.insert(noteUserState).values({
noteId: change.noteId,
userId,
pinned: change.data.pinned ?? false,
archived: change.data.archived ?? false,
sortOrder: change.data.sortOrder ?? 0
}).run();
.get();
if (existing) {
const updates: Record<string, unknown> = {};
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
if (change.data.archived !== undefined) updates.archived = change.data.archived;
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
tx.update(noteUserState).set(updates)
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
.run();
} else {
tx.insert(noteUserState).values({
noteId: change.noteId,
userId,
pinned: change.data.pinned ?? false,
archived: change.data.archived ?? false,
sortOrder: change.data.sortOrder ?? 0
}).run();
}
}
}
}
-65
View File
@@ -55,71 +55,6 @@ describe('mergeNotes (LWW)', () => {
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
it('should preserve pending local fields when remote is newer', () => {
const local = makeNote({
title: 'Local title',
content: 'Local content',
color: 'coral',
updatedAt: new Date('2024-01-01'),
version: 1
});
const remote = makeNote({
title: 'Remote title',
content: 'Remote content',
color: 'default',
updatedAt: new Date('2024-01-02'),
version: 2
});
// Content has pending local changes; title and color do not
const pendingFields = new Set(['content']);
const result = mergeNotes(local, remote, pendingFields);
// Remote wins overall, but content is preserved from local
expect(result.title).toBe('Remote title');
expect(result.content).toBe('Local content');
expect(result.color).toBe('default'); // remote wins for non-pending field
expect(result.version).toBe(2); // metadata from remote
});
it('should take remote values for non-pending fields when remote is newer', () => {
const local = makeNote({
pinned: true,
archived: true,
updatedAt: new Date('2024-01-01')
});
const remote = makeNote({
pinned: false,
archived: false,
updatedAt: new Date('2024-01-02')
});
const pendingFields = new Set(['pinned']);
const result = mergeNotes(local, remote, pendingFields);
expect(result.pinned).toBe(true); // pending — preserved
expect(result.archived).toBe(false); // not pending — remote wins
});
it('should ignore pendingFields when local is newer', () => {
const local = makeNote({
title: 'Local',
updatedAt: new Date('2024-01-02'),
version: 2
});
const remote = makeNote({
title: 'Remote',
updatedAt: new Date('2024-01-01'),
version: 1
});
const pendingFields = new Set(['title']);
const result = mergeNotes(local, remote, pendingFields);
// Local wins entirely since it's newer
expect(result.title).toBe('Local');
});
});
describe('hasChanges', () => {
-20
View File
@@ -98,25 +98,5 @@ export type NoteFilter = 'all' | 'archived' | 'trashed';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
export interface NoteVersionSummary {
id: string;
noteId: string;
version: number;
title: string;
contentPreview: string;
createdAt: Date;
}
export interface NoteVersion {
id: string;
noteId: string;
version: number;
title: string;
content: string;
checklistMode: boolean;
color: NoteColor;
createdAt: Date;
}
export type { UserPreferences } from './preferences.js';
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
+2 -11
View File
@@ -5,24 +5,15 @@ export interface UserPreferences {
defaultNoteColor: NoteColor;
hideFooter: boolean;
sidebarDefaultState: 'open' | 'collapsed';
notifyOnShare: boolean;
notifyOnCollabRemoved: boolean;
notifyOnNoteDeleted: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open',
notifyOnShare: true,
notifyOnCollabRemoved: true,
notifyOnNoteDeleted: true
sidebarDefaultState: 'open'
};
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
'hideFooter',
'notifyOnShare',
'notifyOnCollabRemoved',
'notifyOnNoteDeleted'
'hideFooter'
]);
-105
View File
@@ -1,105 +0,0 @@
import { describe, it, expect } from 'vitest';
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
describe('mergeContent (3-way)', () => {
it('should return remote when local is unchanged', () => {
const base = '- [ ] Milk\n- [ ] Bread';
const local = '- [ ] Milk\n- [ ] Bread';
const remote = '- [x] Milk\n- [ ] Bread';
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
});
it('should return local when remote is unchanged', () => {
const base = '- [ ] Milk\n- [ ] Bread';
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread';
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
});
it('should merge when User A checks an item and User B adds an item', () => {
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter'; // User B adds Butter
const result = mergeContent(base, local, remote);
expect(result).toBe('- [x] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter');
});
it('should merge when both users check different items', () => {
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
const remote = '- [ ] Milk\n- [x] Bread\n- [ ] Eggs'; // User B checks Bread
const result = mergeContent(base, local, remote);
expect(result).toBe('- [x] Milk\n- [x] Bread\n- [ ] Eggs');
});
it('should LWW (prefer remote) when both users edit the same line', () => {
const base = '- [ ] Milk\n- [ ] Bread';
const local = '- [ ] Whole Milk\n- [ ] Bread'; // User A renames Milk
const remote = '- [x] Milk\n- [ ] Bread'; // User B checks Milk
const result = mergeContent(base, local, remote);
// Both changed line 1 — conflict resolved by preferring remote
expect(result).toBe('- [x] Milk\n- [ ] Bread');
});
it('should handle line additions by both sides', () => {
const base = '- [ ] Milk';
const local = '- [ ] Milk\n- [ ] Bread'; // User A adds Bread at end
const remote = '- [ ] Eggs\n- [ ] Milk'; // User B adds Eggs at beginning
const result = mergeContent(base, local, remote);
// Both additions should be preserved
expect(result).toContain('- [ ] Milk');
expect(result).toContain('- [ ] Bread');
expect(result).toContain('- [ ] Eggs');
});
it('should handle line deletion by one side', () => {
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const local = '- [ ] Milk\n- [ ] Eggs'; // User A removes Bread
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'; // User B unchanged
const result = mergeContent(base, local, remote);
expect(result).toBe('- [ ] Milk\n- [ ] Eggs');
});
it('should work with non-checklist markdown (prose paragraphs)', () => {
const base = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nNothing special.';
const local = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nRemember coupons.'; // User A edits notes
const remote = '# Shopping List\n\nBuy groceries today.\n\n## Notes\n\nNothing special.'; // User B edits title
const result = mergeContent(base, local, remote);
expect(result).toContain('# Shopping List');
expect(result).toContain('Remember coupons.');
});
it('should handle empty content', () => {
expect(mergeContent('', '', '')).toBe('');
expect(mergeContent('', 'hello', '')).toBe('hello');
expect(mergeContent('', '', 'hello')).toBe('hello');
});
it('should return remote when local equals remote', () => {
const content = '- [ ] Milk\n- [ ] Bread';
expect(mergeContent('old', content, content)).toBe(content);
});
});
describe('mergeContentTwoWay', () => {
it('should return remote when both are identical', () => {
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
});
it('should attempt merge when contents differ', () => {
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
const result = mergeContentTwoWay(local, remote);
// Without a base, conflict resolution prefers remote
expect(result).toContain('- [ ] Bread');
});
});
-88
View File
@@ -1,88 +0,0 @@
import { diff3Merge } from 'node-diff3';
/**
* Resolve a conflict region by comparing each line against the base.
* If only one side changed a line, take that change.
* If both sides changed the same line, prefer remote (LWW).
* Handles different-length regions by appending extra lines.
*/
function resolveConflict(
aLines: string[],
oLines: string[],
bLines: string[]
): string[] {
const maxLen = Math.max(aLines.length, bLines.length, oLines.length);
const result: string[] = [];
for (let i = 0; i < maxLen; i++) {
const a = i < aLines.length ? aLines[i] : undefined;
const o = i < oLines.length ? oLines[i] : undefined;
const b = i < bLines.length ? bLines[i] : undefined;
const aChanged = a !== o;
const bChanged = b !== o;
if (aChanged && !bChanged && a !== undefined) {
// Only local changed this line — take local
result.push(a);
} else if (bChanged && !aChanged && b !== undefined) {
// Only remote changed this line — take remote
result.push(b);
} else if (aChanged && bChanged) {
// Both changed — LWW: prefer remote
if (b !== undefined) result.push(b);
// If remote deleted (undefined) but local has it, skip (remote wins)
} else {
// Neither changed, or both are the same as base
if (o !== undefined) result.push(o);
else if (b !== undefined) result.push(b);
else if (a !== undefined) result.push(a);
}
}
return result;
}
/**
* 3-way line-level merge for note content.
* Takes a base (common ancestor), local (incoming), and remote (server current).
* For conflict regions, resolves line-by-line: if only one side changed a line,
* take that change. If both changed the same line, prefer remote (LWW).
*/
export function mergeContent(base: string, local: string, remote: string): string {
// If any two are identical, short-circuit
if (local === remote) return remote;
if (local === base) return remote;
if (remote === base) return local;
const baseLines = base.split('\n');
const localLines = local.split('\n');
const remoteLines = remote.split('\n');
const regions = diff3Merge(localLines, baseLines, remoteLines);
const merged: string[] = [];
for (const region of regions) {
if (region.ok) {
merged.push(...region.ok);
} else if (region.conflict) {
merged.push(...resolveConflict(
region.conflict.a,
region.conflict.o,
region.conflict.b
));
}
}
return merged.join('\n');
}
/**
* 2-way merge fallback when no base is available.
* Uses an empty string as the base, so all lines from both sides appear
* as additions. For conflicts (same region), prefers remote.
*/
export function mergeContentTwoWay(local: string, remote: string): string {
if (local === remote) return remote;
return mergeContent('', local, remote);
}
+6 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import Icon from '@iconify/svelte';
import { User, Cpu, ShieldCheck, SlidersHorizontal, Info } from 'lucide-svelte';
let { data, children } = $props();
let pathname = $derived(page.url.pathname);
@@ -23,7 +23,7 @@
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/preferences' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
data-testid="settings-nav-preferences"
>
<Icon icon="pixelarticons:sliders" width={16} />
<SlidersHorizontal size={16} />
Preferences
</a>
</li>
@@ -32,7 +32,7 @@
href="/settings/profile"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/profile' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Icon icon="pixelarticons:user" width={16} />
<User size={16} />
Profile
</a>
</li>
@@ -41,7 +41,7 @@
href="/settings/mcp"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Icon icon="pixelarticons:cpu" width={16} />
<Cpu size={16} />
API
</a>
</li>
@@ -51,7 +51,7 @@
href="/settings/users"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/users' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Icon icon="pixelarticons:shield" width={16} />
<ShieldCheck size={16} />
Users
</a>
</li>
@@ -61,7 +61,7 @@
href="/settings/about"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/about' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
>
<Icon icon="pixelarticons:info-box" width={16} />
<Info size={16} />
About
</a>
</li>
+2 -2
View File
@@ -35,8 +35,8 @@
href="https://bretzel.app"
target="_blank"
rel="noopener noreferrer"
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">bretzel.app</a
> universe.
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">Bretzel</a
> family of apps.
</p>
</section>
+10 -10
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { Key, Copy, Trash2, Plus, Check } from 'lucide-svelte';
interface ApiKey {
id: string;
@@ -118,7 +118,7 @@ ${mcpJsonConfig}`);
<!-- API Keys -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<div class="mb-4 flex items-center gap-2">
<Icon icon="pixelarticons:lock" width={16} class="text-[var(--primary)]" />
<Key size={16} class="text-[var(--primary)]" />
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
</div>
<p class="mb-4 text-xs text-[var(--text-muted)]">
@@ -140,7 +140,7 @@ ${mcpJsonConfig}`);
class="flex items-center gap-1 rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
data-testid="create-api-key-btn"
>
<Icon icon="pixelarticons:plus" width={14} />
<Plus size={14} />
Create
</button>
</form>
@@ -167,9 +167,9 @@ ${mcpJsonConfig}`);
data-testid="copy-key-btn"
>
{#if copiedKey}
<Icon icon="pixelarticons:check" width={14} class="text-[var(--success-text,#3a5a40)]" />
<Check size={14} class="text-[var(--success-text,#3a5a40)]" />
{:else}
<Icon icon="pixelarticons:copy" width={14} />
<Copy size={14} />
{/if}
</button>
</div>
@@ -219,7 +219,7 @@ ${mcpJsonConfig}`);
data-testid="delete-api-key-btn"
title="Revoke key"
>
<Icon icon="pixelarticons:trash" width={14} />
<Trash2 size={14} />
</button>
{/if}
</div>
@@ -253,9 +253,9 @@ ${mcpJsonConfig}`);
title="Copy JSON config"
>
{#if copiedJson}
<Icon icon="pixelarticons:check" width={12} class="text-[var(--success-text,#3a5a40)]" />
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
{:else}
<Icon icon="pixelarticons:copy" width={12} />
<Copy size={12} />
{/if}
</button>
</div>
@@ -279,9 +279,9 @@ ${mcpJsonConfig}`);
title="Copy LLM prompt"
>
{#if copiedPrompt}
<Icon icon="pixelarticons:check" width={12} class="text-[var(--success-text,#3a5a40)]" />
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
{:else}
<Icon icon="pixelarticons:copy" width={12} />
<Copy size={12} />
{/if}
</button>
</div>
@@ -64,42 +64,4 @@
/>
<label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label>
</div>
<!-- Email notifications -->
<div class="space-y-3">
<span class="block text-sm font-medium text-[var(--text)]">Email notifications</span>
<div class="flex items-center gap-3">
<input
type="checkbox"
id="notify-on-share"
checked={prefs.notifyOnShare}
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
class="h-4 w-4 rounded-sm"
data-testid="pref-notify-on-share"
/>
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
</div>
<div class="flex items-center gap-3">
<input
type="checkbox"
id="notify-on-collab-removed"
checked={prefs.notifyOnCollabRemoved}
onchange={() => updatePreference('notifyOnCollabRemoved', !prefs.notifyOnCollabRemoved)}
class="h-4 w-4 rounded-sm"
data-testid="pref-notify-on-collab-removed"
/>
<label for="notify-on-collab-removed" class="text-sm text-[var(--text)]">Email me when I'm removed from a shared note</label>
</div>
<div class="flex items-center gap-3">
<input
type="checkbox"
id="notify-on-note-deleted"
checked={prefs.notifyOnNoteDeleted}
onchange={() => updatePreference('notifyOnNoteDeleted', !prefs.notifyOnNoteDeleted)}
class="h-4 w-4 rounded-sm"
data-testid="pref-notify-on-note-deleted"
/>
<label for="notify-on-note-deleted" class="text-sm text-[var(--text)]">Email me when a shared note I have access to is deleted</label>
</div>
</div>
</div>
-23
View File
@@ -1,30 +1,7 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { pwaInfo } from 'virtual:pwa-info';
let { children } = $props();
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);
}
});
}
});
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
<svelte:head>
{@html webManifest}
</svelte:head>
{@render children()}
-8
View File
@@ -2,7 +2,6 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { listUsers, createUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendWelcomeEmail } from '$lib/server/email.js';
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
@@ -22,12 +21,5 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
}
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
// Send welcome email (fire-and-forget)
if (isEmailConfigured() && user.email) {
const origin = process.env.ORIGIN || 'http://localhost:3000';
sendWelcomeEmail(user.email, user.displayName, origin).catch(() => {});
}
return json(user, { status: 201 });
};
@@ -2,7 +2,6 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const admin = requireAdmin(event);
@@ -12,19 +11,12 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const user = getUser(userId);
if (!user) throw error(404, 'User not found');
const origin = process.env.ORIGIN || 'http://localhost:3000';
if (body.role !== undefined) {
// Prevent admin from demoting themselves
if (userId === admin.id && body.role !== 'admin') {
throw error(400, 'Cannot change your own role');
}
updateUserRole(userId, body.role);
// Notify user of role change (fire-and-forget)
if (isEmailConfigured() && user.email) {
sendRoleChangedEmail(user.email, user.displayName, body.role, origin).catch(() => {});
}
}
if (body.newPassword !== undefined) {
@@ -32,11 +24,6 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
throw error(400, 'Password must be at least 8 characters');
}
await resetPassword(userId, body.newPassword);
// Notify user of password reset (fire-and-forget)
if (isEmailConfigured() && user.email) {
sendPasswordResetEmail(user.email, user.displayName, origin).catch(() => {});
}
}
if (body.revokeSessions) {
-9
View File
@@ -2,7 +2,6 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
const userId = getUserId(event);
@@ -18,9 +17,6 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
if (!verified) throw error(401, 'Invalid password');
}
// Capture email/name before deletion
const { email: userEmail, displayName: userName } = user;
// Delete user and all associated data
await deleteUser(userId);
@@ -31,10 +27,5 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
cookies.delete('session', { path: '/' });
}
// Send deletion confirmation email (fire-and-forget)
if (isEmailConfigured() && userEmail) {
sendAccountDeletedEmail(userEmail, userName).catch(() => {});
}
return json({ success: true });
};
-24
View File
@@ -3,9 +3,6 @@ import type { RequestHandler } from './$types.js';
import { verifyPassword, createSession } from '$lib/server/auth.js';
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { isEmailConfigured, sendAccountLockedEmail } from '$lib/server/email.js';
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const ip = getClientAddress();
@@ -26,27 +23,6 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
const user = await verifyPassword(email, password);
if (!user) {
recordLoginAttempt(db, ip, email, false);
// Check if this failure just triggered a lockout — if so, notify the user
if (isEmailConfigured()) {
const newCheck = checkRateLimit(db, ip, email);
if (!newCheck.allowed) {
const retryAfterMinutes = Math.ceil((newCheck.retryAfter ?? 900) / 60);
const accountUser = db
.select({ email: users.email, displayName: users.displayName })
.from(users)
.where(eq(users.email, email))
.get();
if (accountUser?.email) {
sendAccountLockedEmail(
accountUser.email,
accountUser.displayName,
retryAfterMinutes
).catch(() => {});
}
}
}
throw error(401, 'Invalid email or password');
}
+1 -11
View File
@@ -1,8 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { updateUserProfile, getUser } from '$lib/server/auth.js';
import { isEmailConfigured, sendEmailChangedEmail } from '$lib/server/email.js';
import { updateUserProfile } from '$lib/server/auth.js';
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
@@ -12,15 +11,6 @@ export const PUT: RequestHandler = async ({ request, ...event }) => {
throw error(400, 'Email cannot be empty');
}
// Capture old email before update to notify on change
const existingUser = email !== undefined ? getUser(userId) : null;
updateUserProfile(userId, { displayName, email });
// Send notification to old email when email address changes
if (isEmailConfigured() && existingUser?.email && email && existingUser.email !== email) {
sendEmailChangedEmail(existingUser.email, existingUser.displayName, email).catch(() => {});
}
return json({ success: true });
};
-27
View File
@@ -3,9 +3,6 @@ import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
import { fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
import { isEmailConfigured, sendNotePermanentlyDeletedEmail } from '$lib/server/email.js';
import { getPreferences } from '$lib/server/preferences.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
@@ -28,32 +25,8 @@ export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteOwnership(db, params.id, userId);
// Fetch note and collaborators before deletion to send notifications
let collaboratorsToNotify: Array<{ email: string; displayName: string }> = [];
let noteTitle = 'Untitled';
if (isEmailConfigured()) {
const note = getNote(db, userId, params.id);
if (note) {
noteTitle = note.title || 'Untitled';
const collabMap = fetchCollaboratorsForNotes(db, [params.id]);
const collaborators = collabMap.get(params.id) ?? [];
for (const collab of collaborators) {
const prefs = getPreferences(db, collab.userId);
if (prefs.notifyOnNoteDeleted !== 'false' && collab.email) {
collaboratorsToNotify.push({ email: collab.email, displayName: collab.displayName });
}
}
}
}
const deleted = deleteNote(db, userId, params.id);
if (!deleted) throw error(404, 'Note not found');
// Notify collaborators about the deleted note (fire-and-forget)
for (const collab of collaboratorsToNotify) {
sendNotePermanentlyDeletedEmail(collab.email, collab.displayName, noteTitle).catch(() => {});
}
return json({ success: true });
};
@@ -5,12 +5,6 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from
import { db } from '$lib/server/db/index.js';
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import {
isEmailConfigured,
sendShareNotification,
sendCollaboratorRemovedEmail
} from '$lib/server/email.js';
import { getPreferences } from '$lib/server/preferences.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
@@ -54,39 +48,6 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
addCollaborator(db, params.id, targetUserId, userId);
// Send email notification (fire-and-forget)
if (isEmailConfigured()) {
const targetPrefs = getPreferences(db, targetUserId);
if (targetPrefs.notifyOnShare !== 'false') {
const target = db
.select({ email: users.email, displayName: users.displayName })
.from(users)
.where(eq(users.id, targetUserId))
.get();
const sharer = db
.select({ displayName: users.displayName })
.from(users)
.where(eq(users.id, userId))
.get();
const noteData = db
.select({ title: notes.title })
.from(notes)
.where(eq(notes.id, params.id))
.get();
const origin = process.env.ORIGIN || 'http://localhost:3000';
if (target?.email) {
sendShareNotification(
target.email,
target.displayName,
sharer?.displayName || 'Someone',
noteData?.title || 'Untitled',
origin
).catch(() => {});
}
}
}
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
return json(collaborators.get(params.id) ?? [], { status: 201 });
};
@@ -106,50 +67,7 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
throw error(403, 'Only the owner can remove other collaborators');
}
// Fetch collaborator info and note title before removal for email notification
let removedUserEmail: string | undefined;
let removedUserName: string | undefined;
let noteTitle: string | undefined;
let ownerName: string | undefined;
if (isEmailConfigured() && isOwner && targetUserId !== userId) {
const targetPrefs = getPreferences(db, targetUserId);
if (targetPrefs.notifyOnCollabRemoved !== 'false') {
const target = db
.select({ email: users.email, displayName: users.displayName })
.from(users)
.where(eq(users.id, targetUserId))
.get();
const noteData = db
.select({ title: notes.title })
.from(notes)
.where(eq(notes.id, params.id))
.get();
const owner = db
.select({ displayName: users.displayName })
.from(users)
.where(eq(users.id, userId))
.get();
removedUserEmail = target?.email ?? undefined;
removedUserName = target?.displayName ?? undefined;
noteTitle = noteData?.title ?? undefined;
ownerName = owner?.displayName ?? undefined;
}
}
removeCollaborator(db, params.id, targetUserId);
// Send notification to removed collaborator (fire-and-forget)
if (removedUserEmail) {
const origin = process.env.ORIGIN || 'http://localhost:3000';
sendCollaboratorRemovedEmail(
removedUserEmail,
removedUserName || '',
ownerName || 'Someone',
noteTitle || 'Untitled',
origin
).catch(() => {});
}
return json({ success: true });
};
@@ -1,13 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
import { listVersions } from '$lib/server/versions-service.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(db, params.id, userId);
const versions = listVersions(db, params.id);
return json(versions);
};
@@ -1,15 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
import { getVersion } from '$lib/server/versions-service.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(db, params.id, userId);
const version = getVersion(db, params.id, params.versionId);
if (!version) throw error(404, 'Version not found');
return json(version);
};
@@ -1,29 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
import { getVersion, snapshotCurrentNote } from '$lib/server/versions-service.js';
import { updateNote } from '$lib/server/notes-service.js';
export const POST: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(db, params.id, userId);
const version = getVersion(db, params.id, params.versionId);
if (!version) throw error(404, 'Version not found');
// Snapshot current state before overwriting so the user can undo the restore
snapshotCurrentNote(db, params.id);
// Apply the restored content (updateNote will increment version + create a new snapshot)
const restored = updateNote(db, userId, params.id, {
title: version.title,
content: version.content,
checklistMode: version.checklistMode,
color: version.color
});
if (!restored) throw error(404, 'Note not found');
return json(restored);
};
-796
View File
@@ -1,796 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "Crumbs API",
"description": "Auto-generated API spec for crumbs",
"version": "0.22.3",
"contact": {
"name": "Bretzel",
"url": "https://bretzel.app"
}
},
"servers": [
{
"url": "/",
"description": "Current instance"
}
],
"security": [
{
"sessionCookie": []
}
],
"paths": {
"/api/admin/users": {
"get": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/admin/users/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"patch": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Admin"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/account": {
"delete": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/login": {
"post": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/oauth/{provider}": {
"parameters": [
{
"name": "provider",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/oauth/{provider}/callback": {
"parameters": [
{
"name": "provider",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/password": {
"put": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/profile": {
"put": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/auth/sessions": {
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"delete": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/auth/setup": {
"post": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/mcp": {
"post": {
"tags": [
"Mcp"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"get": {
"tags": [
"Mcp"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"delete": {
"tags": [
"Mcp"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes": {
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/notes/reorder": {
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/notes/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"patch": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/attachments": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"patch": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/collaborators": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"delete": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions/{versionId}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "versionId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/notes/{id}/versions/{versionId}/restore": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "versionId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"post": {
"tags": [
"Notes"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/preferences": {
"get": {
"tags": [
"Preferences"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"put": {
"tags": [
"Preferences"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/search": {
"get": {
"tags": [
"Search"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/settings/api-keys": {
"get": {
"tags": [
"Settings"
],
"responses": {
"200": {
"description": "Success"
}
}
},
"post": {
"tags": [
"Settings"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"/api/settings/api-keys/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"delete": {
"tags": [
"Settings"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/sync": {
"post": {
"tags": [
"Sync"
],
"responses": {
"200": {
"description": "Success"
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"get": {
"tags": [
"Sync"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/tags": {
"get": {
"tags": [
"Tags"
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/users/search": {
"get": {
"tags": [
"Users"
],
"responses": {
"200": {
"description": "Success"
}
}
}
}
},
"components": {
"securitySchemes": {
"sessionCookie": {
"type": "apiKey",
"in": "cookie",
"name": "session"
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 115 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "Crumbs by Bretzel",
"short_name": "Crumbs",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#f9f4ea",
"background_color": "#f9f4ea",
"display": "standalone"
}
+1 -4
View File
@@ -5,10 +5,7 @@ const config = {
kit: {
adapter: adapter({
out: 'build'
}),
serviceWorker: {
register: false
}
})
}
};
+1 -5
View File
@@ -62,12 +62,8 @@ test.describe('Offline Support', () => {
await expect(page.getByText('Survive Reload')).toBeVisible();
// When the network is restored and sync completes
const syncResponse = page.waitForResponse((resp) => resp.url().includes('/api/sync'));
await page.context().setOffline(false);
// Manually dispatch the online event as a safety net — in CI/headless
// environments, setOffline(false) doesn't always emit it reliably
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await syncResponse;
await page.waitForResponse('**/api/sync');
// And the user reloads the page
await page.reload();
+9 -9
View File
@@ -1,17 +1,17 @@
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
test.describe('Organization Features', () => {
test('Scenario: Favorited note appears under the Favorites section', async ({ authenticatedPage: page }) => {
// Given a note titled "Favorite Me" exists
await createNote(page, 'Favorite Me');
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
// Given a note titled "Pin Me" exists
await createNote(page, 'Pin Me');
// When the user favorites the note
const favCard = noteCard(page, 'Favorite Me');
await favCard.hover();
await favCard.getByTestId('favorite-btn').first().click({ force: true });
// When the user pins the note
const pinCard = noteCard(page, 'Pin Me');
await pinCard.hover();
await pinCard.getByTestId('pin-btn').first().click({ force: true });
// Then the "Favorites" section is visible
await expect(page.getByText('Favorites')).toBeVisible();
// Then the "Pinned" section is visible
await expect(page.getByText('Pinned')).toBeVisible();
});
test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => {
-31
View File
@@ -124,37 +124,6 @@ test.describe('Note Sharing', () => {
expect(syncedNote.content).toBe('Owner sync update');
});
test('Scenario: Both users\' edits to different fields are preserved after concurrent PATCH', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared checklist note exists
const note = await createSharedNote(
page,
collabPage,
'Shopping List',
'- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'
);
// When the owner checks Milk
await page.request.patch(`/api/notes/${note.id}`, {
data: { content: '- [x] Milk\n- [ ] Bread\n- [ ] Eggs', baseVersion: note.version }
});
// And the collaborator adds Butter (based on the same original version)
await collabPage.request.patch(`/api/notes/${note.id}`, {
data: { content: '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter', baseVersion: note.version }
});
// Then both changes are preserved via content merge
const res = await page.request.get(`/api/notes/${note.id}`);
const merged = await res.json();
expect(merged.content).toContain('- [x] Milk');
expect(merged.content).toContain('- [ ] Butter');
expect(merged.content).toContain('- [ ] Bread');
expect(merged.content).toContain('- [ ] Eggs');
});
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
authenticatedPage: page,
collabPage
+5 -26
View File
@@ -23,8 +23,6 @@ export default defineConfig({
]
: []),
SvelteKitPWA({
scope: '/',
buildBase: '/',
registerType: 'autoUpdate',
manifest: {
name: 'Crumbs by Bretzel',
@@ -36,26 +34,16 @@ export default defineConfig({
theme_color: '#C8860A',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
navigateFallback: null,
navigateFallback: '/',
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: {
cacheName: 'pages-cache',
networkTimeoutSeconds: 3,
expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }
}
},
{
urlPattern: ({ request, url }) =>
request.method === 'GET' &&
/\/api\/notes\/.*\/attachments\?attachmentId=/.test(url.href),
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
handler: 'CacheFirst',
options: {
cacheName: 'attachment-cache',
@@ -63,21 +51,12 @@ export default defineConfig({
}
},
{
urlPattern: ({ request, url }) =>
request.method === 'GET' && /\/api\//.test(url.pathname),
urlPattern: /^https?:\/\/.*\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
}
},
{
urlPattern: /^https?:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: { maxEntries: 10, maxAgeSeconds: 365 * 24 * 60 * 60 }
}
}
]
}
+1 -28
View File
@@ -166,33 +166,6 @@
<h3>OAuth & SSO</h3>
<p>Sign in with Google, GitHub, or any OIDC provider. Or just use a password — your choice.</p>
</div>
<!-- Note Sharing -->
<div class="feature-card animate-in delay-4">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</div>
<h3>Note Sharing</h3>
<p>Share notes with other users on your instance. Full collaboration with per-user pins and archives.</p>
</div>
<!-- Version History -->
<div class="feature-card animate-in delay-5">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<h3>Version History</h3>
<p>Browse and restore previous versions of any note. Never lose important edits again.</p>
</div>
<!-- Email Notifications -->
<div class="feature-card animate-in delay-6">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
</div>
<h3>Email Alerts</h3>
<p>Optional SMTP notifications for shares, security alerts, and account events. Opt out per user.</p>
</div>
</div>
</div>
</section>
@@ -280,7 +253,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.22.3</span>
<span class="footer-version">v0.19.2</span>
</div>
</footer>