Compare commits

...
89 Commits
Author SHA1 Message Date
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
97 changed files with 6271 additions and 911 deletions
+36 -2
View File
@@ -46,7 +46,18 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit
- run: pnpm test:unit:coverage
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "Unit Test Coverage" coverage/unit/coverage-summary.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: unit-coverage
path: coverage/unit/
retention-days: 14
e2e-tests:
name: E2E Tests
@@ -80,9 +91,25 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Build (with coverage instrumentation)
run: pnpm build
env:
DATABASE_URL: ./data/test-crumbs.db
NODE_ENV: test
VITE_COVERAGE: 'true'
- run: pnpm test:e2e
env:
CI: true
VITE_COVERAGE: 'true'
- name: Merge E2E coverage
if: always()
run: npx nyc report
- name: Coverage summary
if: always()
run: bash scripts/coverage-summary.sh "E2E Test Coverage" coverage/e2e/coverage-summary.json
- uses: actions/upload-artifact@v4
if: always()
@@ -91,7 +118,14 @@ jobs:
path: |
playwright-report/
test-results/
retention-days: 30
retention-days: 7
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-coverage
path: coverage/e2e/
retention-days: 14
docker-build:
name: Docker Build
+4
View File
@@ -32,6 +32,10 @@ vite.config.ts.timestamp-*
/test-results
/playwright-report
# Coverage
/coverage
.nyc_output
# Claude Code
/.claude/
+7
View File
@@ -0,0 +1,7 @@
{
"report-dir": "coverage/e2e",
"temp-dir": "coverage/e2e/raw",
"reporter": ["text", "html", "lcov", "json-summary"],
"include": ["src/**"],
"exclude": ["node_modules", "tests"]
}
+5
View File
@@ -98,6 +98,10 @@ tests/
unit/ # Vitest unit tests
e2e/ # Playwright e2e tests (Gherkin-style)
helpers/fixtures.ts # Shared authenticatedPage fixture
website/ # Static landing page (crumbs.bretzel.app)
index.html # Landing page with feature cards, screenshots, deploy section
styles.css # Landing page styles (retro parchment theme)
assets/ # Favicon, screenshots
```
## Commands
@@ -215,5 +219,6 @@ Keep docs in `docs/` up to date when making changes. If a feature, API endpoint,
|-----|--------|
| `docs/FEATURES.md` | User-facing feature list |
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
| `docs/AUTH.md` | Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.) |
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
+8 -1
View File
@@ -1,4 +1,5 @@
.PHONY: help dev build preview check test test-unit test-e2e lint \
.PHONY: help dev build preview check test test-unit test-e2e \
test-unit-coverage test-e2e-coverage lint \
db-push db-generate db-migrate db-studio docs-api \
install docker-build docker-up docker-down docker-logs clean \
release release-patch release-minor release-major
@@ -27,6 +28,12 @@ test-unit: ## Run unit tests only
test-e2e: ## Run e2e tests only
pnpm test:e2e
test-unit-coverage: ## Run unit tests with coverage
pnpm test:unit:coverage
test-e2e-coverage: ## Run e2e tests with coverage
VITE_COVERAGE=true pnpm build && pnpm test:e2e && npx nyc report
lint: ## Run linter
pnpm lint
+9 -4
View File
@@ -9,7 +9,7 @@
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
## Install
@@ -21,7 +21,7 @@ docker compose up -d
Open http://localhost:3000 and create your admin account on first visit.
#### With OAuth (optional)
#### With SSO (optional)
```yaml
# docker-compose.yml
@@ -34,14 +34,18 @@ services:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_GOOGLE_ID=your-google-client-id
- AUTH_GOOGLE_SECRET=your-google-client-secret
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=your-client-id
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
### Manual (Node.js)
```bash
@@ -99,6 +103,7 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|----------|-------------|
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
| [API](docs/API.md) | REST API reference (auto-generated) |
| [Contributing](CONTRIBUTING.md) | How to contribute |
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.8.1
image: ghcr.io/bretzel-app/crumbs:0.17.0
container_name: crumbs
ports:
- "3000:3000"
+117
View File
@@ -321,6 +321,123 @@ Permanently delete a note
---
## Collaboration
### `GET /api/notes/{id}/collaborators`
List collaborators
Returns all collaborators for a note. Requires access (owner or collaborator).
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Response:** `200`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/notes/{id}/collaborators`
Add a collaborator
Owner-only. Adds a user as collaborator on the note.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
**Request:**
```json
{
"userId": 1
}
```
**Response:** `201`
```json
[
{
"userId": 1,
"displayName": "displayName",
"email": "email",
"addedAt": "2025-01-01T00:00:00.000Z"
}
]
```
**Errors:** `400` Invalid request (missing userId, owner as collaborator), `409` User is already a collaborator
---
### `DELETE /api/notes/{id}/collaborators`
Remove a collaborator
Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `id` | path | string | yes | |
| `userId` | query | | yes | |
**Response:** `200`
```json
{
"success": true
}
```
---
## Users
### `GET /api/users/search`
Search users
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
**Parameters:**
| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Search query (matches email or displayName) |
**Response:** `200`
```json
[
{
"id": 1,
"displayName": "displayName",
"email": "email"
}
]
```
---
## Attachments
### `GET /api/notes/{id}/attachments`
+15 -3
View File
@@ -83,9 +83,8 @@ For a single-user app with multiple devices:
- New v4 uses Vite plugin (faster builds)
### Argon2
- Current best practice for password hashing
- Memory-hard (resists GPU attacks)
- Winner of the Password Hashing Competition
- Memory-hard password hashing (resists GPU attacks)
- See [AUTH.md](AUTH.md) for authentication details
## Database Schema
@@ -99,8 +98,21 @@ Key tables:
- `note_tags` - Many-to-many note ↔ tag
- `attachments` - Image file metadata (path + thumbnailPath)
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
- `sync_log` - Sync operation history
### Collaboration Access Control
Notes support sharing with other users on the same instance:
- **Owner**: Full control (edit, trash, delete, share/unshare)
- **Collaborator**: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via `note_user_state`
- Access checks use `canAccessNote()` / `requireNoteAccess()` / `requireNoteOwnership()` helpers
- `note_user_state` stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
- When owner trashes a note, it vanishes for all collaborators
- Collaborators can leave (remove themselves) from shared notes
## MCP Server
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
+209
View File
@@ -0,0 +1,209 @@
# Authentication
Crumbs supports password-based authentication and optional OAuth/SSO via Google, GitHub, or any OpenID Connect (OIDC) provider (e.g. Authentik, Keycloak, Okta, Auth0).
## First-run setup
On first launch, Crumbs presents a setup screen to create the initial admin account with email and password (minimum 8 characters). This account becomes the instance administrator.
## Password authentication
- Passwords are hashed with **Argon2** (argon2id variant)
- Sessions use httpOnly cookies with 30-day expiry
- Admins can reset passwords and revoke sessions for any user via Settings
## OAuth / SSO
OAuth providers are **auto-enabled** when their environment variables are set — no code changes needed. The login page dynamically shows buttons for each configured provider.
### How it works
1. User clicks an OAuth button on the login page
2. Crumbs redirects to the provider's authorization endpoint (with PKCE)
3. Provider authenticates the user and redirects back to Crumbs
4. Crumbs validates the response, extracts the user's email and name
5. If a matching user exists (by provider ID or email), a session is created
6. If no matching user exists, the login is rejected (invite-only model)
### Invite-only model
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
1. Go to **Settings > Users > Invite User**
2. Create the user with the **same email** they use on the OAuth provider
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
### Environment variables
#### Google OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
| `AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/google/callback`
Setup:
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create an OAuth 2.0 Client ID (Web application)
3. Add the callback URL above as an authorized redirect URI
4. Copy the client ID and secret into your environment
#### GitHub OAuth
| Variable | Description |
|----------|-------------|
| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
**Callback URL:** `https://<your-domain>/api/auth/oauth/github/callback`
Setup:
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set the authorization callback URL to the callback URL above
4. Copy the client ID and secret into your environment
#### Generic OIDC
Any OpenID Connect-compliant provider works via the generic OIDC integration. Crumbs automatically discovers endpoints via the provider's `/.well-known/openid-configuration` document.
| Variable | Description |
|----------|-------------|
| `AUTH_OIDC_ISSUER` | OIDC issuer URL (must serve `/.well-known/openid-configuration`) |
| `AUTH_OIDC_CLIENT_ID` | OIDC client ID |
| `AUTH_OIDC_CLIENT_SECRET` | OIDC client secret |
| `AUTH_OIDC_DISPLAY_NAME` | Button label on login page (default: `SSO`) |
**Callback URL:** `https://<your-domain>/api/auth/oauth/oidc/callback`
**Required scopes:** `openid`, `profile`, `email`
### Provider guides
#### Authentik
[Authentik](https://goauthentik.io/) is a self-hosted identity provider that works with Crumbs via the generic OIDC integration.
**1. Create a provider in Authentik:**
1. Open the Authentik admin panel
2. Go to **Applications > Providers > Create**
3. Select **OAuth2/OpenID Connect**
4. Configure:
- **Name:** `Crumbs`
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or `explicit-consent` to prompt users)
- **Client type:** `Confidential`
- **Redirect URIs:** `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
- **Scopes:** `openid`, `profile`, `email`
5. Save and note the **Client ID** and **Client Secret**
**2. Create an application in Authentik:**
1. Go to **Applications > Applications > Create**
2. Configure:
- **Name:** `Crumbs`
- **Slug:** `crumbs`
- **Provider:** Select the `Crumbs` provider you just created
- **Launch URL:** `https://<your-crumbs-domain>` (optional, for Authentik's app dashboard)
3. Save
**3. Configure Crumbs:**
```yaml
# docker-compose.yml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
environment:
- ORIGIN=https://notes.example.com
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=<client-id>
- AUTH_OIDC_CLIENT_SECRET=<client-secret>
- AUTH_OIDC_DISPLAY_NAME=Authentik
```
> **Note:** The issuer URL for Authentik follows the pattern `https://<authentik-domain>/application/o/<app-slug>/`. This URL must serve a valid `/.well-known/openid-configuration` document — you can verify by visiting `https://<authentik-domain>/application/o/crumbs/.well-known/openid-configuration` in your browser.
**4. Pre-create users:**
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
#### Keycloak
1. Create a new client in your Keycloak realm
2. Set **Client type** to `OpenID Connect`, **Client authentication** to `On`
3. Add the callback URL: `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
4. Set the issuer URL to `https://<keycloak-domain>/realms/<realm-name>`
```env
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
AUTH_OIDC_CLIENT_ID=crumbs
AUTH_OIDC_CLIENT_SECRET=<client-secret>
AUTH_OIDC_DISPLAY_NAME=Keycloak
```
#### Okta / Auth0
Use the issuer URL from your Okta org or Auth0 tenant:
```env
# Okta
AUTH_OIDC_ISSUER=https://your-org.okta.com
# Auth0
AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
```
### Docker example with multiple providers
```yaml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
# Google
- AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
- AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
# GitHub
- AUTH_GITHUB_CLIENT_ID=Iv1.abc123
- AUTH_GITHUB_CLIENT_SECRET=abc123secret
# Authentik (OIDC)
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
- AUTH_OIDC_CLIENT_ID=crumbs-client-id
- AUTH_OIDC_CLIENT_SECRET=crumbs-client-secret
- AUTH_OIDC_DISPLAY_NAME=Authentik
restart: unless-stopped
volumes:
crumbs-data:
```
All configured providers will appear as buttons on the login page.
## Security notes
- All OAuth flows use **PKCE** (Proof Key for Code Exchange) with S256 challenge method
- OAuth state and code verifier are stored as httpOnly cookies (10-minute expiry)
- Passwords are hashed with **Argon2** (never stored in plain text)
- Sessions expire after **30 days**
- The `ORIGIN` environment variable must match your actual domain for CSRF protection
- Always use **HTTPS** in production (via reverse proxy)
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| OAuth button doesn't appear | Provider env vars not set or missing a required variable | Check all required env vars are set and restart |
| "No account found" after OAuth | User doesn't exist in Crumbs | Create the user via Settings with matching email |
| OIDC discovery fails | Issuer URL is wrong or unreachable | Verify `AUTH_OIDC_ISSUER` serves `/.well-known/openid-configuration` |
| Redirect URI mismatch | Callback URL in provider doesn't match `ORIGIN` | Ensure `ORIGIN` matches your domain and the callback URL uses the same origin |
| PKCE error | Provider doesn't support S256 PKCE | Most modern providers support it; check provider docs |
+4 -62
View File
@@ -110,48 +110,9 @@ node build
| `PORT` | `3000` | HTTP port |
| `NODE_ENV` | `development` | Set to `production` for deployment |
### OAuth Providers (optional)
### OAuth / SSO (optional)
Enable social login by setting the client ID and secret for any provider. Users who sign in via OAuth are created automatically (invite-only when combined with admin approval).
| Variable | Description |
|----------|-------------|
| `AUTH_GOOGLE_ID` | Google OAuth client ID |
| `AUTH_GOOGLE_SECRET` | Google OAuth client secret |
| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
| `AUTH_OIDC_ISSUER` | Generic OIDC issuer URL (e.g. `https://accounts.example.com`) |
| `AUTH_OIDC_ID` | OIDC client ID |
| `AUTH_OIDC_SECRET` | OIDC client secret |
| `AUTH_OIDC_NAME` | Display name for the OIDC button (default: "SSO") |
**Callback URLs** — register these in your provider's dashboard:
| Provider | Callback URL |
|----------|-------------|
| Google | `https://your-domain/api/auth/callback/google` |
| GitHub | `https://your-domain/api/auth/callback/github` |
| OIDC | `https://your-domain/api/auth/callback/oidc` |
#### Docker example with Google OAuth
```yaml
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- ORIGIN=https://notes.example.com
- AUTH_GOOGLE_ID=123456789.apps.googleusercontent.com
- AUTH_GOOGLE_SECRET=GOCSPX-xxxxxxxxxxxxx
restart: unless-stopped
volumes:
crumbs-data:
```
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.
## Health Check
@@ -163,7 +124,7 @@ docker inspect --format='{{.State.Health.Status}}' crumbs
## MCP Server Configuration
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes.
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. See [ARCHITECTURE.md](ARCHITECTURE.md#mcp-server) for technical details and the full tool list.
### Setup
@@ -186,28 +147,9 @@ Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI as
}
```
### Available Tools
| Tool | Description |
|------|-------------|
| `list_notes` | List notes (filter by status/tag) |
| `get_note` | Get a note by ID |
| `create_note` | Create a new note |
| `update_note` | Update a note |
| `trash_note` / `restore_note` | Move to/from trash |
| `archive_note` / `unarchive_note` | Archive/unarchive |
| `delete_note` | Permanently delete |
| `search_notes` | Full-text search |
| `list_tags` | List all tags |
| `pin_note` | Pin/unpin a note |
| `reorder_notes` | Set sort orders |
| `upload_image` | Attach image from URL |
## Security Notes
- Always use HTTPS in production (via reverse proxy)
- Set a strong password (min 8 characters recommended)
- The `ORIGIN` variable must match your actual domain for CSRF protection
- Sessions expire after 30 days
- Passwords are hashed with Argon2
- API keys are SHA-256 hashed (never stored in plain text)
- See [AUTH.md](AUTH.md#security-notes) for authentication security details (password hashing, session expiry, PKCE)
+20 -16
View File
@@ -25,8 +25,8 @@
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Inline code (single backtick)
- Tables
- Links — insert, edit, and remove with URL input
- Tables (insert, add/delete rows and columns)
- Dividers (horizontal rules)
- Text alignment (left, center, right, justify)
- Line breaks
@@ -59,7 +59,7 @@
- Tags inside code blocks and URLs are ignored
### Color-Coded Notes
12 colors matching Google Keep's palette:
12 note colors:
- Default, Coral, Peach, Sand, Mint, Sage
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to note theme
@@ -80,26 +80,30 @@
- Restore notes from trash
- Permanently delete from trash
### Note Sharing / Collaboration
- Share notes with other users on the same instance
- User search autocomplete for adding collaborators
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
- Per-user state: pin, archive, and sort order are independent per user
- Owner-only actions: trash, delete, share/unshare
- Collaborator indicator (Users icon) on shared note cards
- Hover popover showing collaborator names
- Share dialog accessible from editor toolbar
- Collaborators can leave shared notes
- Shared notes appear mixed in main list alongside owned notes
### Full-Text Search
- Search across note titles, content, and tags
- Real-time search results as you type
- Clear search to restore original view
- Case-insensitive matching
### Authentication
- Multi-user support with role-based access (admin / user)
- First-run setup: create admin account (min 8 chars password)
- Argon2 password hashing (industry standard)
- Session-based auth with 30-day expiry
- Active session management (view, revoke individual or all other sessions)
- All routes protected except /login and /setup
- Account deletion with password confirmation
### OAuth / SSO
- Optional social login via Google, GitHub, or generic OIDC
- OAuth users created automatically on first sign-in
- Invite-only mode when combined with admin controls
- Configure via environment variables (`AUTH_GOOGLE_ID`, `AUTH_GITHUB_ID`, `AUTH_OIDC_ISSUER`, etc.)
### Authentication & OAuth / SSO
- Multi-user with role-based access (admin / user)
- Password auth (Argon2) + optional OAuth/SSO (Google, GitHub, any OIDC provider)
- Session-based auth with active session management
- Invite-only OAuth model with PKCE-secured flows
- See [AUTH.md](AUTH.md) for full details and provider setup guides
### User Management (admin)
- Admin dashboard to create, list, and delete users
+111
View File
@@ -168,6 +168,104 @@ paths:
'404':
description: Note not found
/api/notes/{id}/collaborators:
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
get:
summary: List collaborators
description: Returns all collaborators for a note. Requires access (owner or collaborator).
tags: [Collaboration]
responses:
'200':
description: Collaborator list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
post:
summary: Add a collaborator
description: Owner-only. Adds a user as collaborator on the note.
tags: [Collaboration]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [userId]
properties:
userId:
type: integer
responses:
'201':
description: Collaborator added, returns updated list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Collaborator'
'400':
description: Invalid request (missing userId, owner as collaborator)
'409':
description: User is already a collaborator
delete:
summary: Remove a collaborator
description: Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
tags: [Collaboration]
parameters:
- name: userId
in: query
required: true
schema:
oneOf:
- type: integer
- type: string
enum: [self]
responses:
'200':
description: Collaborator removed
content:
application/json:
schema:
$ref: '#/components/schemas/Success'
/api/users/search:
get:
summary: Search users
description: Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
tags: [Users]
parameters:
- name: q
in: query
required: true
schema:
type: string
description: Search query (matches email or displayName)
responses:
'200':
description: Matching users (max 10)
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
displayName:
type: string
email:
type: string
/api/notes/{id}/attachments:
parameters:
- name: id
@@ -542,6 +640,19 @@ components:
data:
$ref: '#/components/schemas/NoteInput'
Collaborator:
type: object
properties:
userId:
type: integer
displayName:
type: string
email:
type: string
addedAt:
type: string
format: date-time
ApiKey:
type: object
properties:
+114
View File
@@ -0,0 +1,114 @@
CREATE TABLE `api_keys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`key_hash` text NOT NULL,
`key_prefix` text NOT NULL,
`created_at` integer NOT NULL,
`last_used_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `note_collaborators` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`added_by` integer NOT NULL,
`added_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
CREATE TABLE `note_user_state` (
`note_id` text NOT NULL,
`user_id` integer NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
CREATE TABLE `user_preferences` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`key` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_attachments` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`featured` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
DROP TABLE `attachments`;--> statement-breakpoint
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_notes` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
DROP TABLE `notes`;--> statement-breakpoint
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `__new_sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
DROP TABLE `sync_log`;--> statement-breakpoint
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `__new_tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer DEFAULT 0 NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
DROP TABLE `tags`;--> statement-breakpoint
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1773016788899,
"tag": "0001_strange_lord_hawal",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1773150888301,
"tag": "0002_deep_black_widow",
"breakpoints": true
}
]
}
+11 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.9.0",
"version": "0.18.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -19,7 +19,10 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:coverage": "VITE_COVERAGE=true pnpm build && pnpm test:e2e && nyc report",
"coverage:merge-e2e": "nyc merge coverage/e2e/raw coverage/e2e/merged.json && nyc report",
"test": "pnpm test:unit && pnpm test:e2e",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
@@ -34,6 +37,10 @@
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-table-cell": "^3.20.1",
"@tiptap/extension-table-header": "^3.20.1",
"@tiptap/extension-table-row": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
@@ -64,13 +71,16 @@
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"@vitest/coverage-v8": "^3.2.4",
"drizzle-kit": "^0.31.9",
"nyc": "^18.0.0",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-istanbul": "^7.2.1",
"vitest": "^3.2.4",
"yaml": "^2.8.2"
},
+3 -2
View File
@@ -4,7 +4,8 @@ export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
retries: 0,
workers: process.env.CI ? 4 : undefined,
reporter: 'html',
globalSetup: './tests/e2e/global-setup.ts',
use: {
@@ -27,7 +28,7 @@ export default defineConfig({
}
],
webServer: {
command: 'pnpm build && pnpm preview --port 4173',
command: 'pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
+975
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Writes a coverage summary table to $GITHUB_STEP_SUMMARY (or stdout if not in CI).
# Usage: scripts/coverage-summary.sh <title> <json-summary-path>
set -euo pipefail
TITLE="${1:?Usage: coverage-summary.sh <title> <json-summary-path>}"
JSON="${2:?Usage: coverage-summary.sh <title> <json-summary-path>}"
if [ ! -f "$JSON" ]; then
echo "Coverage summary not found: $JSON" >&2
exit 0
fi
# Resolve to absolute path for require()
ABS_JSON="$(cd "$(dirname "$JSON")" && pwd)/$(basename "$JSON")"
# Extract totals with node (available in CI, no jq dependency)
read -r stmts branches funcs lines < <(node -e "
const s = require('$ABS_JSON').total;
console.log(s.statements.pct, s.branches.pct, s.functions.pct, s.lines.pct);
")
TABLE="## $TITLE
| Metric | Coverage |
|--------|----------|
| Statements | ${stmts}% |
| Branches | ${branches}% |
| Functions | ${funcs}% |
| Lines | ${lines}% |
"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
else
echo "$TABLE"
fi
+104
View File
@@ -72,3 +72,107 @@ body::before {
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: var(--primary-hover);
}
/* Table styles for TipTap editor */
.tiptap table {
border-collapse: collapse;
width: 100%;
margin: 0.5rem 0;
}
.tiptap th,
.tiptap td {
border: 1px solid var(--border-subtle);
padding: 0.375rem 0.625rem;
text-align: left;
vertical-align: top;
min-width: 4rem;
}
.tiptap th {
font-weight: 600;
background-color: color-mix(in srgb, var(--bg-base) 60%, transparent);
}
.tiptap td p,
.tiptap th p {
margin: 0;
}
.tiptap .selectedCell {
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
}
/* Task list styles — shared between TipTap editor and markdown-it preview */
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
.tiptap ul[data-type="taskList"],
.prose ul.task-list {
list-style: none;
padding-left: 0;
margin: 0.25rem 0;
}
.tiptap ul[data-type="taskList"] li,
.prose ul.task-list li {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
padding: 0;
}
.tiptap ul[data-type="taskList"] li::marker,
.prose ul.task-list li::marker {
content: none;
}
/* TipTap: checkbox is inside a label */
.tiptap ul[data-type="taskList"] li label {
display: flex;
align-items: center;
flex-shrink: 0;
}
/* Shared checkbox styles */
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
.prose ul.task-list .task-checkbox {
cursor: pointer;
margin: 0;
flex-shrink: 0;
}
/* TipTap: text content is in a div>p */
.tiptap ul[data-type="taskList"] li > div {
flex: 1;
min-width: 0;
}
.tiptap ul[data-type="taskList"] li > div p {
margin: 0;
}
/* Checked item styling */
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--text-muted);
}
/* Override prose spacing for task lists */
.prose ul[data-type="taskList"],
.prose ul.task-list {
padding-left: 0;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.prose ul[data-type="taskList"] li,
.prose ul.task-list li {
padding-left: 0;
margin-top: 0;
margin-bottom: 0;
}
.prose ul.task-list li p {
margin: 0;
}
+19 -24
View File
@@ -22,36 +22,31 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// MCP endpoint: authenticate via Bearer token (API key)
if (pathname.startsWith('/api/mcp')) {
// API routes: authenticate via Bearer token if present
if (pathname.startsWith('/api/')) {
const authHeader = event.request.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
if (token) {
const { valid, userId } = validateApiKey(token);
if (!valid || !userId) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const { valid, userId } = validateApiKey(token);
if (!valid || !userId) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const user = getUserForApiKey(userId);
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const user = getUserForApiKey(userId);
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
event.locals.user = user;
return resolve(event);
}
event.locals.user = user;
return resolve(event);
}
// Check if setup is complete
+1
View File
@@ -19,6 +19,7 @@
let { content, onChange }: Props = $props();
// svelte-ignore state_referenced_locally
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import Users from 'lucide-svelte/icons/users';
interface Props {
collaborators: Collaborator[];
ownerName?: string;
}
const { collaborators, ownerName }: Props = $props();
let showPopover = $state(false);
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
function handleMouseEnter() {
hoverTimeout = setTimeout(() => {
showPopover = true;
}, 150);
}
function handleMouseLeave() {
clearTimeout(hoverTimeout);
showPopover = false;
}
function handleClick(e: Event) {
e.stopPropagation();
showPopover = !showPopover;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative inline-flex"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<button
type="button"
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
title="Shared note"
onclick={handleClick}
data-testid="collaborator-indicator"
>
<Users class="h-4 w-4" />
</button>
{#if showPopover}
<div
class="absolute right-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
data-testid="collaborator-popover"
>
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
<ul class="space-y-0.5">
{#if ownerName}
<li class="text-xs text-[var(--text)]">
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
</li>
{/if}
{#each collaborators as collab}
<li class="text-xs text-[var(--text)]">
{collab.displayName || collab.email}
</li>
{/each}
</ul>
</div>
{/if}
</div>
+179 -82
View File
@@ -6,6 +6,7 @@
Heading,
List,
ListOrdered,
ListChecks,
TextQuote,
CodeXml,
Bold,
@@ -23,7 +24,10 @@
AlignJustify,
Minus,
ChevronDown,
Ellipsis
Table2,
BetweenHorizontalEnd,
BetweenVerticalEnd,
RemoveFormatting
} from 'lucide-svelte';
interface Props {
@@ -35,6 +39,9 @@
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
let openDropdown: string | null = $state(null);
let linkUrl: string = $state('');
@@ -110,13 +117,35 @@
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
data-testid="formatting-toolbar"
>
<!-- Undo/Redo -->
<button
onclick={() => editor?.chain().focus().undo().run()}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo"
data-testid="format-undo"
>
<Undo2 size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo"
data-testid="format-redo"
>
<Redo2 size={iconSize} />
</button>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(editor?.isActive('bold') ?? false)}
class={btnClass(isActive('bold'))}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
@@ -124,7 +153,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(editor?.isActive('italic') ?? false)}
class={btnClass(isActive('italic'))}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
@@ -132,7 +161,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(editor?.isActive('strike') ?? false)}
class={btnClass(isActive('strike'))}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
@@ -140,7 +169,7 @@
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(editor?.isActive('underline') ?? false)}
class={btnClass(isActive('underline'))}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
@@ -153,7 +182,7 @@
<div class="relative" data-dropdown="heading">
<button
onclick={() => toggleDropdown('heading')}
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
class={dropdownBtnClass(isActive('heading'))}
title="Heading"
data-testid="format-heading"
>
@@ -164,7 +193,7 @@
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 1 }))}
data-testid="format-h1"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
@@ -172,7 +201,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 2 }))}
data-testid="format-h2"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
@@ -180,7 +209,7 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
class={dropdownItemClass(isActive('heading', { level: 3 }))}
data-testid="format-h3"
>
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
@@ -194,7 +223,7 @@
<div class="relative" data-dropdown="list">
<button
onclick={() => toggleDropdown('list')}
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
title="Lists"
data-testid="format-list"
>
@@ -205,7 +234,7 @@
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
class={dropdownItemClass(isActive('bulletList'))}
data-testid="format-bullet-list"
>
<List size={iconSize} />
@@ -213,12 +242,20 @@
</button>
<button
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
class={dropdownItemClass(isActive('orderedList'))}
data-testid="format-ordered-list"
>
<ListOrdered size={iconSize} />
<span>Ordered list</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
class={dropdownItemClass(isActive('taskList'))}
data-testid="format-task-list"
>
<ListChecks size={iconSize} />
<span>Task list</span>
</button>
</div>
{/if}
</div>
@@ -227,7 +264,7 @@
<div class="relative" data-dropdown="link">
<button
onclick={() => toggleDropdown('link')}
class={btnClass(editor?.isActive('link') ?? false)}
class={btnClass(isActive('link'))}
title="Insert link (Ctrl+K)"
data-testid="format-link"
>
@@ -259,7 +296,7 @@
<button
type="button"
onclick={openLink}
disabled={!editor?.getAttributes('link').href}
disabled={!getAttrs('link').href}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
title="Open link"
data-testid="format-link-open"
@@ -269,7 +306,7 @@
<button
type="button"
onclick={removeLink}
disabled={!editor?.isActive('link')}
disabled={!isActive('link')}
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
title="Remove link"
data-testid="format-unlink"
@@ -281,99 +318,123 @@
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- More dropdown -->
<div class="relative" data-dropdown="more">
<!-- Table dropdown -->
<div class="relative" data-dropdown="table">
<button
onclick={() => toggleDropdown('more')}
class={dropdownBtnClass(false)}
title="More options"
data-testid="format-more"
onclick={() => toggleDropdown('table')}
class={dropdownBtnClass(isActive('table'))}
title="Table"
data-testid="format-table"
>
<Ellipsis size={iconSize} />
<Table2 size={iconSize} />
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'more'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
{#if openDropdown === 'table'}
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
disabled={!canUndo()}
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-undo"
data-testid="format-table-insert"
>
<Undo2 size={iconSize} />
<span>Undo</span>
<Table2 size={iconSize} />
<span>Insert table</span>
</button>
<button
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
disabled={!canRedo()}
class={dropdownItemClass()}
data-testid="format-redo"
>
<Redo2 size={iconSize} />
<span>Redo</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('code') ?? false)}
data-testid="format-code"
>
<Code size={iconSize} />
<span>Inline code</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
<span>Blockquote</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
<span>Code block</span>
</button>
<button
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-hr"
>
<Minus size={iconSize} />
<span>Divider</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
{#if isActive('table')}
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-row"
>
<BetweenHorizontalEnd size={iconSize} />
<span>Add row</span>
</button>
<button
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-add-col"
>
<BetweenVerticalEnd size={iconSize} />
<span>Add column</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-row"
>
<Minus size={iconSize} />
<span>Delete row</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-table-delete-col"
>
<Minus size={iconSize} />
<span>Delete column</span>
</button>
<button
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
class={`${dropdownItemClass()} text-[var(--destructive)]`}
data-testid="format-table-delete"
>
<RemoveFormatting size={iconSize} />
<span>Delete table</span>
</button>
{/if}
</div>
{/if}
</div>
<!-- Alignment dropdown -->
<div class="relative" data-dropdown="align">
<button
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
title="Text alignment"
data-testid="format-align"
>
{#if isActive({ textAlign: 'center' })}
<AlignCenter size={iconSize} />
{:else if isActive({ textAlign: 'right' })}
<AlignRight size={iconSize} />
{:else if isActive({ textAlign: 'justify' })}
<AlignJustify size={iconSize} />
{:else}
<AlignLeft size={iconSize} />
{/if}
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Align left</span>
<span>Left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Align center</span>
<span>Center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Align right</span>
<span>Right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
data-testid="format-align-justify"
>
<AlignJustify size={iconSize} />
@@ -382,4 +443,40 @@
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Block-level formatting -->
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(isActive('code'))}
title="Inline code"
data-testid="format-code"
>
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(isActive('blockquote'))}
title="Blockquote"
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(isActive('codeBlock'))}
title="Code block"
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
class={btnClass()}
title="Divider"
data-testid="format-hr"
>
<Minus size={iconSize} />
</button>
</div>
+12 -3
View File
@@ -9,6 +9,8 @@
const { src, alt, onClose }: Props = $props();
let dialogEl: HTMLDivElement | undefined = $state();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
@@ -21,14 +23,21 @@
onClose();
}
}
$effect(() => {
dialogEl?.focus();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={dialogEl}
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
onclick={handleBackgroundClick}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
aria-label="Image lightbox"
tabindex="-1"
data-testid="image-lightbox"
>
<button
+10 -6
View File
@@ -119,14 +119,18 @@
{@const isPending = 'pending' in attachment && attachment.pending}
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
<div class="group relative">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<img
src={imgSrc}
alt={attachment.filename}
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
<button
type="button"
class="h-20 w-20 cursor-pointer rounded-sm p-0 border-0 bg-transparent overflow-hidden"
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
data-testid="attachment-thumbnail"
/>
>
<img
src={imgSrc}
alt={attachment.filename}
class="h-20 w-20 object-cover {isPending ? 'opacity-70' : ''}"
/>
</button>
{#if isPending}
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
<RefreshCw class="h-3 w-3 text-white" />
+54 -57
View File
@@ -1,15 +1,16 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, 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 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;
@@ -73,8 +74,9 @@
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<article
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
@@ -88,15 +90,19 @@
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
{#each featuredAttachments.slice(0, 3) as attachment}
<div class="relative min-w-0 flex-1">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<img
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
alt={attachment.filename}
class="h-24 w-full cursor-pointer object-cover"
loading="lazy"
<button
type="button"
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="card-thumbnail"
/>
>
<img
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
alt={attachment.filename}
class="h-24 w-full object-cover"
loading="lazy"
/>
</button>
</div>
{/each}
{#if featuredAttachments.length > 3}
@@ -107,16 +113,25 @@
</div>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
<!-- Status indicators (top-right) -->
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
{#if note.isShared && note.collaborators}
<CollaboratorPopover
collaborators={note.collaborators}
ownerName={note.isOwner ? 'You' : undefined}
/>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
</div>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
@@ -146,14 +161,6 @@
</div>
{/if}
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<TagChip {tag} />
{/each}
</div>
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
@@ -203,14 +210,25 @@
<Archive class="h-4 w-4" />
</button>
{/if}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
</button>
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
</button>
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
{/if}
</div>
@@ -219,24 +237,3 @@
{/if}
</article>
<style>
article :global(.prose li:has(.task-checkbox)) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
article :global(.prose li:has(.task-checkbox))::before {
display: none;
}
article :global(.prose li:has(.task-checkbox))::marker {
content: none;
}
article :global(.task-checkbox) {
margin-top: 0.15rem;
flex-shrink: 0;
}
</style>
+95 -5
View File
@@ -4,16 +4,21 @@
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import ShareDialog from './ShareDialog.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 } from '$lib/types/index.js';
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import ImageIcon from 'lucide-svelte/icons/image';
import Type from 'lucide-svelte/icons/type';
import FileCode from 'lucide-svelte/icons/file-code';
import FileText from 'lucide-svelte/icons/file-text';
import UserPlus from 'lucide-svelte/icons/user-plus';
import Users from 'lucide-svelte/icons/users';
interface Props {
note: Note | null;
@@ -22,18 +27,20 @@
}
const { note, isNew = false, onClose }: Props = $props();
const prefs = getPreferences();
// svelte-ignore state_referenced_locally
let title = $state(note?.title ?? '');
// svelte-ignore state_referenced_locally
let content = $state(note?.content ?? '');
// svelte-ignore state_referenced_locally
let color = $state<NoteColor>(note?.color ?? 'default');
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let showImageUpload = $state(false);
let rawMarkdownMode = $state(note?.checklistMode ?? false);
// svelte-ignore state_referenced_locally
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
@@ -47,6 +54,36 @@
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
let showShareDialog = $state(false);
// svelte-ignore state_referenced_locally
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
const isOwner = $derived(note?.isOwner !== false);
const isShared = $derived(collaboratorsList.length > 0);
// Use displayName from first user or 'You' for owner
const ownerName = $derived(isOwner ? 'You' : 'Owner');
function handleCollaboratorsUpdate(updated: Collaborator[]) {
collaboratorsList = updated;
// Update the note in the store
if (noteId) {
notes.update((list) =>
list.map((n) =>
n.id === noteId
? { ...n, collaborators: updated, isShared: updated.length > 0 }
: n
)
);
}
}
async function toggleShareDialog() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showShareDialog = !showShareDialog;
}
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
@@ -99,6 +136,21 @@
bgStyle = `background-color: ${colors.bg}`;
});
// 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 handleOverlayMousedown(e: MouseEvent) {
mousedownOnOverlay = e.target === e.currentTarget;
}
function handleOverlayClick() {
if (mousedownOnOverlay) saveAndClose();
mousedownOnOverlay = false;
}
async function saveAndClose() {
if (!title.trim() && !content.trim()) {
onClose();
@@ -160,13 +212,14 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onclick={saveAndClose}
onmousedown={handleOverlayMousedown}
onclick={handleOverlayClick}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
@@ -179,6 +232,13 @@
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
data-testid="note-title-input"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (textareaEl) textareaEl.focus();
else if (tiptapEditor) tiptapEditor.commands.focus('start');
}
}}
/>
<!-- Content -->
@@ -284,6 +344,26 @@
<FileCode class="h-5 w-5" />
{/if}
</button>
<!-- Share button (owner only for new shares, all for viewing) -->
{#if isOwner}
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
data-testid="share-toggle"
>
{#if isShared}
<Users class="h-5 w-5 text-[var(--primary)]" />
{:else}
<UserPlus class="h-5 w-5" />
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
<Users class="h-5 w-5" />
</span>
{/if}
</div>
<button
@@ -296,3 +376,13 @@
</div>
</div>
</div>
{#if showShareDialog && noteId}
<ShareDialog
noteId={noteId}
collaborators={collaboratorsList}
{ownerName}
onClose={() => (showShareDialog = false)}
onUpdate={handleCollaboratorsUpdate}
/>
{/if}
+3 -12
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import NoteCard from './NoteCard.svelte';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { dndzone, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import type { Note } from '$lib/types/index.js';
@@ -44,20 +43,12 @@
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
data-testid="note-grid"
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {}, delayTouchStart: 400 }}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each localItems as note (note.id)}
<div class="relative h-full" animate:flip={{ duration: flipDurationMs }}>
<div
use:dragHandle
aria-label="drag handle for {note.title || 'note'}"
class="absolute top-2 left-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
data-testid="note-drag-handle"
>
<GripVertical class="h-4 w-4" />
</div>
<div class="h-full outline-none" animate:flip={{ duration: flipDurationMs }}>
<NoteCard {note} {onEdit} fullHeight />
</div>
{/each}
+16 -16
View File
@@ -2,9 +2,7 @@
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import SortSelector from '$lib/components/SortSelector.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
import { sortMode } from '$lib/stores/sort.js';
import { onMount } from 'svelte';
import type { Note, NoteFilter } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
@@ -19,10 +17,11 @@
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
// Sync store with route props
// Sync store with route props and reload notes when filter changes
$effect(() => {
currentFilter.set(filter);
selectedTag.set(tag);
loadNotes(filter);
});
function openEditor(note: Note) {
@@ -55,7 +54,7 @@
</script>
{#if filter === 'all' && !tag}
<div class="mx-auto mb-6 max-w-xl">
<div class="mx-auto mb-6 hidden max-w-xl md:block">
<button
onclick={() => (showNewNote = true)}
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
@@ -65,18 +64,19 @@
Add a crumb...
</button>
</div>
<button
onclick={() => (showNewNote = true)}
class="fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border)] bg-[var(--primary)] text-white shadow-[var(--card-shadow)] transition-colors hover:bg-[var(--primary-hover)] md:hidden"
aria-label="Add a crumb"
data-testid="new-note-fab"
>
<Plus class="h-5 w-5" />
</button>
{/if}
{#if filter === 'all'}
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
<TagFilter />
<SortSelector />
</div>
{:else}
<div class="mb-4">
<TagFilter />
</div>
{/if}
<div class="mb-4">
<TagFilter />
</div>
{#if filter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
@@ -88,7 +88,7 @@
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
</div>
{/if}
@@ -96,7 +96,7 @@
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable={$sortMode === 'custom'}
draggable
dndType="unpinned-notes"
onReorder={handleReorder}
/>
@@ -1,9 +1,19 @@
<script lang="ts">
import zxcvbn from 'zxcvbn';
import type { ZXCVBNResult } from 'zxcvbn';
let { password = '' }: { password: string } = $props();
const result = $derived(password.length > 0 ? zxcvbn(password) : null);
let result = $state<ZXCVBNResult | null>(null);
$effect(() => {
if (password.length > 0) {
import('zxcvbn').then((mod) => {
result = mod.default(password);
});
} else {
result = null;
}
});
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
const colors = [
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts">
import type { Collaborator } from '$lib/types/index.js';
import X from 'lucide-svelte/icons/x';
import UserMinus from 'lucide-svelte/icons/user-minus';
import Search from 'lucide-svelte/icons/search';
import { showToast } from '$lib/stores/toast.js';
interface UserResult {
id: number;
displayName: string;
email: string;
}
interface Props {
noteId: string;
collaborators: Collaborator[];
ownerName: string;
onClose: () => void;
onUpdate: (collaborators: Collaborator[]) => void;
}
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
let searchQuery = $state('');
let searchResults = $state<UserResult[]>([]);
let isSearching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function handleSearch(query: string) {
clearTimeout(searchTimeout);
if (!query.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
isSearching = true;
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const users: UserResult[] = await res.json();
// Filter out existing collaborators
const collabIds = new Set(collaborators.map((c) => c.userId));
searchResults = users.filter((u) => !collabIds.has(u.id));
}
} catch {
// Search failed silently
} finally {
isSearching = false;
}
}, 300);
}
$effect(() => {
handleSearch(searchQuery);
});
async function addCollaborator(user: UserResult) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: user.id })
});
if (res.ok) {
const updated: Collaborator[] = await res.json();
onUpdate(updated);
searchQuery = '';
searchResults = [];
showToast(`Shared with ${user.displayName || user.email}`, 'success');
} else {
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
showToast(err.message || 'Failed to share', 'error');
}
} catch {
showToast('Failed to share note', 'error');
}
}
async function removeCollaborator(userId: number) {
try {
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
method: 'DELETE'
});
if (res.ok) {
const updated = collaborators.filter((c) => c.userId !== userId);
onUpdate(updated);
}
} catch {
showToast('Failed to remove collaborator', 'error');
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onclick={onClose}
onkeydown={handleKeydown}
data-testid="share-dialog-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
data-testid="share-dialog"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
<button
onclick={onClose}
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
title="Close"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Search input -->
<div class="relative px-4 pt-3">
<div class="relative">
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
<input
type="text"
placeholder="Search users..."
bind:value={searchQuery}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="share-search-input"
/>
</div>
<!-- Search results dropdown -->
{#if searchResults.length > 0}
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
{#each searchResults as user}
<li>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
onclick={() => addCollaborator(user)}
data-testid="share-user-result"
>
<span class="font-medium">{user.displayName || 'User'}</span>
{#if user.email}
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Current collaborators -->
<div class="px-4 py-3">
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
<ul class="space-y-1">
<!-- Owner -->
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
<span class="text-sm text-[var(--text)]">
{ownerName}
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
</span>
</li>
<!-- Collaborators -->
{#each collaborators as collab}
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
<span class="text-sm text-[var(--text)]">
{collab.displayName || collab.email}
</span>
<button
onclick={() => removeCollaborator(collab.userId)}
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
title="Remove"
data-testid="remove-collaborator-btn"
>
<UserMinus class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
</div>
</div>
</div>
-29
View File
@@ -1,29 +0,0 @@
<script lang="ts">
import Clock from 'lucide-svelte/icons/clock';
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
import Hand from 'lucide-svelte/icons/hand';
import { sortMode, type SortMode } from '$lib/stores/sort.js';
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
{ value: 'updated', label: 'Updated', icon: Clock },
{ value: 'created', label: 'Created', icon: CalendarPlus },
{ value: 'custom', label: 'Custom', icon: Hand }
];
</script>
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
{#each modes as mode}
<button
onclick={() => sortMode.set(mode.value)}
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
{$sortMode === mode.value
? 'bg-[var(--primary)] text-white'
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
data-testid="sort-{mode.value}"
aria-label="Sort by {mode.label}"
>
<mode.icon class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{mode.label}</span>
</button>
{/each}
</div>
+8 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
import CloudCheck from 'lucide-svelte/icons/cloud-check';
import CloudSync from 'lucide-svelte/icons/cloud-sync';
import CloudOff from 'lucide-svelte/icons/cloud-off';
@@ -44,7 +44,12 @@
});
</script>
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
<button
class="flex cursor-pointer items-center rounded-sm p-1 transition-colors hover:bg-[var(--border-subtle)]/50"
title="Sync status: {status} — click to sync"
onclick={() => sync()}
data-testid="sync-indicator"
>
{#if displayStatus === 'synced'}
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
@@ -54,4 +59,4 @@
{:else}
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
{/if}
</div>
</button>
+1 -1
View File
@@ -11,7 +11,7 @@
{#if onclick}
<button
{onclick}
class="rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
class="whitespace-nowrap rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
? 'bg-[var(--primary)] text-white'
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
data-testid="tag-chip"
+11 -1
View File
@@ -15,9 +15,19 @@
</script>
{#if $allTags.length > 0}
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
<!-- svelte-ignore css_unused_selector -->
<div class="tag-scroll flex gap-2 overflow-x-auto max-md:flex-nowrap md:flex-wrap" data-testid="tag-filter">
{#each $allTags as tag}
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
{/each}
</div>
{/if}
<style>
.tag-scroll {
scrollbar-width: none;
}
.tag-scroll::-webkit-scrollbar {
display: none;
}
</style>
+16 -1
View File
@@ -6,6 +6,12 @@
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Placeholder from '@tiptap/extension-placeholder';
import { Table } from '@tiptap/extension-table';
import { TableRow } from '@tiptap/extension-table-row';
import { TableHeader } from '@tiptap/extension-table-header';
import { TableCell } from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import { Markdown } from 'tiptap-markdown';
interface Props {
@@ -30,6 +36,12 @@
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder }),
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Markdown
],
content,
@@ -57,11 +69,14 @@
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
data-testid="tiptap-editor"
role="textbox"
aria-multiline="true"
tabindex="0"
onclick={() => editor?.commands.focus()}
></div>
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, users } from './db/schema.js';
import type { Db } from './db/index.js';
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
const STRANGER_ID = 3;
function seedUsers() {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
function shareNote(noteId: string) {
db.insert(noteCollaborators)
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('canAccessNote', () => {
it('should allow owner to access their note', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', OWNER_ID);
expect(result).toEqual({ canAccess: true, isOwner: true });
});
it('should allow collaborator to access shared note', () => {
seedNote('n1');
shareNote('n1');
const result = canAccessNote(db, 'n1', COLLAB_ID);
expect(result).toEqual({ canAccess: true, isOwner: false });
});
it('should deny non-collaborator access', () => {
seedNote('n1');
const result = canAccessNote(db, 'n1', STRANGER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
it('should return false for non-existent note', () => {
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
expect(result).toEqual({ canAccess: false, isOwner: false });
});
});
describe('requireNoteAccess', () => {
it('should throw 404 for unauthorized user', () => {
seedNote('n1');
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
});
it('should return isOwner for authorized owner', () => {
seedNote('n1');
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
});
});
describe('requireNoteOwnership', () => {
it('should throw 404 for collaborator', () => {
seedNote('n1');
shareNote('n1');
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
});
it('should not throw for owner', () => {
seedNote('n1');
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
});
});
describe('getUserId', () => {
it('should throw 401 when no user in locals', () => {
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
});
it('should return user id from locals', () => {
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
expect(id).toBe(42);
});
});
+36
View File
@@ -1,4 +1,7 @@
import { error } from '@sveltejs/kit';
import type { Db } from './db/index.js';
import { notes, noteCollaborators } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
type EventWithLocals = {
locals: App.Locals;
@@ -14,3 +17,36 @@ export function requireAdmin(event: EventWithLocals) {
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
return user;
}
/**
* Check if a user can access a note (as owner or collaborator).
*/
export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note) return { canAccess: false, isOwner: false };
if (note.userId === userId) return { canAccess: true, isOwner: true };
const collab = db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.get();
return { canAccess: !!collab, isOwner: false };
}
/**
* Require that a user can access a note. Throws 404 if no access.
*/
export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } {
const { canAccess, isOwner } = canAccessNote(db, noteId, userId);
if (!canAccess) throw error(404, 'Not found');
return { isOwner };
}
/**
* Require that a user is the owner of a note. Throws 404 if not owner.
*/
export function requireNoteOwnership(db: Db, noteId: string, userId: number): void {
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
if (!note || note.userId !== userId) throw error(404, 'Not found');
}
+8 -7
View File
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'fs';
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
@@ -16,6 +16,7 @@ if (!existsSync(ATTACHMENTS_DIR)) {
}
export async function saveAttachment(
db: Db,
noteId: string,
file: File,
userId: number,
@@ -59,7 +60,7 @@ export async function saveAttachment(
* Batch fetch attachments for multiple notes in a single query.
* Returns a Map of noteId → Attachment[].
*/
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map<string, Attachment[]> {
if (noteIds.length === 0) return new Map();
const rows = db
@@ -80,7 +81,7 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
return map;
}
export async function getAttachment(id: string, noteId: string) {
export async function getAttachment(db: Db, id: string, noteId: string) {
return db
.select()
.from(attachments)
@@ -88,11 +89,11 @@ export async function getAttachment(id: string, noteId: string) {
.get();
}
export async function getAttachmentsByNote(noteId: string) {
export async function getAttachmentsByNote(db: Db, noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
export async function updateAttachment(db: Db, id: string, noteId: string, data: { featured: boolean }) {
const [updated] = await db
.update(attachments)
.set({ featured: data.featured })
@@ -101,8 +102,8 @@ export async function updateAttachment(id: string, noteId: string, data: { featu
return updated ?? null;
}
export async function deleteAttachment(id: string, noteId: string) {
const attachment = await getAttachment(id, noteId);
export async function deleteAttachment(db: Db, id: string, noteId: string) {
const attachment = await getAttachment(db, id, noteId);
if (attachment) {
try {
await unlink(attachment.path);
+3 -3
View File
@@ -194,10 +194,10 @@ export function revokeAllSessions(userId: number, exceptToken?: string): void {
export async function createUser(
email: string,
displayName: string,
password: string,
password: string | null,
role: 'admin' | 'user' = 'user'
): Promise<User> {
const hash = await argon2.hash(password);
const hash = password ? await argon2.hash(password) : null;
const result = db
.insert(users)
.values({
@@ -205,7 +205,7 @@ export async function createUser(
displayName,
role,
passwordHash: hash,
authProvider: 'password',
authProvider: password ? 'password' : 'none',
createdAt: new Date()
})
.returning()
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('fetchCollaboratorsForNotes', () => {
it('should return Map with user details for shared notes', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
const map = fetchCollaboratorsForNotes(db, ['n1']);
const collabs = map.get('n1');
expect(collabs).toHaveLength(1);
expect(collabs![0].userId).toBe(COLLAB_ID);
expect(collabs![0].displayName).toBe('Collab');
expect(collabs![0].email).toBe('collab@test.com');
});
it('should return empty Map for notes with no collaborators', () => {
seedNote('n1');
const map = fetchCollaboratorsForNotes(db, ['n1']);
expect(map.size).toBe(0);
});
});
describe('addCollaborator', () => {
it('should insert collaborator record', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
const rows = db.select().from(noteCollaborators).all();
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(COLLAB_ID);
expect(rows[0].addedBy).toBe(OWNER_ID);
});
});
describe('removeCollaborator', () => {
it('should clean up noteUserState on removal', () => {
seedNote('n1');
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 })
.run();
removeCollaborator(db, 'n1', COLLAB_ID);
const collabs = db.select().from(noteCollaborators).all();
expect(collabs).toHaveLength(0);
const states = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
.all();
expect(states).toHaveLength(0);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { Db } from './db/index.js';
import { noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import type { Collaborator } from '$lib/types/index.js';
/**
* Batch fetch collaborators for multiple notes.
* Returns a Map of noteId → Collaborator[].
*/
export function fetchCollaboratorsForNotes(db: Db, noteIds: string[]): Map<string, Collaborator[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select({
noteId: noteCollaborators.noteId,
userId: noteCollaborators.userId,
displayName: users.displayName,
email: users.email,
addedAt: noteCollaborators.addedAt
})
.from(noteCollaborators)
.innerJoin(users, eq(noteCollaborators.userId, users.id))
.where(inArray(noteCollaborators.noteId, noteIds))
.all();
const map = new Map<string, Collaborator[]>();
for (const row of rows) {
const collab: Collaborator = {
userId: row.userId,
displayName: row.displayName,
email: row.email,
addedAt: row.addedAt
};
const existing = map.get(row.noteId);
if (existing) {
existing.push(collab);
} else {
map.set(row.noteId, [collab]);
}
}
return map;
}
/**
* Add a collaborator to a note.
*/
export function addCollaborator(db: Db, noteId: string, userId: number, addedBy: number): void {
db.insert(noteCollaborators)
.values({ noteId, userId, addedBy, addedAt: new Date() })
.run();
}
/**
* Remove a collaborator from a note. Also cleans up their per-user state.
*/
export function removeCollaborator(db: Db, noteId: string, userId: number): void {
db.delete(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
.run();
db.delete(noteUserState)
.where(and(eq(noteUserState.noteId, noteId), eq(noteUserState.userId, userId)))
.run();
}
/**
* Get all collaborator user IDs for a note.
*/
export function getCollaboratorIds(db: Db, noteId: string): number[] {
return db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(eq(noteCollaborators.noteId, noteId))
.all()
.map((r) => r.userId);
}
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './schema.js';
import { eq, and } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
function seedTwoUsers(db: BetterSQLite3Database<typeof schema>) {
db.insert(users)
.values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
])
.run();
}
function seedNote(db: BetterSQLite3Database<typeof schema>, id: string, userId: number) {
const now = new Date();
db.insert(notes).values({ id, userId, title: 'Test', content: '', createdAt: now, updatedAt: now }).run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedTwoUsers(db);
});
describe('noteCollaborators table', () => {
it('should add a collaborator to a note', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(1);
expect(result[0].userId).toBe(2);
expect(result[0].addedBy).toBe(1);
});
it('should enforce unique constraint on (noteId, userId)', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
expect(() =>
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run()
).toThrow();
});
it('should allow multiple collaborators per note', () => {
seedNote(db, 'n1', 1);
// Add a third user
db.insert(users)
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(noteCollaborators).values([
{ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() },
{ noteId: 'n1', userId: 3, addedBy: 1, addedAt: new Date() }
]).run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(2);
});
it('should remove a collaborator', () => {
seedNote(db, 'n1', 1);
db.insert(noteCollaborators)
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
.run();
db.delete(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, 'n1'), eq(noteCollaborators.userId, 2)))
.run();
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
expect(result).toHaveLength(0);
});
});
describe('noteUserState table', () => {
it('should store per-user pin/archive/sortOrder state', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 5 })
.run();
const result = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
.get();
expect(result).toBeDefined();
expect(result!.pinned).toBe(true);
expect(result!.archived).toBe(false);
expect(result!.sortOrder).toBe(5);
});
it('should enforce unique constraint on (noteId, userId)', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: false, archived: false, sortOrder: 0 })
.run();
expect(() =>
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 0 })
.run()
).toThrow();
});
it('should default to false/false/0 for new state', () => {
seedNote(db, 'n1', 1);
db.insert(noteUserState)
.values({ noteId: 'n1', userId: 2 })
.run();
const result = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
.get();
expect(result!.pinned).toBe(false);
expect(result!.archived).toBe(false);
expect(result!.sortOrder).toBe(0);
});
it('should allow independent state per user per note', () => {
seedNote(db, 'n1', 1);
db.insert(users)
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
.run();
db.insert(noteUserState).values([
{ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 1 },
{ noteId: 'n1', userId: 3, pinned: false, archived: true, sortOrder: 2 }
]).run();
const user2State = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2))).get();
const user3State = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 3))).get();
expect(user2State!.pinned).toBe(true);
expect(user2State!.archived).toBe(false);
expect(user3State!.pinned).toBe(false);
expect(user3State!.archived).toBe(true);
});
});
+52
View File
@@ -93,6 +93,21 @@ sqlite.exec(`
client_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
added_by INTEGER NOT NULL REFERENCES users(id),
added_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS note_user_state (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
@@ -100,6 +115,14 @@ sqlite.exec(`
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Migration: detect old schema (no email column on users) and add columns
@@ -168,6 +191,11 @@ sqlite.exec(`
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
@@ -201,8 +229,32 @@ try {
// Column already exists
}
// Migration: make password_hash nullable (for OAuth-only users)
const pwCol = (
sqlite.prepare("PRAGMA table_info('users')").all() as { name: string; notnull: number }[]
).find((col) => col.name === 'password_hash');
if (pwCol && pwCol.notnull) {
sqlite.exec(`
CREATE TABLE __new_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
DROP TABLE users;
ALTER TABLE __new_users RENAME TO users;
`);
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export type Db = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<typeof schema>;
export { sqlite };
+50
View File
@@ -128,6 +128,42 @@ export const syncLog = sqliteTable(
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
);
export const noteCollaborators = sqliteTable(
'note_collaborators',
{
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
addedBy: integer('added_by')
.references(() => users.id)
.notNull(),
addedAt: integer('added_at', { mode: 'timestamp' }).notNull()
},
(table) => [
uniqueIndex('note_collaborators_unique').on(table.noteId, table.userId),
index('note_collaborators_user_id_idx').on(table.userId)
]
);
export const noteUserState = sqliteTable(
'note_user_state',
{
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull()
},
(table) => [uniqueIndex('note_user_state_unique').on(table.noteId, table.userId)]
);
export const loginAttempts = sqliteTable(
'login_attempts',
{
@@ -139,3 +175,17 @@ export const loginAttempts = sqliteTable(
},
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
);
export const userPreferences = sqliteTable(
'user_preferences',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
key: text('key').notNull(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
},
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
);
+29
View File
@@ -68,6 +68,24 @@ export function createTestDb(options?: { seedUser?: boolean }) {
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE note_collaborators (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
added_by INTEGER NOT NULL REFERENCES users(id),
added_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX note_collaborators_unique ON note_collaborators(note_id, user_id);
CREATE INDEX note_collaborators_user_id_idx ON note_collaborators(user_id);
CREATE TABLE note_user_state (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX note_user_state_unique ON note_user_state(note_id, user_id);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
@@ -76,6 +94,8 @@ export function createTestDb(options?: { seedUser?: boolean }) {
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
thumbnail_path TEXT,
featured INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
@@ -107,6 +127,15 @@ export function createTestDb(options?: { seedUser?: boolean }) {
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE TABLE user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
`);
const db = drizzle(sqlite, { schema });
+17 -16
View File
@@ -12,6 +12,7 @@ import {
reorderNotes
} from '../notes-service.js';
import { saveAttachment } from '../attachments.js';
import { db } from '../db/index.js';
const NOTE_COLORS = [
'default',
@@ -45,7 +46,7 @@ export function createMcpServer(userId: number): McpServer {
tag: z.string().optional().describe('Filter by tag name')
},
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
let result = listNotes(userId, filter as NoteFilter);
let result = listNotes(db, userId, filter as NoteFilter);
if (tag) {
result = result.filter((n) =>
@@ -64,7 +65,7 @@ export function createMcpServer(userId: number): McpServer {
'Get a single note by ID with its tags and attachments',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const note = getNote(userId, id);
const note = getNote(db, userId, id);
if (!note) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -88,7 +89,7 @@ export function createMcpServer(userId: number): McpServer {
checklistMode: z.boolean().optional().describe('Enable checklist mode')
},
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
const note = createNote(userId, { title, content, color, checklistMode });
const note = createNote(db, userId, { title, content, color, checklistMode });
return {
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
};
@@ -107,7 +108,7 @@ export function createMcpServer(userId: number): McpServer {
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
},
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
const result = updateNote(userId, id, updates);
const result = updateNote(db, userId, id, updates);
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -126,7 +127,7 @@ export function createMcpServer(userId: number): McpServer {
'Move a note to trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { trashed: true });
const result = updateNote(db, userId, id, { trashed: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -144,7 +145,7 @@ export function createMcpServer(userId: number): McpServer {
'Restore a note from trash',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { trashed: false });
const result = updateNote(db, userId, id, { trashed: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -162,7 +163,7 @@ export function createMcpServer(userId: number): McpServer {
'Archive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { archived: true });
const result = updateNote(db, userId, id, { archived: true });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -180,7 +181,7 @@ export function createMcpServer(userId: number): McpServer {
'Unarchive a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const result = updateNote(userId, id, { archived: false });
const result = updateNote(db, userId, id, { archived: false });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -198,7 +199,7 @@ export function createMcpServer(userId: number): McpServer {
'Permanently delete a note',
{ id: z.string().describe('Note ID') },
async ({ id }: { id: string }) => {
const existing = getNote(userId, id);
const existing = getNote(db, userId, id);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -206,7 +207,7 @@ export function createMcpServer(userId: number): McpServer {
};
}
deleteNote(userId, id);
deleteNote(db, userId, id);
return {
content: [
{
@@ -223,7 +224,7 @@ export function createMcpServer(userId: number): McpServer {
'Search notes by title, content, or tag name',
{ query: z.string().describe('Search query') },
async ({ query }: { query: string }) => {
const results = searchNotes(userId, query);
const results = searchNotes(db, userId, query);
return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
};
@@ -231,7 +232,7 @@ export function createMcpServer(userId: number): McpServer {
);
server.tool('list_tags', 'List all tags', {}, async () => {
const allTags = listAllTags(userId);
const allTags = listAllTags(db, userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
};
@@ -245,7 +246,7 @@ export function createMcpServer(userId: number): McpServer {
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
},
async ({ id, pinned }: { id: string; pinned: boolean }) => {
const result = updateNote(userId, id, { pinned });
const result = updateNote(db, userId, id, { pinned });
if (!result) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -277,7 +278,7 @@ export function createMcpServer(userId: number): McpServer {
.describe('Array of note IDs with their new sort orders')
},
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
reorderNotes(userId, orders);
reorderNotes(db, userId, orders);
return {
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
};
@@ -292,7 +293,7 @@ export function createMcpServer(userId: number): McpServer {
imageUrl: z.string().url().describe('URL of the image to fetch')
},
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
const existing = getNote(userId, noteId);
const existing = getNote(db, userId, noteId);
if (!existing) {
return {
content: [{ type: 'text' as const, text: 'Note not found' }],
@@ -320,7 +321,7 @@ export function createMcpServer(userId: number): McpServer {
const filename = `upload_${Date.now()}.${ext}`;
const file = new File([buffer], filename, { type: contentType });
const attachment = await saveAttachment(noteId, file, userId);
const attachment = await saveAttachment(db, noteId, file, userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
+311
View File
@@ -0,0 +1,311 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { Db } from './db/index.js';
import {
listNotes,
getNote,
createNote,
updateNote,
deleteNote,
searchNotes,
reorderNotes
} from './notes-service.js';
let db: Db;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users)
.values([
{
email: 'owner@test.com',
displayName: 'Owner',
role: 'admin',
authProvider: 'password',
createdAt: new Date()
},
{
email: 'collab@test.com',
displayName: 'Collab',
role: 'user',
authProvider: 'password',
createdAt: new Date()
}
])
.run();
}
function seedNote(
id: string,
overrides: Partial<typeof notes.$inferInsert> = {}
) {
const now = new Date();
db.insert(notes)
.values({
id,
userId: OWNER_ID,
title: '',
content: '',
createdAt: now,
updatedAt: now,
...overrides
})
.run();
return id;
}
function shareNote(noteId: string, userId = COLLAB_ID) {
db.insert(noteCollaborators)
.values({
noteId,
userId,
addedBy: OWNER_ID,
addedAt: new Date()
})
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('listNotes', () => {
it('should return only active owned notes (excludes archived/trashed)', () => {
seedNote('active', { title: 'Active' });
seedNote('archived', { title: 'Archived', archived: true });
seedNote('trashed', { title: 'Trashed', trashed: true });
const result = listNotes(db, OWNER_ID);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Active');
});
it('should include shared notes with per-user state overlay', () => {
seedNote('shared', { title: 'Shared Note' });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 })
.run();
const result = listNotes(db, COLLAB_ID);
expect(result).toHaveLength(1);
expect(result[0].title).toBe('Shared Note');
expect(result[0].pinned).toBeTruthy();
expect(result[0].sortOrder).toBe(5);
});
it('should sort pinned first, then by updatedAt desc', () => {
const old = new Date('2024-01-01');
const recent = new Date('2024-01-02');
seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old });
seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent });
seedNote('old', { title: 'Old', updatedAt: old, createdAt: old });
const result = listNotes(db, OWNER_ID);
expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']);
});
it('should return only archived notes with archived filter', () => {
seedNote('active', { title: 'Active' });
seedNote('archived', { title: 'Archived', archived: true });
seedNote('shared', { title: 'Shared Archived' });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 })
.run();
// Owner sees their archived note
const ownerResult = listNotes(db, OWNER_ID, 'archived');
expect(ownerResult).toHaveLength(1);
expect(ownerResult[0].title).toBe('Archived');
// Collaborator sees shared note they archived
const collabResult = listNotes(db, COLLAB_ID, 'archived');
expect(collabResult).toHaveLength(1);
expect(collabResult[0].title).toBe('Shared Archived');
});
it('should exclude shared notes from trashed filter', () => {
seedNote('trashed', { title: 'Trashed', trashed: true });
seedNote('shared', { title: 'Shared' });
shareNote('shared');
const result = listNotes(db, COLLAB_ID, 'trashed');
expect(result).toHaveLength(0);
});
});
describe('getNote', () => {
it('should return null for non-existent note', () => {
expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull();
});
it('should return null if user has no access', () => {
seedNote('private', { title: 'Private' });
expect(getNote(db, COLLAB_ID, 'private')).toBeNull();
});
it('should overlay collaborator per-user state', () => {
seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 });
shareNote('shared');
db.insert(noteUserState)
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 })
.run();
const note = getNote(db, COLLAB_ID, 'shared');
expect(note).not.toBeNull();
expect(note!.pinned).toBeFalsy();
expect(note!.archived).toBeTruthy();
expect(note!.sortOrder).toBe(5);
});
});
describe('createNote', () => {
it('should generate UUID and set version=1', () => {
const note = createNote(db, OWNER_ID, { title: 'Test' });
expect(note.id).toBeDefined();
expect(note.id.length).toBe(36); // UUID format
expect(note.version).toBe(1);
});
it('should use provided ID when given', () => {
const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' });
expect(note.id).toBe('custom-id');
});
it('should extract tags from content', () => {
const note = createNote(db, OWNER_ID, {
title: 'My #todo note',
content: 'Some #important content'
});
expect(note.tags).toContain('todo');
expect(note.tags).toContain('important');
});
});
describe('updateNote', () => {
it('should allow owner to update shared fields directly', () => {
seedNote('n1', { title: 'Original' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' });
expect(updated).not.toBeNull();
expect(updated!.title).toBe('Updated');
});
it('should allow collaborator to edit content', () => {
seedNote('n1', { title: 'Original', content: 'Old' });
shareNote('n1');
const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' });
expect(updated).not.toBeNull();
expect(updated!.content).toBe('New content');
});
it('should route collaborator per-user fields to noteUserState', () => {
seedNote('n1', { title: 'Note', pinned: false });
shareNote('n1');
updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 });
// Owner's note pinned should be unchanged
const ownerView = getNote(db, OWNER_ID, 'n1');
expect(ownerView!.pinned).toBeFalsy();
// Collaborator sees their per-user state
const collabView = getNote(db, COLLAB_ID, 'n1');
expect(collabView!.pinned).toBeTruthy();
expect(collabView!.sortOrder).toBe(7);
});
it('should return null when collaborator tries to trash', () => {
seedNote('n1', { title: 'Note' });
shareNote('n1');
const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true });
expect(result).toBeNull();
});
it('should increment version on update', () => {
seedNote('n1', { title: 'V1' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' });
expect(updated!.version).toBe(2);
});
it('should extract and sync tags on title/content change', () => {
seedNote('n1', { title: 'Original' });
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
expect(updated!.tags).toContain('newtag');
});
});
describe('deleteNote', () => {
it('should allow owner to permanently delete', () => {
seedNote('n1', { title: 'Delete me' });
const result = deleteNote(db, OWNER_ID, 'n1');
expect(result).toBe(true);
expect(getNote(db, OWNER_ID, 'n1')).toBeNull();
});
it('should return false for non-existent or unauthorized', () => {
seedNote('n1', { title: 'Private' });
expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false);
expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false);
});
});
describe('searchNotes', () => {
it('should match title and content', () => {
seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' });
seedNote('n2', { title: 'Shopping list', content: 'Buy milk' });
const results = searchNotes(db, OWNER_ID, 'budget');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n1');
});
it('should include shared notes in results', () => {
seedNote('shared', { title: 'Shared doc', content: 'Collaboration' });
shareNote('shared');
const results = searchNotes(db, COLLAB_ID, 'Collaboration');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('shared');
});
it('should return empty array for empty query', () => {
seedNote('n1', { title: 'Test' });
expect(searchNotes(db, OWNER_ID, '')).toEqual([]);
});
});
describe('reorderNotes', () => {
it('should update owner sortOrder in notes table', () => {
seedNote('n1', { title: 'A' });
seedNote('n2', { title: 'B' });
reorderNotes(db, OWNER_ID, [
{ id: 'n1', sortOrder: 2 },
{ id: 'n2', sortOrder: 1 }
]);
const n1 = getNote(db, OWNER_ID, 'n1');
const n2 = getNote(db, OWNER_ID, 'n2');
expect(n1!.sortOrder).toBe(2);
expect(n2!.sortOrder).toBe(1);
});
it('should update collaborator sortOrder in noteUserState', () => {
seedNote('n1', { title: 'Note', sortOrder: 0 });
shareNote('n1');
reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]);
// Owner's sortOrder unchanged
const ownerView = getNote(db, OWNER_ID, 'n1');
expect(ownerView!.sortOrder).toBe(0);
// Collaborator sees their sortOrder
const collabView = getNote(db, COLLAB_ID, 'n1');
expect(collabView!.sortOrder).toBe(5);
});
});
+272 -84
View File
@@ -1,59 +1,156 @@
import { db as defaultDb } from './db/index.js';
import { notes, noteTags, tags } from './db/schema.js';
import { eq, and, desc, like, or, inArray } from 'drizzle-orm';
import type { Db } from './db/index.js';
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 type { NoteFilter } from '$lib/types/index.js';
const db = defaultDb;
interface NoteRow {
id: string;
userId: number;
title: string;
content: string;
color: string;
pinned: boolean;
archived: boolean;
trashed: boolean;
trashedAt: Date | null;
checklistMode: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
version: number;
}
export function listNotes(userId: number, filter: NoteFilter = 'all') {
function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
const noteIds = noteRows.map((n) => n.id);
const tagMap = fetchTagsForNotes(db, noteIds);
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
return noteRows.map((note) => {
const collaborators = collabMap.get(note.id) ?? [];
const isOwner = note.userId === userId;
const isShared = collaborators.length > 0;
return {
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? [],
collaborators,
isOwner,
isShared
};
});
}
/**
* Get shared notes for a user with per-user state overlay.
*/
function getSharedNotes(db: Db, userId: number, filter: 'all' | 'archived'): NoteRow[] {
const rows = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
filter === 'archived'
? sql`COALESCE(${noteUserState.archived}, 0) = 1`
: sql`COALESCE(${noteUserState.archived}, 0) = 0`
)
)
.all();
return rows as NoteRow[];
}
export function listNotes(db: Db, userId: number, filter: NoteFilter = 'all') {
// Owned notes
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
// Collaborators never see trashed notes — only owner's trashed notes
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const result = db
const ownedNotes = db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
.all() as NoteRow[];
const noteIds = result.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
return result.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
// Shared notes (not for trashed filter — collaborators don't see trash)
let sharedNotes: NoteRow[] = [];
if (filter !== 'trashed') {
sharedNotes = getSharedNotes(db, userId, filter === 'archived' ? 'archived' : 'all');
}
const combined = [...ownedNotes, ...sharedNotes];
combined.sort((a, b) => {
// Pinned first, then by updatedAt descending
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
return hydrateNotes(db, combined, userId);
}
export function getNote(userId: number, id: string) {
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
export function getNote(db: Db, userId: number, id: string) {
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) return null;
const note = db.select().from(notes).where(eq(notes.id, id)).get();
if (!note) return null;
const tagMap = fetchTagsForNotes([id]);
const attachmentMap = fetchAttachmentsForNotes([id]);
return {
...note,
tags: tagMap.get(id) ?? [],
attachments: attachmentMap.get(id) ?? []
};
let effectiveNote = note as NoteRow;
// For collaborators, overlay per-user state
if (!isOwner) {
const userState = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
effectiveNote = {
...note,
pinned: userState?.pinned ?? false,
archived: userState?.archived ?? false,
sortOrder: userState?.sortOrder ?? 0
};
}
const result = hydrateNotes(db, [effectiveNote], userId);
return result[0] ?? null;
}
export interface CreateNoteInput {
@@ -66,7 +163,7 @@ export interface CreateNoteInput {
sortOrder?: number;
}
export function createNote(userId: number, input: CreateNoteInput) {
export function createNote(db: Db, userId: number, input: CreateNoteInput) {
const now = new Date();
const id = input.id || uuidv4();
@@ -93,7 +190,7 @@ export function createNote(userId: number, input: CreateNoteInput) {
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(id, extractedTags, userId);
syncNoteTags(db, id, extractedTags, userId);
return { ...newNote, tags: extractedTags };
}
@@ -109,59 +206,97 @@ export interface UpdateNoteInput {
sortOrder?: number;
}
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
/** Per-user fields that go to noteUserState for collaborators */
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
export function updateNote(db: Db, userId: number, id: string, input: UpdateNoteInput) {
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) return null;
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
if (!existing) return null;
// Collaborators cannot trash notes
if (!isOwner && input.trashed !== undefined) {
return null;
}
const now = new Date();
const updates: Record<string, unknown> = {
// Shared fields: update the notes table (all participants can edit content)
const sharedUpdates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
let hasSharedUpdates = false;
if (input.title !== undefined) updates.title = input.title;
if (input.content !== undefined) updates.content = input.content;
if (input.color !== undefined) updates.color = input.color;
if (input.pinned !== undefined) updates.pinned = input.pinned;
if (input.archived !== undefined) updates.archived = input.archived;
if (input.trashed !== undefined) {
updates.trashed = input.trashed;
updates.trashedAt = input.trashed ? now : null;
if (input.title !== undefined) { sharedUpdates.title = input.title; 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; }
if (isOwner) {
// Owner: per-user fields go to notes table directly
if (input.pinned !== undefined) { sharedUpdates.pinned = input.pinned; hasSharedUpdates = true; }
if (input.archived !== undefined) { sharedUpdates.archived = input.archived; hasSharedUpdates = true; }
if (input.trashed !== undefined) {
sharedUpdates.trashed = input.trashed;
sharedUpdates.trashedAt = input.trashed ? now : null;
hasSharedUpdates = true;
}
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
}
if (input.checklistMode !== undefined) updates.checklistMode = input.checklistMode;
if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
db.update(notes)
.set(updates)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
if (hasSharedUpdates || isOwner) {
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
}
const updated = db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.get();
// Collaborator: per-user fields go to noteUserState
if (!isOwner) {
const hasPerUserFields = PER_USER_FIELDS.some((f) => input[f] !== undefined);
if (hasPerUserFields) {
const userState = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
if (userState) {
const stateUpdates: Record<string, unknown> = {};
if (input.pinned !== undefined) stateUpdates.pinned = input.pinned;
if (input.archived !== undefined) stateUpdates.archived = input.archived;
if (input.sortOrder !== undefined) stateUpdates.sortOrder = input.sortOrder;
db.update(noteUserState)
.set(stateUpdates)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.run();
} else {
db.insert(noteUserState)
.values({
noteId: id,
userId,
pinned: input.pinned ?? false,
archived: input.archived ?? false,
sortOrder: input.sortOrder ?? 0
})
.run();
}
}
}
if (input.title !== undefined || input.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
syncNoteTags(id, extractedTags, userId);
const updated = db.select().from(notes).where(eq(notes.id, id)).get();
if (updated) {
const content = `${updated.title} ${updated.content}`;
const extractedTags = extractTags(content);
syncNoteTags(db, id, extractedTags, existing.userId);
}
}
const tagMap = fetchTagsForNotes([id]);
const attachmentMap = fetchAttachmentsForNotes([id]);
return {
...updated,
tags: tagMap.get(id) ?? [],
attachments: attachmentMap.get(id) ?? []
};
return getNote(db, userId, id);
}
export function deleteNote(userId: number, id: string): boolean {
export function deleteNote(db: Db, userId: number, id: string): boolean {
const existing = db
.select()
.from(notes)
@@ -175,12 +310,13 @@ export function deleteNote(userId: number, id: string): boolean {
return true;
}
export function searchNotes(userId: number, query: string) {
export function searchNotes(db: Db, userId: number, query: string) {
if (!query) return [];
const pattern = `%${query}%`;
const results = db
// Owned notes matching query
const ownedResults = db
.select()
.from(notes)
.where(
@@ -192,6 +328,40 @@ export function searchNotes(userId: number, query: string) {
)
.all();
// Shared notes matching query
const sharedResults = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
)
.all();
// Tag search for owned notes
const tagResults = db
.select({ noteId: noteTags.noteId })
.from(noteTags)
@@ -199,12 +369,12 @@ export function searchNotes(userId: number, query: string) {
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set(results.map((n) => n.id));
const resultIds = new Set([...ownedResults.map((n) => n.id), ...sharedResults.map((n) => n.id)]);
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
(id) => !resultIds.has(id)
);
let extraNotes: typeof results = [];
let extraNotes: typeof ownedResults = [];
if (extraNoteIds.length > 0) {
extraNotes = db
.select()
@@ -213,24 +383,42 @@ export function searchNotes(userId: number, query: string) {
.all();
}
const combined = [...results, ...extraNotes];
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
return combined.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
return hydrateNotes(db, combined, userId);
}
export function listAllTags(userId: number) {
export function listAllTags(db: Db, userId: number) {
return db.select().from(tags).where(eq(tags.userId, userId)).all();
}
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
export function reorderNotes(db: Db, userId: number, orders: { id: string; sortOrder: number }[]) {
const now = new Date();
for (const { id, sortOrder } of orders) {
db.update(notes)
.set({ sortOrder, updatedAt: now })
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
const { canAccess, isOwner } = canAccessNote(db, id, userId);
if (!canAccess) continue;
if (isOwner) {
db.update(notes)
.set({ sortOrder, updatedAt: now })
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
} else {
// Collaborator: upsert into noteUserState
const existing = db
.select()
.from(noteUserState)
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.get();
if (existing) {
db.update(noteUserState)
.set({ sortOrder })
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
.run();
} else {
db.insert(noteUserState)
.values({ noteId: id, userId, pinned: false, archived: false, sortOrder })
.run();
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import type { Db } from './db/index.js';
import { getPreferences, upsertPreferences } from './preferences.js';
let db: Db;
const USER_ID = 1;
beforeEach(() => {
const testDb = createTestDb({ seedUser: true });
db = testDb.db;
});
describe('getPreferences', () => {
it('should return empty object for user with no preferences', () => {
const prefs = getPreferences(db, USER_ID);
expect(prefs).toEqual({});
});
});
describe('upsertPreferences', () => {
it('should insert new preferences', () => {
const result = upsertPreferences(db, USER_ID, {
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
expect(result).toEqual({
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
});
it('should update existing preferences', () => {
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' });
expect(result.defaultNoteMode).toBe('richtext');
});
it('should handle partial updates without affecting other keys', () => {
upsertPreferences(db, USER_ID, {
defaultNoteMode: 'markdown',
hideFooter: 'true'
});
const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' });
expect(result).toEqual({
defaultNoteMode: 'markdown',
hideFooter: 'false'
});
});
it('should keep preferences isolated between users', () => {
// Create a second user
db.run(
/* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})`
);
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
upsertPreferences(db, 2, { defaultNoteMode: 'richtext' });
expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown');
expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext');
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { Db } from './db/index.js';
import { userPreferences } from './db/schema.js';
import { eq } from 'drizzle-orm';
/**
* Get all preferences for a user as a key-value record.
*/
export function getPreferences(db: Db, userId: number): Record<string, string> {
const rows = db
.select({ key: userPreferences.key, value: userPreferences.value })
.from(userPreferences)
.where(eq(userPreferences.userId, userId))
.all();
const result: Record<string, string> = {};
for (const row of rows) {
result[row.key] = row.value;
}
return result;
}
/**
* Upsert multiple preferences for a user.
* Uses INSERT OR REPLACE with the unique (user_id, key) index.
*/
export function upsertPreferences(
db: Db,
userId: number,
prefs: Record<string, string>
): Record<string, string> {
const now = new Date();
for (const [key, value] of Object.entries(prefs)) {
db.insert(userPreferences)
.values({ userId, key, value, updatedAt: now })
.onConflictDoUpdate({
target: [userPreferences.userId, userPreferences.key],
set: { value, updatedAt: now }
})
.run();
}
return getPreferences(db, userId);
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { loginAttempts } from './db/schema.js';
import type { Db } from './db/index.js';
import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js';
let db: Db;
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
});
const IP = '192.168.1.1';
const EMAIL = 'test@test.com';
function addFailedAttempts(count: number, now: Date) {
for (let i = 0; i < count; i++) {
recordLoginAttempt(db, IP, EMAIL, false, now);
}
}
describe('checkRateLimit', () => {
it('should allow first attempt', () => {
const result = checkRateLimit(db, IP, EMAIL);
expect(result.allowed).toBe(true);
});
it('should allow up to 4 failed attempts', () => {
const now = new Date();
addFailedAttempts(4, now);
const result = checkRateLimit(db, IP, EMAIL, now);
expect(result.allowed).toBe(true);
});
it('should block after 5 failed attempts within window', () => {
const now = new Date();
addFailedAttempts(5, now);
const result = checkRateLimit(db, IP, EMAIL, now);
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeDefined();
expect(result.retryAfter!).toBeGreaterThan(0);
});
it('should return correct retryAfter value', () => {
const now = new Date();
addFailedAttempts(5, now);
const result = checkRateLimit(db, IP, EMAIL, now);
// Lockout is 15 minutes (900 seconds)
expect(result.retryAfter).toBe(900);
});
it('should allow again after lockout expires', () => {
const attemptTime = new Date('2024-01-01T00:00:00Z');
addFailedAttempts(5, attemptTime);
// Check at attempt time — should be blocked
const blocked = checkRateLimit(db, IP, EMAIL, attemptTime);
expect(blocked.allowed).toBe(false);
// Check 16 minutes later — should be allowed
const later = new Date(attemptTime.getTime() + 16 * 60 * 1000);
const allowed = checkRateLimit(db, IP, EMAIL, later);
expect(allowed.allowed).toBe(true);
});
});
describe('recordLoginAttempt', () => {
it('should insert attempt record', () => {
recordLoginAttempt(db, IP, EMAIL, false);
const rows = db.select().from(loginAttempts).all();
expect(rows).toHaveLength(1);
expect(rows[0].ip).toBe(IP);
expect(rows[0].email).toBe(EMAIL);
});
it('should record success/failure flag correctly', () => {
recordLoginAttempt(db, IP, EMAIL, true);
recordLoginAttempt(db, IP, EMAIL, false);
const rows = db.select().from(loginAttempts).all();
expect(rows[0].success).toBe(true);
expect(rows[1].success).toBe(false);
});
});
describe('clearOldAttempts', () => {
it('should remove attempts older than 2x lockout', () => {
const old = new Date('2024-01-01T00:00:00Z');
const recent = new Date('2024-01-01T01:00:00Z');
recordLoginAttempt(db, IP, EMAIL, false, old);
recordLoginAttempt(db, IP, EMAIL, false, recent);
// Clear at a time where old is > 2x lockout (30 min) ago but recent is not
const clearTime = new Date(old.getTime() + 31 * 60 * 1000);
clearOldAttempts(db, clearTime);
const rows = db.select().from(loginAttempts).all();
expect(rows).toHaveLength(1);
});
});
+13 -15
View File
@@ -1,21 +1,19 @@
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { loginAttempts } from './db/schema.js';
import { and, eq, gte, sql } from 'drizzle-orm';
import { and, eq, gte, lt, sql } from 'drizzle-orm';
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 1000; // 1 minute
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
export function checkRateLimit(
db: Db,
ip: string,
email: string
email: string,
now?: Date
): { allowed: boolean; retryAfter?: number } {
// Skip rate limiting in test environment
if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') {
return { allowed: true };
}
const windowStart = new Date(Date.now() - LOCKOUT_MS);
const currentTime = now ?? new Date();
const windowStart = new Date(currentTime.getTime() - LOCKOUT_MS);
// Count failed attempts from this IP in the lockout window
const result = db
@@ -50,7 +48,7 @@ export function checkRateLimit(
if (latest) {
const retryAfter = Math.ceil(
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
(latest.timestamp.getTime() + LOCKOUT_MS - currentTime.getTime()) / 1000
);
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
}
@@ -60,20 +58,20 @@ export function checkRateLimit(
return { allowed: true };
}
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
export function recordLoginAttempt(db: Db, ip: string, email: string, success: boolean, now?: Date): void {
db.insert(loginAttempts)
.values({
ip,
email,
success,
timestamp: new Date()
timestamp: now ?? new Date()
})
.run();
}
export function clearOldAttempts(): void {
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
export function clearOldAttempts(db: Db, now?: Date): void {
const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2);
db.delete(loginAttempts)
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
.where(lt(loginAttempts.timestamp, cutoff))
.run();
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { notes, tags, noteTags, users } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
let db: Db;
const USER_ID = 1;
function seedUser() {
db.insert(users)
.values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() })
.run();
}
function seedNote(id: string) {
db.insert(notes)
.values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() })
.run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUser();
});
describe('syncNoteTags', () => {
it('should create new tags and associations', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID);
const tagRows = db.select().from(tags).all();
expect(tagRows).toHaveLength(2);
const associations = db.select().from(noteTags).all();
expect(associations).toHaveLength(2);
});
it('should remove old associations and add new ones', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['old-tag'], USER_ID);
syncNoteTags(db, 'n1', ['new-tag'], USER_ID);
const map = fetchTagsForNotes(db, ['n1']);
expect(map.get('n1')).toEqual(['new-tag']);
});
it('should handle empty tag list (removes all)', () => {
seedNote('n1');
syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID);
syncNoteTags(db, 'n1', [], USER_ID);
const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all();
expect(associations).toHaveLength(0);
});
it('should deduplicate existing tags', () => {
seedNote('n1');
seedNote('n2');
syncNoteTags(db, 'n1', ['shared-tag'], USER_ID);
syncNoteTags(db, 'n2', ['shared-tag'], USER_ID);
// Should reuse the same tag row
const tagRows = db.select().from(tags).all();
expect(tagRows).toHaveLength(1);
});
});
describe('fetchTagsForNotes', () => {
it('should return Map of noteId to tag names', () => {
seedNote('n1');
seedNote('n2');
syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID);
syncNoteTags(db, 'n2', ['gamma'], USER_ID);
const map = fetchTagsForNotes(db, ['n1', 'n2']);
expect(map.get('n1')).toHaveLength(2);
expect(map.get('n1')).toContain('alpha');
expect(map.get('n1')).toContain('beta');
expect(map.get('n2')).toEqual(['gamma']);
});
it('should return empty map for empty noteIds', () => {
const map = fetchTagsForNotes(db, []);
expect(map.size).toBe(0);
});
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { db } from './db/index.js';
import type { Db } from './db/index.js';
import { noteTags, tags } from './db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
@@ -6,7 +6,7 @@ import { eq, and, inArray } from 'drizzle-orm';
* Batch fetch tags for multiple notes in a single query.
* Returns a Map of noteId → tag names.
*/
export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
export function fetchTagsForNotes(db: Db, noteIds: string[]): Map<string, string[]> {
if (noteIds.length === 0) return new Map();
const rows = db
@@ -32,7 +32,7 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
* Should be called within a transaction for atomicity.
*/
export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
export function syncNoteTags(db: Db, noteId: string, tagNames: string[], userId: number) {
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
for (const name of tagNames) {
+22 -13
View File
@@ -3,7 +3,6 @@ import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
import { showToast } from '$lib/stores/toast.js';
import { extractTags } from '$lib/utils/tags.js';
import { sortMode, type SortMode } from '$lib/stores/sort.js';
export const notes = writable<Note[]>([]);
export const currentFilter = writable<NoteFilter>('all');
@@ -28,25 +27,20 @@ export const filteredNotes = derived(
}
);
function sortNotes(list: Note[], mode: SortMode): Note[] {
function sortNotes(list: Note[]): Note[] {
return [...list].sort((a, b) => {
if (mode === 'created') {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
if (mode === 'custom') {
return a.sortOrder - b.sortOrder;
}
// default: updated
const orderDiff = a.sortOrder - b.sortOrder;
if (orderDiff !== 0) return orderDiff;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
export const pinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
sortNotes($notes.filter((n) => n.pinned), $sortMode)
export const pinnedNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => n.pinned))
);
export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
sortNotes($notes.filter((n) => !n.pinned), $sortMode)
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
sortNotes($notes.filter((n) => !n.pinned))
);
export const allTags = derived(notes, ($notes) => {
@@ -233,6 +227,21 @@ export async function togglePin(id: string, currentPinned: boolean): Promise<Not
return updateNote(id, { pinned: !currentPinned });
}
export async function leaveNote(id: string): Promise<boolean> {
try {
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
showToast('Failed to leave note', 'error');
return false;
} catch {
return false;
}
}
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
// Optimistic update
notes.update((list) =>
+114
View File
@@ -0,0 +1,114 @@
import { browser } from '$app/environment';
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
import type { UserPreferences } from '$lib/types/preferences.js';
const STORAGE_KEY = 'crumbs-preferences';
let preferences = $state<UserPreferences>({ ...DEFAULT_PREFERENCES });
function loadFromLocalStorage(): UserPreferences {
if (!browser) return { ...DEFAULT_PREFERENCES };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return { ...DEFAULT_PREFERENCES, ...parsed };
}
} catch {
// Corrupted localStorage — use defaults
}
return { ...DEFAULT_PREFERENCES };
}
function saveToLocalStorage(prefs: UserPreferences) {
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// localStorage full or unavailable
}
}
/** Convert server key-value record to typed UserPreferences */
function fromServerRecord(record: Record<string, string>): Partial<UserPreferences> {
const result: Partial<UserPreferences> = {};
for (const [key, value] of Object.entries(record)) {
if (key in DEFAULT_PREFERENCES) {
const k = key as keyof UserPreferences;
if (BOOLEAN_PREF_KEYS.has(k)) {
(result as Record<string, unknown>)[k] = value === 'true';
} else {
(result as Record<string, unknown>)[k] = value;
}
}
}
return result;
}
/** Convert UserPreferences to server key-value record */
function toServerRecord(prefs: Partial<UserPreferences>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(prefs)) {
result[key] = String(value);
}
return result;
}
/**
* Initialize preferences — load from localStorage, then sync from server.
*/
export function initPreferences() {
preferences = loadFromLocalStorage();
syncPreferencesFromServer();
}
/**
* Get the reactive preferences object.
*/
export function getPreferences(): UserPreferences {
return preferences;
}
/**
* Update a single preference. Saves to localStorage and pushes to server.
*/
export function updatePreference<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) {
preferences = { ...preferences, [key]: value };
saveToLocalStorage(preferences);
pushToServer({ [key]: value });
}
/**
* Pull preferences from server and merge into local state.
*/
export async function syncPreferencesFromServer() {
if (!browser) return;
try {
const res = await fetch('/api/preferences');
if (res.ok) {
const serverPrefs: Record<string, string> = await res.json();
const parsed = fromServerRecord(serverPrefs);
preferences = { ...DEFAULT_PREFERENCES, ...parsed };
saveToLocalStorage(preferences);
}
} catch {
// Offline — local state stands
}
}
/** Push preference changes to server */
async function pushToServer(partial: Partial<UserPreferences>) {
if (!browser) return;
try {
await fetch('/api/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toServerRecord(partial))
});
} catch {
// Offline — localStorage already updated
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
describe('preferences defaults', () => {
it('should have correct default values', () => {
expect(DEFAULT_PREFERENCES).toEqual({
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open'
});
});
it('should identify boolean preference keys', () => {
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).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);
});
});
-24
View File
@@ -1,24 +0,0 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export type SortMode = 'updated' | 'created' | 'custom';
const STORAGE_KEY = 'crumbs-sort-mode';
function getInitial(): SortMode {
if (browser) {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'updated' || stored === 'created' || stored === 'custom') {
return stored;
}
}
return 'updated';
}
export const sortMode = writable<SortMode>(getInitial());
if (browser) {
sortMode.subscribe((value) => {
localStorage.setItem(STORAGE_KEY, value);
});
}
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from '../server/db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from '../server/db/schema.js';
import { getChangesSince, processSyncPush } from './server.js';
let db: BetterSQLite3Database<typeof schema>;
const OWNER_ID = 1;
const COLLAB_ID = 2;
function seedUsers() {
db.insert(users).values([
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
]).run();
}
function seedSharedNote(id: string, overrides: Partial<typeof notes.$inferInsert> = {}) {
const now = new Date();
db.insert(notes).values({
id,
userId: OWNER_ID,
title: 'Shared Note',
content: 'Original content',
createdAt: now,
updatedAt: now,
...overrides
}).run();
db.insert(noteCollaborators).values({
noteId: id,
userId: COLLAB_ID,
addedBy: OWNER_ID,
addedAt: now
}).run();
}
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
seedUsers();
});
describe('getChangesSince — shared notes', () => {
it('should return shared notes updated after the given timestamp', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t2, createdAt: t1 });
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
expect(changes[0].id).toBe('n1');
expect(changes[0].title).toBe('Shared Note');
expect(changes[0].content).toBe('Original content');
});
it('should not return shared notes updated before the given timestamp', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const changes = await getChangesSince(db, t2.getTime(), COLLAB_ID);
expect(changes).toHaveLength(0);
});
it('should return per-user state overlay for collaborators', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
// Owner has pinned=true, archived=false on the note
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, archived: false });
// Collaborator has pinned=false, archived=true in noteUserState
db.insert(noteUserState).values({
noteId: 'n1',
userId: COLLAB_ID,
pinned: false,
archived: true,
sortOrder: 5
}).run();
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
// Should reflect the collaborator's per-user state, not the owner's
expect(changes[0].pinned).toBeFalsy();
expect(changes[0].archived).toBeTruthy();
expect(changes[0].sortOrder).toBe(5);
});
it('should default to false/0 when collaborator has no noteUserState', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
// Owner has pinned=true, sortOrder=10
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 });
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
// Without noteUserState, collaborator should get defaults (false/0), not owner's values
expect(changes[0].pinned).toBeFalsy();
expect(changes[0].sortOrder).toBe(0);
});
it('should not return trashed notes for collaborators', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true });
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(0);
});
it('should return owner edits to a shared note for the collaborator', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
// Simulate owner editing the note
const t2 = new Date('2024-01-02T00:00:00Z');
db.update(notes)
.set({ content: 'Updated by owner', updatedAt: t2, version: 2 })
.where(eq(notes.id, 'n1'))
.run();
// Collaborator syncs since t1
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(1);
expect(changes[0].content).toBe('Updated by owner');
expect(changes[0].version).toBe(2);
});
it('should return both owned and shared notes for a user who is both owner and collaborator on different notes', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
const t2 = new Date('2024-01-02T00:00:00Z');
// Note owned by COLLAB_ID
db.insert(notes).values({
id: 'owned-by-collab',
userId: COLLAB_ID,
title: 'My Own Note',
content: '',
createdAt: t1,
updatedAt: t2
}).run();
// Note shared with COLLAB_ID (owned by OWNER_ID)
seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 });
const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID);
expect(changes).toHaveLength(2);
const ids = changes.map((c) => c.id).sort();
expect(ids).toEqual(['owned-by-collab', 'shared-with-collab']);
});
});
describe('processSyncPush — shared notes', () => {
it('should allow collaborator to update shared content fields', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Updated by collab', content: 'New content' }
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Updated by collab');
expect(note.content).toBe('New content');
expect(note.version).toBe(2);
});
it('should route collaborator per-user fields to noteUserState', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { pinned: true, sortOrder: 3 }
}], COLLAB_ID);
// Per-user fields should be in noteUserState, not in notes
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.pinned).toBe(false); // Owner's pinned state unchanged
const userState = db.select().from(noteUserState)
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
.get();
expect(userState).toBeDefined();
expect(userState!.pinned).toBe(true);
expect(userState!.sortOrder).toBe(3);
});
it('should not allow collaborator to trash a note', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { trashed: true }
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.trashed).toBe(false);
});
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
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1,
data: { title: 'Stale edit' }
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Shared Note'); // Unchanged
});
it('should not allow non-collaborator to update the note', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
// Create note without sharing with user 3
db.insert(notes).values({
id: 'n1',
userId: OWNER_ID,
title: 'Private',
content: '',
createdAt: t1,
updatedAt: t1
}).run();
// Add a third user
db.insert(users).values({
email: 'stranger@test.com',
displayName: 'Stranger',
role: 'user',
authProvider: 'password',
createdAt: new Date()
}).run();
const t2 = t1.getTime() + 1000;
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Hacked' }
}], 3); // User 3 is not owner or collaborator
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.title).toBe('Private'); // Unchanged
});
it('should log sync operations', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', { updatedAt: t1, createdAt: t1 });
const t2 = t1.getTime() + 1000;
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t2,
data: { title: 'Updated' }
}], COLLAB_ID);
const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all();
expect(logs).toHaveLength(1);
expect(logs[0].userId).toBe(COLLAB_ID);
expect(logs[0].operation).toBe('update');
});
});
+119 -23
View File
@@ -1,14 +1,14 @@
import { db } from '$lib/server/db/index.js';
import { notes, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and } from 'drizzle-orm';
import type { Db } from '$lib/server/db/index.js';
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 type { SyncQueueItem } from './idb.js';
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise<void> {
export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId: number): Promise<void> {
const noteIdsToSyncTags: string[] = [];
db.transaction((tx) => {
@@ -17,24 +17,78 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
case 'create':
case 'update': {
if (!change.data) continue;
const existing = tx
.select()
.from(notes)
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.get();
if (existing) {
if (change.timestamp > existing.updatedAt.getTime()) {
// Check access: owner can update all fields, collaborator can update shared fields
const note = tx.select().from(notes).where(eq(notes.id, change.noteId)).get();
if (note) {
const isOwner = note.userId === userId;
const isCollaborator = !isOwner && tx
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, change.noteId), eq(noteCollaborators.userId, userId)))
.get();
if (!isOwner && !isCollaborator) continue;
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;
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({
...change.data,
updatedAt: new Date(change.timestamp),
version: existing.version + 1
})
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.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)
.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();
}
}
}
}
} else if (change.operation === 'create' && change.data) {
// Only owner can create notes
tx.insert(notes)
.values({
id: change.noteId,
@@ -60,6 +114,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
break;
}
case 'delete': {
// Only owner can delete
tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run();
break;
}
@@ -78,21 +133,62 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
});
for (const noteId of noteIdsToSyncTags) {
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
if (note) {
const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content), userId);
syncNoteTags(db, noteId, extractTags(content), note.userId);
}
}
}
/**
* Get all notes updated since a given timestamp for a specific user.
* Includes both owned and shared notes.
* For shared notes, per-user state (pinned, archived, sortOrder) is overlaid
* from noteUserState so collaborators get their own view.
*/
export async function getChangesSince(sinceTimestamp: number, userId: number) {
return db
export async function getChangesSince(db: Db, sinceTimestamp: number, userId: number) {
const since = new Date(sinceTimestamp);
// Owned notes
const ownedNotes = db
.select()
.from(notes)
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp))))
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, since)))
.all();
// Shared notes (via collaborator relationship) with per-user state overlay
const sharedNotes = db
.select({
id: notes.id,
userId: notes.userId,
title: notes.title,
content: notes.content,
color: notes.color,
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
createdAt: notes.createdAt,
updatedAt: notes.updatedAt,
version: notes.version
})
.from(noteCollaborators)
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
.leftJoin(
noteUserState,
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
)
.where(
and(
eq(noteCollaborators.userId, userId),
eq(notes.trashed, false),
gt(notes.updatedAt, since)
)
)
.all();
return [...ownedNotes, ...sharedNotes];
}
+13
View File
@@ -14,6 +14,16 @@ export interface Note {
version: number;
tags?: string[];
attachments?: Attachment[];
collaborators?: Collaborator[];
isOwner?: boolean;
isShared?: boolean;
}
export interface Collaborator {
userId: number;
displayName: string;
email: string;
addedAt: Date;
}
export interface NoteCreate {
@@ -87,3 +97,6 @@ export type NoteColor =
export type NoteFilter = 'all' | 'archived' | 'trashed';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
export type { UserPreferences } from './preferences.js';
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
+19
View File
@@ -0,0 +1,19 @@
import type { NoteColor } from './index.js';
export interface UserPreferences {
defaultNoteMode: 'richtext' | 'markdown';
defaultNoteColor: NoteColor;
hideFooter: boolean;
sidebarDefaultState: 'open' | 'collapsed';
}
export const DEFAULT_PREFERENCES: UserPreferences = {
defaultNoteMode: 'richtext',
defaultNoteColor: 'default',
hideFooter: false,
sidebarDefaultState: 'open'
};
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
'hideFooter'
]);
+11
View File
@@ -24,6 +24,17 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
const token = new state.Token('html_inline', '', 0);
token.content = checkbox;
tokens[i].children!.unshift(token);
// Add task-list class to parent <ul> (once)
for (let j = i - 1; j >= 0; j--) {
if (tokens[j].type === 'bullet_list_open') {
const cls = tokens[j].attrGet('class') ?? '';
if (!cls.includes('task-list')) {
tokens[j].attrJoin('class', 'task-list');
}
break;
}
}
}
}
});
+14 -4
View File
@@ -5,13 +5,21 @@
import { loadNotes } from '$lib/stores/notes.js';
import { startSync, stopSync } from '$lib/sync/client.js';
import { initDb } from '$lib/sync/idb.js';
import { initPreferences, getPreferences } from '$lib/stores/preferences.svelte.js';
import { onMount, onDestroy } from 'svelte';
let { data, children } = $props();
let sidebarOpen = $state(false);
const prefs = $derived(getPreferences());
onMount(() => {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
initPreferences();
const prefState = getPreferences();
if (prefState.sidebarDefaultState === 'collapsed') {
sidebarOpen = false;
} else {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
}
if (data.user) {
initDb(data.user.id);
}
@@ -34,9 +42,11 @@
</div>
</main>
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> &mdash; made with 🥨 in Strasbourg
</footer>
{#if !prefs.hideFooter}
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}" data-testid="app-footer">
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> &mdash; made with 🥨 in Strasbourg
</footer>
{/if}
<Toast />
</div>
+14 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
import { User, Cpu, ShieldCheck, SlidersHorizontal } from 'lucide-svelte';
let { data, children } = $props();
let pathname = $derived(page.url.pathname);
@@ -19,8 +19,18 @@
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
<li>
<a
href="/settings"
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
href="/settings/preferences"
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"
>
<SlidersHorizontal size={16} />
Preferences
</a>
</li>
<li>
<a
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)]'}"
>
<User size={16} />
Profile
@@ -32,7 +42,7 @@
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)]'}"
>
<Cpu size={16} />
MCP Server
API
</a>
</li>
{#if data.user?.role === 'admin'}
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(302, '/settings/preferences');
}
+19 -9
View File
@@ -26,7 +26,7 @@
let mcpJsonConfig = $derived(`{
"mcpServers": {
"crumbs": {
"type": "streamable-http",
"type": "http",
"url": "${baseUrl}/api/mcp",
"headers": {
"Authorization": "Bearer ${activeApiKey}"
@@ -37,12 +37,12 @@
let llmInstallPrompt = $derived(`Install the Crumbs MCP server with the following configuration:
- Type: Streamable HTTP
- Type: HTTP (Streamable HTTP)
- URL: ${baseUrl}/api/mcp
- Authentication: Bearer token in the Authorization header
- API Key: ${activeApiKey}
Add this to your MCP client settings (e.g. Claude Code's .claude/settings.json or Claude Desktop's config):
Add this to your MCP client settings (e.g. Claude Code's .claude/settings.json):
${mcpJsonConfig}`);
@@ -122,11 +122,11 @@ ${mcpJsonConfig}`);
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
</div>
<p class="mb-4 text-xs text-[var(--text-muted)]">
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
API keys allow external tools to access your notes via the MCP protocol and the REST API.
</p>
<!-- Create Key Form -->
<form onsubmit={createKey} class="mb-6 flex gap-2">
<form onsubmit={createKey} class="mb-6 flex flex-col gap-2 sm:flex-row">
<input
type="text"
bind:value={newKeyName}
@@ -182,7 +182,7 @@ ${mcpJsonConfig}`);
{:else}
<div class="space-y-2" data-testid="api-keys-list">
{#each keys as key (key.id)}
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3" data-testid="api-key-item">
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 sm:flex-row sm:items-center sm:justify-between" data-testid="api-key-item">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-[var(--text)]" data-testid="api-key-name">{key.name}</span>
@@ -195,7 +195,7 @@ ${mcpJsonConfig}`);
{/if}
</div>
</div>
<div class="ml-4">
<div class="sm:ml-4">
{#if deleteConfirmId === key.id}
<div class="flex items-center gap-1">
<button
@@ -229,11 +229,21 @@ ${mcpJsonConfig}`);
{/if}
</section>
<!-- REST API Example -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">REST API</h2>
<p class="mb-3 text-xs text-[var(--text-muted)]">
Use your API key to authenticate requests to any <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">/api/*</code> endpoint:
</p>
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 text-xs text-[var(--text)]">curl -H "Authorization: Bearer {activeApiKey}" \
{baseUrl}/api/notes</pre>
</section>
<!-- MCP Config Help -->
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">MCP Client Configuration</h2>
<p class="mb-3 text-xs text-[var(--text-muted)]">
Add this to your MCP client config (e.g. Claude Code settings):
Add this to your <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
</p>
<div class="relative">
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)]">{mcpJsonConfig}</pre>
@@ -0,0 +1,67 @@
<script lang="ts">
import ColorPicker from '$lib/components/ColorPicker.svelte';
import { getPreferences, updatePreference } from '$lib/stores/preferences.svelte.js';
import type { NoteColor } from '$lib/types/index.js';
const prefs = $derived(getPreferences());
</script>
<div class="space-y-8">
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
<!-- Default note mode -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Default note mode</span>
<div class="flex gap-1" role="group" aria-label="Default note mode">
<button
onclick={() => updatePreference('defaultNoteMode', 'richtext')}
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'richtext' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
data-testid="pref-mode-richtext"
>
Rich text
</button>
<button
onclick={() => updatePreference('defaultNoteMode', 'markdown')}
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.defaultNoteMode === 'markdown' ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
data-testid="pref-mode-markdown"
>
Markdown
</button>
</div>
</div>
<!-- Default note color -->
<div class="space-y-2">
<span class="block text-sm font-medium text-[var(--text)]">Default note color</span>
<ColorPicker
selected={prefs.defaultNoteColor}
onSelect={(color: NoteColor) => updatePreference('defaultNoteColor', color)}
/>
</div>
<!-- Hide footer -->
<div class="flex items-center gap-3">
<input
type="checkbox"
id="hide-footer"
checked={prefs.hideFooter}
onchange={() => updatePreference('hideFooter', !prefs.hideFooter)}
class="h-4 w-4 rounded-sm"
data-testid="pref-hide-footer"
/>
<label for="hide-footer" class="text-sm text-[var(--text)]">Hide footer</label>
</div>
<!-- Sidebar default state -->
<div class="flex items-center gap-3">
<input
type="checkbox"
id="sidebar-default"
checked={prefs.sidebarDefaultState === 'collapsed'}
onchange={() => updatePreference('sidebarDefaultState', prefs.sidebarDefaultState === 'collapsed' ? 'open' : 'collapsed')}
class="h-4 w-4 rounded-sm"
data-testid="pref-sidebar-default"
/>
<label for="sidebar-default" class="text-sm text-[var(--text)]">Start with sidebar collapsed</label>
</div>
</div>
@@ -14,7 +14,9 @@
let { data } = $props();
// Profile state
// svelte-ignore state_referenced_locally
let displayName = $state(data.user?.displayName || '');
// svelte-ignore state_referenced_locally
let email = $state(data.user?.email || '');
let profileMsg = $state('');
let profileError = $state(false);
@@ -326,7 +328,7 @@
<div class="mb-3 text-sm text-[var(--error-text)]">{deleteMsg}</div>
{/if}
<div class="flex items-center gap-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="password"
bind:value={deletePassword}
@@ -335,7 +337,7 @@
/>
<button
onclick={deleteAccount}
class="rounded-sm bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white hover:opacity-90"
class="whitespace-nowrap rounded-sm bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
Confirm delete
</button>
+30 -16
View File
@@ -3,6 +3,7 @@
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
let { data } = $props();
// svelte-ignore state_referenced_locally
let users = $state<User[]>(data.users);
// Create user form
@@ -10,17 +11,23 @@
let newDisplayName = $state('');
let newPassword = $state('');
let newRole = $state<'admin' | 'user'>('user');
let oauthOnly = $state(false);
let createMsg = $state('');
let createError = $state(false);
async function createUser() {
createMsg = '';
if (!newEmail || !newPassword) {
createMsg = 'Email and password are required';
if (!newEmail) {
createMsg = 'Email is required';
createError = true;
return;
}
if (newPassword.length < 8) {
if (!oauthOnly && !newPassword) {
createMsg = 'Password is required (or enable OAuth-only)';
createError = true;
return;
}
if (!oauthOnly && newPassword.length < 8) {
createMsg = 'Password must be at least 8 characters';
createError = true;
return;
@@ -32,7 +39,7 @@
body: JSON.stringify({
email: newEmail,
displayName: newDisplayName || newEmail.split('@')[0],
password: newPassword,
password: oauthOnly ? undefined : newPassword,
role: newRole
})
});
@@ -43,6 +50,7 @@
newDisplayName = '';
newPassword = '';
newRole = 'user';
oauthOnly = false;
createMsg = 'User created';
createError = false;
} else {
@@ -124,13 +132,19 @@
placeholder="Display name (optional)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<input
type="password"
bind:value={newPassword}
placeholder="Password (min 8 characters)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<PasswordStrengthMeter password={newPassword} />
<label class="flex items-center gap-2 text-sm text-[var(--text)]">
<input type="checkbox" bind:checked={oauthOnly} />
OAuth-only (no password)
</label>
{#if !oauthOnly}
<input
type="password"
bind:value={newPassword}
placeholder="Password (min 8 characters)"
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
/>
<PasswordStrengthMeter password={newPassword} />
{/if}
<select
bind:value={newRole}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
@@ -153,17 +167,17 @@
<div class="space-y-3">
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-4">
<div>
<p class="font-medium text-[var(--text)]">
<div class="flex flex-col gap-2 rounded-sm border border-[var(--border-subtle)] p-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="truncate font-medium text-[var(--text)]">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
{/if}
</p>
<p class="text-sm text-[var(--text-muted)]">{user.email}</p>
<p class="truncate text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<button
onclick={() => toggleRole(user)}
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
+1
View File
@@ -42,6 +42,7 @@
}
}
// svelte-ignore state_referenced_locally
const providers = data.providers ?? [];
</script>
+4 -4
View File
@@ -12,14 +12,14 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
requireAdmin(event);
const { email, displayName, password, role } = await request.json();
if (!email || !password) {
throw error(400, 'Email and password are required');
if (!email) {
throw error(400, 'Email is required');
}
if (password.length < 8) {
if (password && password.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
const user = await createUser(email, displayName || email.split('@')[0], password, role || 'user');
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
return json(user, { status: 201 });
};
+4 -3
View File
@@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit';
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';
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const ip = getClientAddress();
@@ -11,7 +12,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
throw error(400, 'Email and password are required');
}
const rateCheck = checkRateLimit(ip, email);
const rateCheck = checkRateLimit(db, ip, email);
if (!rateCheck.allowed) {
return json(
{ error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter },
@@ -21,11 +22,11 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
const user = await verifyPassword(email, password);
if (!user) {
recordLoginAttempt(ip, email, false);
recordLoginAttempt(db, ip, email, false);
throw error(401, 'Invalid email or password');
}
recordLoginAttempt(ip, email, true);
recordLoginAttempt(db, ip, email, true);
const userAgent = request.headers.get('user-agent') || undefined;
const token = await createSession(user.id, { userAgent, ip });
+7 -66
View File
@@ -1,79 +1,20 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
import { listNotes, createNote } from '$lib/server/notes-service.js';
import type { NoteFilter } from '$lib/types/index.js';
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const filter = url.searchParams.get('filter') || 'all';
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const result = db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
const noteIds = result.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const notesWithTags = result.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
return json(notesWithTags);
const filter = (url.searchParams.get('filter') || 'all') as NoteFilter;
const result = listNotes(db, userId, filter);
return json(result);
};
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const now = new Date();
const id = body.id || uuidv4();
const newNote = {
id,
userId,
title: body.title || '',
content: body.content || '',
color: body.color || 'default',
pinned: body.pinned || false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: body.checklistMode || false,
sortOrder: body.sortOrder || 0,
createdAt: now,
updatedAt: now,
version: 1
};
const content = `${newNote.title} ${newNote.content}`;
const extractedTags = extractTags(content);
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(id, extractedTags, userId);
return json({ ...newNote, tags: extractedTags }, { status: 201 });
const newNote = createNote(db, userId, body);
return json(newNote, { status: 201 });
};
+11 -77
View File
@@ -1,98 +1,32 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const note = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
const note = getNote(db, userId, params.id);
if (!note) throw error(404, 'Note not found');
if (!note) {
throw error(404, 'Note not found');
}
const tagMap = fetchTagsForNotes([note.id]);
const attachmentMap = fetchAttachmentsForNotes([note.id]);
return json({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
});
return json(note);
};
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
const updated = updateNote(db, userId, params.id, body);
if (!updated) throw error(404, 'Note not found');
if (!existing) {
throw error(404, 'Note not found');
}
const now = new Date();
const updates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
if (body.title !== undefined) updates.title = body.title;
if (body.content !== undefined) updates.content = body.content;
if (body.color !== undefined) updates.color = body.color;
if (body.pinned !== undefined) updates.pinned = body.pinned;
if (body.archived !== undefined) updates.archived = body.archived;
if (body.trashed !== undefined) {
updates.trashed = body.trashed;
updates.trashedAt = body.trashed ? now : null;
}
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
const updated = db.select().from(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).get();
if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
syncNoteTags(params.id, extractedTags, userId);
}
const tagMap = fetchTagsForNotes([params.id]);
const attachmentMap = fetchAttachmentsForNotes([params.id]);
return json({
...updated,
tags: tagMap.get(params.id) ?? [],
attachments: attachmentMap.get(params.id) ?? []
});
return json(updated);
};
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
requireNoteOwnership(db, params.id, userId);
if (!existing) {
throw error(404, 'Note not found');
}
const deleted = deleteNote(db, userId, params.id);
if (!deleted) throw error(404, 'Note not found');
db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
return json({ success: true });
};
@@ -3,29 +3,16 @@ import type { RequestHandler } from './$types.js';
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
import { readFile } from 'fs/promises';
import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { getUserId } from '$lib/server/api-utils.js';
/** Verify the note belongs to the authenticated user */
async function verifyNoteOwnership(noteId: string, userId: number) {
const note = await db
.select()
.from(notes)
.where(and(eq(notes.id, noteId), eq(notes.userId, userId)))
.get();
if (!note) throw error(404, 'Note not found');
return note;
}
import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(db, params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) {
const attachment = await getAttachment(attachmentId, params.id);
const attachment = await getAttachment(db, attachmentId, params.id);
if (!attachment) throw error(404, 'Attachment not found');
const wantThumb = url.searchParams.get('thumb') === '1';
@@ -42,13 +29,13 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
});
}
const list = await getAttachmentsByNote(params.id);
const list = await getAttachmentsByNote(db, params.id);
return json(list);
};
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(db, params.id, userId);
const formData = await request.formData();
const file = formData.get('file') as File;
@@ -67,13 +54,13 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => {
}
const thumbnail = formData.get('thumbnail') as File | null;
const attachment = await saveAttachment(params.id, file, userId, thumbnail);
const attachment = await saveAttachment(db, params.id, file, userId, thumbnail);
return json(attachment, { status: 201 });
};
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(db, params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
@@ -81,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
const body = await request.json();
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured });
const updated = await updateAttachment(db, attachmentId, params.id, { featured: body.featured });
if (!updated) throw error(404, 'Attachment not found');
return json(updated);
@@ -89,11 +76,11 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
requireNoteAccess(db, params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
await deleteAttachment(attachmentId, params.id);
await deleteAttachment(db, attachmentId, params.id);
return json({ success: true });
};
@@ -0,0 +1,73 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
import { db } from '$lib/server/db/index.js';
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params, ...event }) => {
const userId = getUserId(event);
requireNoteAccess(db, params.id, userId);
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
return json(collaborators.get(params.id) ?? []);
};
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
requireNoteOwnership(db, params.id, userId);
const body = await request.json();
const targetUserId = body.userId;
if (!targetUserId || typeof targetUserId !== 'number') {
throw error(400, 'userId is required');
}
// Cannot add yourself
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, params.id)).get();
if (note && note.userId === targetUserId) {
throw error(400, 'Cannot add the owner as a collaborator');
}
// Target user must exist
const targetUser = db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).get();
if (!targetUser) {
throw error(404, 'User not found');
}
// Check not already a collaborator
const existing = db
.select({ userId: noteCollaborators.userId })
.from(noteCollaborators)
.where(and(eq(noteCollaborators.noteId, params.id), eq(noteCollaborators.userId, targetUserId)))
.get();
if (existing) {
throw error(409, 'User is already a collaborator');
}
addCollaborator(db, params.id, targetUserId, userId);
const collaborators = fetchCollaboratorsForNotes(db, [params.id]);
return json(collaborators.get(params.id) ?? [], { status: 201 });
};
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
const userIdParam = url.searchParams.get('userId');
const targetUserId = userIdParam === 'self' ? userId : Number(userIdParam);
if (!targetUserId) {
throw error(400, 'userId query parameter is required');
}
const { isOwner } = requireNoteAccess(db, params.id, userId);
// Owner can remove anyone. Collaborator can only remove self.
if (!isOwner && targetUserId !== userId) {
throw error(403, 'Only the owner can remove other collaborators');
}
removeCollaborator(db, params.id, targetUserId);
return json({ success: true });
};
+2 -1
View File
@@ -1,5 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { reorderNotes } from '$lib/server/notes-service.js';
import { getUserId } from '$lib/server/api-utils.js';
@@ -12,6 +13,6 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
return json({ error: 'Invalid payload' }, { status: 400 });
}
reorderNotes(userId, orders);
reorderNotes(db, userId, orders);
return json({ ok: true });
};
+18
View File
@@ -0,0 +1,18 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { getUserId } from '$lib/server/api-utils.js';
import { getPreferences, upsertPreferences } from '$lib/server/preferences.js';
export const GET: RequestHandler = async (event) => {
const userId = getUserId(event);
const prefs = getPreferences(db, userId);
return json(prefs);
};
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const body: Record<string, string> = await request.json();
const updated = upsertPreferences(db, userId, body);
return json(updated);
};
+3 -47
View File
@@ -1,10 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { and, eq, like, or, inArray } from 'drizzle-orm';
import { fetchTagsForNotes } from '$lib/server/tags.js';
import { getUserId } from '$lib/server/api-utils.js';
import { searchNotes } from '$lib/server/notes-service.js';
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
@@ -14,48 +12,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => {
return json([]);
}
const pattern = `%${query}%`;
const results = db
.select()
.from(notes)
.where(
and(
eq(notes.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
)
.all();
// Also search by tag name
const tagResults = db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set(results.map((n) => n.id));
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
(id) => !resultIds.has(id)
);
let extraNotes: typeof results = [];
if (extraNoteIds.length > 0) {
extraNotes = db
.select()
.from(notes)
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.all();
}
const combined = [...results, ...extraNotes];
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
const withTags = combined.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
return json(withTags);
const results = searchNotes(db, userId, query);
return json(results);
};
+5 -4
View File
@@ -4,6 +4,7 @@ import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
import { fetchTagsForNotes } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
import { getUserId } from '$lib/server/api-utils.js';
import { db } from '$lib/server/db/index.js';
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
@@ -13,18 +14,18 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
return json({ error: 'Invalid changes' }, { status: 400 });
}
await processSyncPush(changes, userId);
await processSyncPush(db, changes, userId);
return json({ success: true });
};
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const since = parseInt(url.searchParams.get('since') || '0', 10);
const changes = await getChangesSince(since, userId);
const changes = await getChangesSince(db, since, userId);
const noteIds = changes.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const tagMap = fetchTagsForNotes(db, noteIds);
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
const changesWithTags = changes.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
+35
View File
@@ -0,0 +1,35 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { ne } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const query = url.searchParams.get('q')?.trim();
if (!query || query.length < 1) {
return json([]);
}
const pattern = `%${query}%`;
const results = db
.select({
id: users.id,
displayName: users.displayName,
email: users.email
})
.from(users)
.where(
ne(users.id, userId)
)
.all()
.filter(
(u) =>
u.displayName.toLowerCase().includes(query.toLowerCase()) ||
u.email.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 10);
return json(results);
};
+1 -49
View File
@@ -1,5 +1,5 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page, Locator } from '@playwright/test';
import type { Page } from '@playwright/test';
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
async function runTiptapCommand(page: Page, commandFn: string) {
@@ -38,54 +38,6 @@ test.describe('Rich text formatting', () => {
await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**');
});
test('Scenario: Italic shortcut formats selected text in the editor', async ({ authenticatedPage: page }) => {
// Given the user is editing a new note with content "hello world"
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'hello world');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "world" and applies italic formatting
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleItalic().run()');
// Then the selected text appears italic in the editor
await expect(editor.locator('em')).toHaveText('world');
});
test('Scenario: Formatting toolbar applies bold to selected text', async ({ authenticatedPage: page }) => {
// Given the user is editing a note
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'hello world');
// When the user selects "world" and clicks the bold button
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).run()');
await page.getByTestId('format-bold').click();
// Then the selected text appears bold
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await expect(editor.locator('strong')).toHaveText('world');
});
test('Scenario: Formatting toolbar is always visible when editing', async ({ authenticatedPage: page }) => {
// When the user opens a new note
await page.getByTestId('new-note-btn').click();
// Then the formatting toolbar is visible immediately
await expect(page.getByTestId('formatting-toolbar')).toBeVisible();
});
test('Scenario: Inline code shortcut wraps selected text', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with content "use npm install"
await page.getByTestId('new-note-btn').click();
await typeViaMarkdown(page, 'use npm install');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "npm install" and applies code formatting
await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:5,to:16}).toggleCode().run()');
// Then the selected text appears as inline code
await expect(editor.locator('code')).toHaveText('npm install');
});
test('Scenario: Existing note shows rich text in TipTap editor', async ({ authenticatedPage: page }) => {
// Given a note with bold markdown content exists
await page.getByTestId('new-note-btn').click();
+24
View File
@@ -0,0 +1,24 @@
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import type { Page } from '@playwright/test';
const COVERAGE_DIR = join(process.cwd(), 'coverage', 'e2e', 'raw');
let counter = 0;
/**
* Collect Istanbul coverage data from the browser page.
* The vite-plugin-istanbul injects `window.__coverage__` when COVERAGE=true.
*/
export async function collectCoverage(page: Page, testTitle: string) {
if (process.env.VITE_COVERAGE !== 'true') return;
const coverage = await page.evaluate(() => (window as any).__coverage__);
if (!coverage) return;
mkdirSync(COVERAGE_DIR, { recursive: true });
const safeName = testTitle.replace(/[^a-zA-Z0-9-_]/g, '_');
const filename = `coverage-${safeName}-${counter++}-${Date.now()}.json`;
writeFileSync(join(COVERAGE_DIR, filename), JSON.stringify(coverage));
}
+36 -3
View File
@@ -1,9 +1,14 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test';
import { collectCoverage } from './coverage';
const TEST_EMAIL = 'admin@test.com';
const TEST_PASSWORD = 'testpassword123';
const TEST_DISPLAY_NAME = 'Test Admin';
const COLLAB_EMAIL = 'collab@test.com';
const COLLAB_PASSWORD = 'collabpass123';
const COLLAB_NAME = 'Collaborator User';
/** Find a specific note card by its title text */
export function noteCard(page: Page, title: string): Locator {
return page.locator('[data-testid="note-card"]', { hasText: title });
@@ -12,11 +17,39 @@ export function noteCard(page: Page, title: string): Locator {
/**
* Extended test fixture that handles setup/auth.
*/
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
export const test = base.extend<{ authenticatedPage: Page; collabPage: Page }>({
authenticatedPage: async ({ page }, use, testInfo) => {
// Setup: create user and login
await setupAndLogin(page);
await use(page);
// Teardown: collect coverage data from the browser
await collectCoverage(page, testInfo.title);
},
collabPage: async ({ browser, authenticatedPage }, use, testInfo) => {
// Create collab user via admin API (idempotent)
const res = await authenticatedPage.request.post('/api/admin/users', {
data: {
email: COLLAB_EMAIL,
displayName: COLLAB_NAME,
password: COLLAB_PASSWORD,
role: 'user'
}
});
// Ignore 409 — user already exists
// Login as collaborator in a new browser context
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByTestId('email-input').fill(COLLAB_EMAIL);
await page.getByTestId('password-input').fill(COLLAB_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
await page.waitForLoadState('networkidle');
await use(page);
// Teardown: collect coverage data from the browser
await collectCoverage(page, testInfo.title + '-collab');
await context.close();
}
});
@@ -57,4 +90,4 @@ export async function setupAndLogin(page: Page) {
await page.waitForLoadState('networkidle');
}
export { expect, TEST_EMAIL, TEST_PASSWORD };
export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME };
-19
View File
@@ -47,23 +47,4 @@ test.describe('Notes CRUD', () => {
// Then the note is no longer visible
await expect(page.getByText('Delete Me')).not.toBeVisible();
});
test('Scenario: Bold formatting applies to selected text in the editor', async ({ authenticatedPage: page }) => {
// Given the user is editing a note with content "hello world"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('markdown-toggle').click();
await page.getByTestId('note-content-input').fill('hello world');
await page.getByTestId('markdown-toggle').click();
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
// When the user selects "world" and applies bold formatting
await page.getByTestId('tiptap-editor').evaluate((el) => {
const ed = (el as any).__tiptapEditor;
if (!ed) return;
ed.chain().focus().setTextSelection({ from: 7, to: 12 }).toggleBold().run();
});
// Then the selected text appears bold in the editor
await expect(editor.locator('strong')).toHaveText('world');
});
});
+1 -40
View File
@@ -70,7 +70,7 @@ test.describe('Organization Features', () => {
});
test.describe('Note Sorting', () => {
test('Scenario: Notes can be sorted by last updated', async ({ authenticatedPage: page }) => {
test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => {
// Given two notes exist with different update times
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sort-First');
@@ -82,9 +82,6 @@ test.describe('Note Sorting', () => {
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Sort-Second')).toBeVisible();
// When the user selects "Updated" sort mode
await page.getByTestId('sort-updated').click();
// Then Sort-Second (most recently updated) appears before Sort-First
const sortFirst = noteCard(page, 'Sort-First');
const sortSecond = noteCard(page, 'Sort-Second');
@@ -92,40 +89,4 @@ test.describe('Note Sorting', () => {
const secondBox = await sortFirst.boundingBox();
expect(firstBox!.y).toBeLessThanOrEqual(secondBox!.y);
});
test('Scenario: Notes can be sorted by creation date', async ({ authenticatedPage: page }) => {
// Given two notes exist
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Created-Older');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Created-Older')).toBeVisible();
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Created-Newer');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Created-Newer')).toBeVisible();
// When the user selects "Created" sort mode
await page.getByTestId('sort-created').click();
// Then Created-Newer appears before Created-Older
const newer = noteCard(page, 'Created-Newer');
const older = noteCard(page, 'Created-Older');
const newerBox = await newer.boundingBox();
const olderBox = await older.boundingBox();
expect(newerBox!.y).toBeLessThanOrEqual(olderBox!.y);
});
test('Scenario: Sort preference persists across page reload', async ({ authenticatedPage: page }) => {
// Given the user selects "Created" sort mode
await page.getByTestId('sort-created').click();
// When the page is reloaded
await page.reload();
await page.waitForLoadState('networkidle');
// Then the "Created" sort mode is still active
const createdBtn = page.getByTestId('sort-created');
await expect(createdBtn).toHaveClass(/bg-\[var\(--primary\)\]/);
});
});
-3
View File
@@ -13,7 +13,6 @@ test.describe('Search', () => {
// When the user searches for "Grocery"
await page.getByTestId('search-input').fill('Grocery');
await page.waitForTimeout(500);
// Then "Grocery Errands" is visible in the results
await expect(page.getByText('Grocery Errands')).toBeVisible();
@@ -30,7 +29,6 @@ test.describe('Search', () => {
// When the user searches for "tomato"
await page.getByTestId('search-input').fill('tomato');
await page.waitForTimeout(500);
// Then "Recipe" is visible in the results
await expect(page.getByText('Recipe')).toBeVisible();
@@ -39,7 +37,6 @@ test.describe('Search', () => {
test('Scenario: Searching for a nonexistent term yields no results', async ({ authenticatedPage: page }) => {
// When the user searches for a term that matches nothing
await page.getByTestId('search-input').fill('xyznonexistent');
await page.waitForTimeout(500);
// Then no notes are displayed
await expect(page.getByTestId('note-card')).toHaveCount(0);
+74 -6
View File
@@ -1,5 +1,73 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Settings — Preferences', () => {
test('Scenario: Preferences tab is visible in settings nav', async ({
authenticatedPage: page
}) => {
// When the user navigates to settings
await page.goto('/settings');
// Then the Preferences nav link is visible
await expect(page.getByTestId('settings-nav-preferences')).toBeVisible();
});
test('Scenario: Default note mode change applies to new notes', async ({
authenticatedPage: page
}) => {
// Given the user sets default note mode to Markdown
await page.goto('/settings/preferences');
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
await page.getByTestId('pref-mode-markdown').click();
await putResponse;
// When the user creates a new note
await page.goto('/');
await page.getByTestId('new-note-btn').click();
// Then the note editor opens in markdown mode (textarea visible)
await expect(page.getByTestId('note-content-input')).toBeVisible();
});
test('Scenario: Footer toggle hides and shows footer', async ({
authenticatedPage: page
}) => {
// Given the footer is visible
await page.goto('/');
await expect(page.getByTestId('app-footer')).toBeVisible();
// When the user enables the hide footer preference
await page.goto('/settings/preferences');
await page.getByTestId('pref-hide-footer').check();
// Then the footer is hidden
await page.goto('/');
await expect(page.getByTestId('app-footer')).not.toBeVisible();
// When the user disables the hide footer preference
await page.goto('/settings/preferences');
await page.getByTestId('pref-hide-footer').uncheck();
// Then the footer is visible again
await page.goto('/');
await expect(page.getByTestId('app-footer')).toBeVisible();
});
test('Scenario: Preferences persist across page reload', async ({
authenticatedPage: page
}) => {
// Given the user changes default note mode to Markdown
await page.goto('/settings/preferences');
await page.getByTestId('pref-mode-markdown').click();
// When the page is reloaded
await page.reload();
// Then the Markdown button is still selected (has primary styling)
const mdBtn = page.getByTestId('pref-mode-markdown');
await expect(mdBtn).toHaveClass(/font-medium/);
});
});
test.describe('Settings — API Key Management', () => {
test('Scenario: Created API key appears in the keys list', async ({
authenticatedPage: page
@@ -46,8 +114,8 @@ test.describe('Settings — API Key Management', () => {
// When the user clicks the Settings link in the sidebar
await page.getByTestId('settings-link').click();
// Then the settings page is shown
await expect(page).toHaveURL('/settings');
// Then the settings page redirects to preferences
await expect(page).toHaveURL('/settings/preferences');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
@@ -57,15 +125,15 @@ test.describe('Settings — API Key Management', () => {
// Given the user is on the settings page
await page.goto('/settings');
// When the user clicks the MCP Server nav link
await page.getByRole('link', { name: 'MCP Server' }).click();
// When the user clicks the API nav link
await page.getByRole('link', { name: 'API' }).click();
// Then the MCP settings page is shown
// Then the API settings page is shown
await expect(page).toHaveURL('/settings/mcp');
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible();
// When the user clicks back to Profile
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page).toHaveURL('/settings');
await expect(page).toHaveURL('/settings/profile');
});
});
+157
View File
@@ -0,0 +1,157 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
/**
* Create a note via API and share it with the collaborator.
* Returns the note object (with id).
*/
async function createSharedNote(
ownerPage: Page,
collabPage: Page,
title: string,
content = 'Shared content'
) {
// Create note
const res = await ownerPage.request.post('/api/notes', {
data: { title, content }
});
const note = await res.json();
// Find the collab user ID
const usersRes = await ownerPage.request.get('/api/users/search?q=collab');
const users = await usersRes.json();
const collabUser = users[0];
// Share the note
await ownerPage.request.post(`/api/notes/${note.id}/collaborators`, {
data: { userId: collabUser.id }
});
return note;
}
test.describe('Note Sharing', () => {
test('Scenario: Owner shares a note via UI and collaborator sees it', async ({
authenticatedPage: page,
collabPage
}) => {
// When the owner creates a note titled "Share Test"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Share Test');
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Shared content');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Share Test')).toBeVisible();
// And the owner opens the share dialog and adds the collaborator
await noteCard(page, 'Share Test').click();
await page.getByTestId('share-toggle').click();
await expect(page.getByTestId('share-dialog')).toBeVisible();
await page.getByTestId('share-search-input').fill('collab');
await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 });
await page.getByTestId('share-user-result').first().click();
await expect(page.getByTestId('share-collaborator')).toBeVisible();
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
await page.getByTestId('close-editor-btn').click();
// Then the owner sees a collaborator indicator
await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible();
// And the collaborator sees the shared note in their list
await collabPage.reload();
await collabPage.waitForLoadState('networkidle');
await expect(noteCard(collabPage, 'Share Test')).toBeVisible({ timeout: 10000 });
});
test('Scenario: Collaborator can edit shared note and owner sees changes', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Collab Edit Test');
// When the collaborator edits the note via API
await collabPage.request.patch(`/api/notes/${note.id}`, {
data: { content: 'Edited by collaborator' }
});
// Then the owner sees the updated content
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
const ownerNote = await ownerRes.json();
expect(ownerNote.content).toBe('Edited by collaborator');
});
test('Scenario: Owner and collaborator have independent per-user state', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Pin Independence Test');
// When the collaborator pins the note
await collabPage.request.patch(`/api/notes/${note.id}`, {
data: { pinned: true }
});
// Then the collaborator sees it as pinned
const collabRes = await collabPage.request.get(`/api/notes/${note.id}`);
const collabNote = await collabRes.json();
expect(collabNote.pinned).toBeTruthy();
// But the owner does not see it as pinned
const ownerRes = await page.request.get(`/api/notes/${note.id}`);
const ownerNote = await ownerRes.json();
expect(ownerNote.pinned).toBeFalsy();
});
test('Scenario: Sync returns correct data for both owner and collaborator', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Sync Round-trip Test');
// When the owner updates the content
await page.request.patch(`/api/notes/${note.id}`, {
data: { content: 'Owner sync update' }
});
// Then the collaborator sees the change via sync endpoint
const syncRes = await collabPage.request.get(`/api/sync?since=0`);
const syncedNotes = await syncRes.json();
const syncedNote = syncedNotes.find((n: { id: string }) => n.id === note.id);
expect(syncedNote).toBeDefined();
expect(syncedNote.content).toBe('Owner sync update');
});
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
authenticatedPage: page,
collabPage
}) => {
// Given a shared note exists
const note = await createSharedNote(page, collabPage, 'Leave Test Note');
// When the collaborator views their notes
await collabPage.reload();
await collabPage.waitForLoadState('networkidle');
await expect(noteCard(collabPage, 'Leave Test Note')).toBeVisible({ timeout: 10000 });
// Then the collaborator's card shows a leave button, not trash
const card = noteCard(collabPage, 'Leave Test Note');
await card.hover();
await expect(card.getByTestId('leave-btn')).toBeVisible();
await expect(card.getByTestId('trash-btn')).not.toBeVisible();
// When the collaborator leaves
await card.getByTestId('leave-btn').click({ force: true });
// Then the note disappears from the collaborator's list
await expect(noteCard(collabPage, 'Leave Test Note')).not.toBeVisible({ timeout: 5000 });
// But the owner still has it
await page.reload();
await page.waitForLoadState('networkidle');
await expect(noteCard(page, 'Leave Test Note')).toBeVisible();
});
});
+15
View File
@@ -1,12 +1,27 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import tailwindcss from '@tailwindcss/vite';
import istanbul from 'vite-plugin-istanbul';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
chunkSizeWarningLimit: 850
},
plugins: [
tailwindcss(),
sveltekit(),
...(process.env.VITE_COVERAGE === 'true'
? [
istanbul({
include: 'src/**/*',
exclude: ['node_modules', 'tests/', '**/*.test.ts'],
extension: ['.ts', '.svelte'],
requireEnv: true,
forceBuildInstrument: true
})
]
: []),
SvelteKitPWA({
registerType: 'autoUpdate',
manifest: {
+14 -1
View File
@@ -1,8 +1,21 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
$lib: path.resolve(__dirname, 'src/lib')
}
},
test: {
include: ['src/**/*.test.ts'],
environment: 'node'
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov', 'json-summary'],
reportsDirectory: './coverage/unit',
include: ['src/lib/**/*.ts'],
exclude: ['src/lib/**/*.test.ts', 'src/lib/types/**']
}
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+12 -8
View File
@@ -4,15 +4,19 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crumbs - Your notes. Your server. Your rules.</title>
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app. A privacy-respecting Google Keep alternative you can run on your own server with a single Docker command.">
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app that respects your privacy. Deploy with a single Docker command.">
<meta property="og:title" content="Crumbs by Bretzel">
<meta property="og:description" content="Self-hostable, offline-first note-taking. A Google Keep alternative that respects your privacy.">
<meta property="og:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://crumbs.bretzel.app">
<meta property="og:image" content="https://crumbs.bretzel.app/assets/og-image.png">
<meta name="twitter:card" content="summary_large_image">
<meta property="og:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Crumbs by Bretzel">
<meta name="twitter:description" content="A self-hostable, offline-first note-taking app that respects your privacy.">
<meta name="twitter:image" content="https://crumbs.bretzel.app/assets/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png">
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
<link rel="stylesheet" href="styles.css">
</head>
@@ -58,7 +62,7 @@
Your notes. Your server.<br><span>Your rules.</span>
</h1>
<p class="hero-subtitle animate-in delay-1">
A self-hostable, offline-first note-taking app. The Google Keep alternative that respects your privacy.
A self-hostable, offline-first note-taking app that respects your privacy.
</p>
<div class="hero-buttons animate-in delay-2">
<a href="#deploy" class="btn btn-primary">
@@ -141,8 +145,8 @@
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
</div>
<h3>MCP Server</h3>
<p>Built-in AI integration. Connect Claude, Cursor, or any MCP client to read and write your notes.</p>
<h3>API & MCP</h3>
<p>Full REST API with token auth. Integrate with n8n, Zapier, or scripts. Built-in MCP server for AI tools like Claude and Cursor.</p>
</div>
<!-- Multi-User -->
@@ -249,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.9.0</span>
<span class="footer-version">v0.18.0</span>
</div>
</footer>