Compare commits

..
101 Commits
Author SHA1 Message Date
github-actions[bot] 719b32ad16 chore: bump version to 0.8.1 2026-03-09 00:29:30 +00:00
Frederic CabassutandClaude Opus 4.6 522fdbfd13 style: restyle settings pages to match parchment design system
- Move settings routes into (app) layout group for header/sidebar
- Replace all gray/white/dark-mode Tailwind classes with CSS variables
- Use rounded-sm, card-shadow, and parchment color tokens throughout
- Fix PasswordStrengthMeter to use themed colors
- Fix page titles from "KeepNotes" to "Crumbs"

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

Also install missing zxcvbn and arctic dependencies from phase2 merge.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #12

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

Closes #11

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

Closes #9

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

Closes #10

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:07:56 +01:00
github-actions[bot] 2465d1b93a chore: pin docker-compose to v0.3.3 [skip ci] 2026-03-07 07:09:56 +00:00
github-actions[bot] b8a87d1626 chore: bump version to 0.3.3 2026-03-07 07:04:44 +00:00
Frederic CabassutandClaude Opus 4.6 b7b9bca29a fix: update docker-compose only after Docker image is pushed
Move docker-compose.yml update from release.sh (pre-build) to a
post-release job in both bump.yml and release.yml. This ensures the
image tag in docker-compose only appears on main after the image
exists on ghcr.io, preventing Dokploy from trying to pull a
non-existent image.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:50:10 +01:00
114 changed files with 5990 additions and 928 deletions
+17 -1
View File
@@ -1,8 +1,24 @@
# Database path (relative to project root or absolute)
DATABASE_URL=./data/crumbs.db
# Server origin (required for CSRF protection)
# Server origin (required for CSRF protection and OAuth redirect URLs)
ORIGIN=http://localhost:3000
# Node environment
NODE_ENV=development
# --- OAuth Providers (optional, auto-enabled when set) ---
# Google OAuth 2.0
# AUTH_GOOGLE_CLIENT_ID=
# AUTH_GOOGLE_CLIENT_SECRET=
# GitHub OAuth
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# Generic OIDC (e.g. Authentik, Keycloak, Okta)
# AUTH_OIDC_ISSUER=https://your-idp.example.com
# AUTH_OIDC_CLIENT_ID=
# AUTH_OIDC_CLIENT_SECRET=
# AUTH_OIDC_DISPLAY_NAME=SSO
+2
View File
@@ -0,0 +1,2 @@
# Default owner for everything in the repo
* @fredicious
+40
View File
@@ -0,0 +1,40 @@
---
name: Bug Report
about: Something isn't working as expected
title: "bug: "
labels: bug
assignees: ""
---
## Description
A clear summary of the bug.
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What should happen.
## Actual Behavior
What happens instead.
## Environment
- **Crumbs version**: (e.g. 0.3.2)
- **Browser**: (e.g. Chrome 120, Firefox 121)
- **OS**: (e.g. macOS 15, Ubuntu 24.04)
- **Deployment**: (Docker / Node.js / dev server)
## Screenshots
If applicable, add screenshots to help explain the problem.
## Additional Context
Any other relevant information (logs, related issues, etc.).
+27
View File
@@ -0,0 +1,27 @@
---
name: Feature Request
about: Suggest an idea or improvement
title: "feat: "
labels: enhancement
assignees: ""
---
## Description
A clear description of the feature you'd like to see.
## Motivation
Why is this feature important? What problem does it solve?
## Proposed Solution
How do you think this should work?
## Alternatives Considered
Have you considered other approaches? If so, why were they ruled out?
## Additional Context
Any mockups, links, or references that help illustrate the request.
+18
View File
@@ -0,0 +1,18 @@
## Summary
What does this PR do and why?
## Test Plan
- [ ] Unit tests added/updated
- [ ] E2E tests added/updated (if user-facing)
- [ ] `make check` passes
- [ ] `make test` passes
## Related Issues
Closes #
## Screenshots
If this changes the UI, include before/after screenshots.
+25
View File
@@ -95,3 +95,28 @@ jobs:
with:
tag_name: ${{ needs.bump.outputs.tag }}
generate_release_notes: true
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: [bump, release]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update docker-compose.yml
env:
TAG: ${{ needs.bump.outputs.tag }}
run: |
VERSION="${TAG#v}"
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docker-compose.yml
git diff --cached --quiet && exit 0
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
git push origin main
+5 -5
View File
@@ -51,7 +51,6 @@ jobs:
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
@@ -86,16 +85,17 @@ jobs:
CI: true
- uses: actions/upload-artifact@v4
if: failure()
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
path: |
playwright-report/
test-results/
retention-days: 30
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
+25
View File
@@ -54,3 +54,28 @@ jobs:
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update docker-compose.yml
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docker-compose.yml
git diff --cached --quiet && exit 0
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
git push origin main
+36
View File
@@ -0,0 +1,36 @@
name: Deploy Website
on:
push:
branches: [main]
paths: ['website/**']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: website
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+3
View File
@@ -37,3 +37,6 @@ vite.config.ts.timestamp-*
# Local notes
todo.md
# Worktrees
.worktrees/
+42
View File
@@ -0,0 +1,42 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.2] - 2026-03-07
### Fixed
- Update docker-compose only after Docker image is pushed
## [0.3.1] - 2026-03-07
### Fixed
- Include tags in sync pull response and update API spec
### Changed
- Update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md documentation
## [0.3.0] - 2026-03-06
### Added
- Tag management system with auto-extraction from note content
- Full-text search across titles, content, and tags
- Dark mode with system preference detection
- Note pinning, archive, and trash workflows
- Image attachments with drag-and-drop upload
- 12 color themes for note cards
- PWA support with offline-first IndexedDB sync
- LWW CRDT-based conflict resolution
- Docker deployment with multi-stage build
- CI/CD pipeline with GitHub Actions
- Comprehensive E2E test suite with Playwright
- OpenAPI spec and auto-generated API docs
### Changed
- Initial public release
[0.3.2]: https://github.com/bretzel-app/crumbs/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/bretzel-app/crumbs/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/bretzel-app/crumbs/releases/tag/v0.3.0
+24 -2
View File
@@ -55,6 +55,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
### Layout groups
@@ -124,6 +125,12 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
| `make release-minor` | — | Bump minor version, tag, and push |
| `make release-major` | — | Bump major version, tag, and push |
## Git conventions
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
## CI/CD
GitHub Actions with two workflows:
@@ -135,10 +142,14 @@ GitHub Actions with two workflows:
4. **E2E Tests** — Playwright with Chromium (depends on step 3, uploads report on failure)
5. **Docker Build** — validates the Docker image builds (depends on step 3)
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags:
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io` (GitHub Container Registry)
**Bump & Release** (`.github/workflows/bump.yml`) — workflow_dispatch (primary release method):
- Runs `scripts/release.sh` to bump version in `package.json` + `docker-compose.yml`, tag, and push
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io`
- Creates a GitHub Release with auto-generated notes
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags (fallback for manual/local tag pushes):
- Same as above: CI pipeline, Docker build/push, GitHub Release
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
## Testing philosophy: BDD/TDD
@@ -188,3 +199,14 @@ E2E tests follow Cucumber BDD conventions via comments. Follow the [Better Gherk
```
**Resilience test** — ask: *"Will this comment need to change if the UI implementation changes?"* If yes, rewrite it to remove implementation details. The Playwright code underneath handles the *how*; the comments describe the *what*.
## Documentation
Keep docs in `docs/` up to date when making changes. If a feature, API endpoint, deployment config, or architectural decision changes, update the relevant doc file in the same commit or PR:
| Doc | Covers |
|-----|--------|
| `docs/FEATURES.md` | User-facing feature list |
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
+31
View File
@@ -0,0 +1,31 @@
# Code of Conduct
## Our Commitment
Crumbs is a community project, and we want everyone to feel welcome regardless of background, identity, or experience level. We are committed to providing a harassment-free experience for all participants.
## Expected Behavior
- Be respectful and considerate in communication
- Give and accept constructive feedback gracefully
- Focus on what is best for the project and community
- Show empathy towards others
## Unacceptable Behavior
- Harassment, intimidation, or discrimination of any kind
- Personal attacks, insults, or derogatory language
- Publishing others' private information without consent
- Any conduct that would be considered inappropriate in a professional setting
## Scope
This code of conduct applies to all project spaces — issues, pull requests, discussions, and any other communication channel associated with Crumbs.
## Enforcement
Instances of unacceptable behavior may be reported to the maintainers at **security@bretzel.app**. All reports will be reviewed and handled confidentially. Maintainers reserve the right to remove, edit, or reject contributions that violate this code of conduct, and to temporarily or permanently ban contributors for repeated or severe violations.
## Attribution
This code of conduct is inspired by the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1.
+88
View File
@@ -0,0 +1,88 @@
# Contributing to Crumbs
Thanks for your interest in contributing! This guide will help you get started.
## Development Setup
```bash
# Clone the repo
git clone https://github.com/bretzel-app/crumbs.git
cd crumbs
# Install dependencies
pnpm install
# Start the dev server
pnpm dev
```
Open http://localhost:5173 and set your password on first visit.
Run `make help` to see all available commands.
## Project Structure
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system design. Key directories:
- `src/routes/` — SvelteKit pages and API endpoints
- `src/lib/components/` — Svelte 5 components (runes, not stores)
- `src/lib/server/db/` — Drizzle ORM schema and database connection
- `src/lib/sync/` — Offline sync and CRDT logic
- `tests/unit/` — Vitest unit tests
- `tests/e2e/` — Playwright end-to-end tests
## Making Changes
1. **Fork and branch** — Create a feature branch from `main`
2. **Follow existing patterns** — Match the code style you see in the codebase
3. **Write tests** — Add unit tests for logic, E2E tests for user-facing features
4. **Run checks before pushing:**
```bash
make check # Type checking
make lint # Linting
make test-unit # Unit tests
make test-e2e # E2E tests
```
5. **Open a PR** — Target `main`, describe what and why
## Commit Messages
We use conventional-style commit messages:
```
feat: add tag autocomplete
fix: prevent duplicate notes on sync
docs: update deployment guide
chore: bump dependencies
```
Keep the subject line under 70 characters. Use the body for context when needed.
## Code Style
- **Svelte 5 runes** — Use `$state`, `$derived`, `$props`, `$effect` (not Svelte 4 stores)
- **TypeScript** — Strict mode, no `any` unless absolutely necessary
- **Tailwind CSS v4** — Use CSS variables from `src/app.css`, never hardcoded Tailwind colors
- **Design system** — Follow the Retro Parchment aesthetic described in `CLAUDE.md`
## Testing
- **Unit tests**: Pure logic (parsing, auth, sync). Run with `make test-unit`
- **E2E tests**: User workflows in the browser. Run with `make test-e2e`
- E2E tests use Gherkin-style Given/When/Then comments (see `CLAUDE.md` for conventions)
## Reporting Bugs
Use [GitHub Issues](https://github.com/bretzel-app/crumbs/issues) with the bug report template. Include:
- Steps to reproduce
- Expected vs actual behavior
- Browser/OS and Crumbs version
## Security Issues
**Do not open public issues for security vulnerabilities.** See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present Bretzel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -3
View File
@@ -1,4 +1,13 @@
# 🥨 Crumbs by Bretzel
[![CI](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/github/v/release/bretzel-app/crumbs)](https://github.com/bretzel-app/crumbs/releases)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://github.com/bretzel-app/crumbs/pkgs/container/crumbs)
<p align="center">
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
</p>
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
@@ -32,7 +41,6 @@ pnpm build # Production build
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
- Organize with #tags, pinning, archive, and trash
- Full-text search across titles, content, and tags
- Dark mode with system detection
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
- Single-user password auth (Argon2)
- Docker deployment with a single command
@@ -62,6 +70,9 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
| [API](docs/API.md) | REST API reference (auto-generated) |
| [Contributing](CONTRIBUTING.md) | How to contribute |
| [Security](SECURITY.md) | Vulnerability reporting policy |
| [Changelog](CHANGELOG.md) | Release history |
## CI/CD
@@ -72,4 +83,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
## License
MIT
[MIT](LICENSE)
+44
View File
@@ -0,0 +1,44 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | Yes |
| < latest | No |
We recommend always running the latest version of Crumbs.
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via [GitHub's private vulnerability reporting](https://github.com/bretzel-app/crumbs/security/advisories/new).
Include as much of the following as possible:
- Description of the vulnerability
- Steps to reproduce
- Impact assessment
- Suggested fix (if any)
You should receive an initial response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
## Scope
Since Crumbs is a self-hosted application, the following are in scope:
- Authentication bypass or session hijacking
- SQL injection or data leaks
- Cross-site scripting (XSS)
- Server-side request forgery (SSRF)
- Path traversal or file access vulnerabilities
- Denial of service via crafted input
## Disclosure Policy
We follow coordinated disclosure. Once a fix is available, we will:
1. Release a patched version
2. Publish a security advisory on GitHub
3. Credit the reporter (unless anonymity is requested)
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.3.1
image: ghcr.io/bretzel-app/crumbs:0.7.2
container_name: crumbs
ports:
- "3000:3000"
+24
View File
@@ -103,6 +103,7 @@ List notes
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -125,6 +126,7 @@ Create a note
```json
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -147,6 +149,7 @@ Create a note
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -181,6 +184,7 @@ Get a note
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -212,6 +216,7 @@ Partial update — only include changed fields.
```json
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -234,6 +239,7 @@ Partial update — only include changed fields.
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -288,6 +294,19 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
**Response:** `200`
```json
[
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/notes/{id}/attachments`
@@ -366,6 +385,7 @@ Searches across note titles, content, and tags. Excludes trashed notes.
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -423,6 +443,7 @@ Pull changes since timestamp
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -451,6 +472,7 @@ Push local changes
"operation": "create",
"timestamp": 1,
"data": {
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -473,4 +495,6 @@ Push local changes
}
```
**Errors:** `400` Invalid changes payload
---
+9 -1
View File
@@ -49,6 +49,14 @@ For a single-user app with multiple devices:
- If timestamps match: higher version wins
- If both match: local version is preferred
### Attachment Data Flow
1. User drops/picks image in NoteEditor
2. Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
3. **Online**: POST optimized + thumbnail to `/api/notes/{id}/attachments` → server saves to `data/attachments/` + DB
4. **Offline**: store blobs in IDB `pendingAttachments` store → display via `URL.createObjectURL()` → on reconnect, sync pushes to server
5. **Display**: NoteCard uses `&thumb=1` URL (200px WebP), editor shows full-size
6. **SW Caching**: `CacheFirst` rule caches attachment URLs — previously-viewed images available offline
## Tech Stack Rationale
### SvelteKit
@@ -89,7 +97,7 @@ Key tables:
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
- `note_tags` - Many-to-many note ↔ tag
- `attachments` - Image file metadata
- `attachments` - Image file metadata (path + thumbnailPath)
- `sync_log` - Sync operation history
## File Organization
+1 -1
View File
@@ -16,7 +16,7 @@ Open http://localhost:3000 and set your password.
# docker-compose.yml
services:
crumbs:
build: .
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "8080:3000" # Change external port
volumes:
+26 -16
View File
@@ -8,16 +8,27 @@
- Content supports full Markdown syntax
- Auto-save on editor close
### Markdown Support
- **Bold** (`**text**`)
- *Italic* (`*text*`)
### Rich Text Editor
- WYSIWYG editing powered by Tiptap
- Formatting toolbar with visual controls
- Raw Markdown mode toggle for direct editing
- Undo/Redo with keyboard shortcuts
### Text Formatting
- **Bold** (`**text**` / Ctrl+B)
- *Italic* (`*text*` / Ctrl+I)
- Underline (Ctrl+U)
- ~~Strikethrough~~ (`~~text~~` / Ctrl+Shift+X)
- Headings (`# H1` through `###### H6`)
- Blockquotes (`> quote`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Inline code (single backtick)
- Tables
- Links (auto-linkified URLs)
- Links — insert, edit, and remove with URL input
- Dividers (horizontal rules)
- Text alignment (left, center, right, justify)
- Line breaks
### Checklists / Task Lists
@@ -31,7 +42,13 @@
- Upload images via file picker or drag-and-drop
- Supports JPEG, PNG, GIF, WebP, SVG
- Max file size: 10MB
- Thumbnail previews in note cards
- **Client-side optimization**: images resized to fit 1920×1920, compressed to WebP (~80% quality), transparent PNGs preserved
- **Auto-generated thumbnails**: 200×200 WebP at 60% quality for fast card previews
- Thumbnail strip on note cards (up to 3 images, "+N" badge for more)
- Full-size images in editor, thumbnails on cards (`&thumb=1`)
- **Offline support**: optimized blobs stored in IndexedDB, displayed via blob URLs, auto-synced on reconnect
- Pending upload indicator on offline-queued images
- Service worker CacheFirst caching for previously-viewed images
- Remove attachments from editor
### Tag Organization
@@ -45,7 +62,7 @@
12 colors matching Google Keep's palette:
- Default, Coral, Peach, Sand, Mint, Sage
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to light/dark mode
- Colors adapt to note theme
### Pin Notes
- Pin important notes to always appear at top
@@ -69,13 +86,6 @@
- Clear search to restore original view
- Case-insensitive matching
### Dark Mode
- System preference detection (auto)
- Manual toggle override
- Preference persisted in localStorage
- All components adapt to theme
- Note colors adjust for dark backgrounds
### Single-User Auth
- First-run setup: create password (min 8 chars)
- Argon2 password hashing (industry standard)
@@ -86,9 +96,9 @@
### PWA / Offline-First
- Installable as standalone app
- Web app manifest with icons
- Service worker for asset caching
- IndexedDB for local data storage
- Background sync every 30 seconds
- Service worker for asset caching (CacheFirst for attachments, NetworkFirst for API)
- IndexedDB for local data storage (notes + pending attachments)
- Background sync every 30 seconds (notes + offline attachments)
- LWW conflict resolution for multi-device use
- Sync status indicator (synced/syncing/offline/error)
+35 -1
View File
@@ -185,9 +185,21 @@ paths:
in: query
schema:
type: string
- name: thumb
in: query
description: Set to "1" to return the thumbnail instead of the full image (requires attachmentId)
schema:
type: string
enum: ['1']
responses:
'200':
description: Attachment list or file content
description: Attachment list (without attachmentId) or file content (with attachmentId)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Attachment'
post:
summary: Upload an image attachment
tags: [Attachments]
@@ -203,6 +215,10 @@ paths:
type: string
format: binary
description: Image file (max 10MB)
thumbnail:
type: string
format: binary
description: Pre-generated thumbnail (WebP, ~200px)
responses:
'201':
description: Attachment metadata
@@ -306,6 +322,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Success'
'400':
description: Invalid changes payload
components:
securitySchemes:
@@ -340,6 +358,10 @@ components:
type: boolean
trashed:
type: boolean
trashedAt:
type: string
format: date-time
nullable: true
checklistMode:
type: boolean
sortOrder:
@@ -356,10 +378,18 @@ components:
type: array
items:
type: string
attachments:
type: array
items:
$ref: '#/components/schemas/Attachment'
NoteInput:
type: object
properties:
id:
type: string
format: uuid
description: Optional — generated if omitted
title:
type: string
content:
@@ -399,6 +429,10 @@ components:
type: string
size:
type: integer
thumbnailPath:
type: string
nullable: true
description: Server path to thumbnail file (null if no thumbnail)
createdAt:
type: string
format: date-time
+67
View File
@@ -0,0 +1,67 @@
CREATE TABLE `attachments` (
`id` text PRIMARY KEY NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `note_tags` (
`note_id` text NOT NULL,
`tag_id` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_tags_note_id_idx` ON `note_tags` (`note_id`);--> statement-breakpoint
CREATE INDEX `note_tags_tag_id_idx` ON `note_tags` (`tag_id`);--> statement-breakpoint
CREATE TABLE `notes` (
`id` text PRIMARY KEY NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL
);
+465
View File
@@ -0,0 +1,465 @@
{
"version": "6",
"dialect": "sqlite",
"id": "682b02aa-a28d-4904-8996-cbc4d740f626",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_unique": {
"name": "tags_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1773000424830,
"tag": "0000_wooden_vargas",
"breakpoints": true
}
]
}
+45 -29
View File
@@ -1,8 +1,15 @@
{
"name": "crumbs",
"private": true,
"version": "0.3.1",
"version": "0.8.1",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
"repository": {
"type": "git",
"url": "https://github.com/bretzel-app/crumbs.git"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -22,43 +29,47 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"@tiptap/core": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-task-item": "^3.20.0",
"@tiptap/extension-task-list": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"argon2": "^0.43.0",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"idb": "^8.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.0.0",
"markdown-it": "^14.1.1",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.0.0"
"uuid": "^11.1.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@sveltejs/kit": "^2.50.2",
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/markdown-it": "^14.0.0",
"@tailwindcss/vite": "^4.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/uuid": "^10.0.0",
"@vite-pwa/sveltekit": "^1.0.0",
"drizzle-kit": "^0.31.0",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"drizzle-kit": "^0.31.9",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^3.0.0",
"vitest": "^3.2.4",
"yaml": "^2.8.2"
},
"pnpm": {
@@ -66,6 +77,11 @@
"argon2",
"better-sqlite3",
"esbuild"
]
],
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0"
}
}
}
+9 -7
View File
@@ -1,12 +1,12 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: './tests/e2e/global-setup.ts',
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
globalSetup: './tests/e2e/global-setup.ts',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
@@ -14,14 +14,15 @@ export default defineConfig({
},
projects: [
{
name: 'setup',
testMatch: /auth\.spec\.ts/,
name: 'auth-setup',
testMatch: 'auth.spec.ts',
fullyParallel: false,
use: { ...devices['Desktop Chrome'] }
},
{
name: 'chromium',
testIgnore: /auth\.spec\.ts/,
dependencies: ['setup'],
name: 'app',
testIgnore: 'auth.spec.ts',
dependencies: ['auth-setup'],
use: { ...devices['Desktop Chrome'] }
}
],
@@ -31,7 +32,8 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
DATABASE_URL: './data/test-crumbs.db'
DATABASE_URL: './data/test-crumbs.db',
NODE_ENV: 'test'
}
}
});
+377 -531
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -60,8 +60,15 @@ echo "Bumping $BUMP version..."
npm version "$BUMP" -m "chore: bump version to %s" --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
sed -i'' -e "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${NEW_VERSION}|" docker-compose.yml
git add package.json docker-compose.yml
# Update version in marketing website
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
else
sed -i "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
fi
git add package.json website/index.html
git commit -m "chore: bump version to $NEW_VERSION"
git tag "v$NEW_VERSION"
+8 -1
View File
@@ -3,7 +3,14 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
user: {
id: number;
email: string;
displayName: string;
role: 'admin' | 'user';
} | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+19 -3
View File
@@ -2,11 +2,20 @@ import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
const PUBLIC_PATHS = [
'/login',
'/setup',
'/api/auth/login',
'/api/auth/setup',
'/api/auth/oauth'
];
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Initialize locals
event.locals.user = null;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return resolve(event);
@@ -21,9 +30,16 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// Validate session
// Validate session and populate user
const sessionToken = event.cookies.get('session');
if (!sessionToken || !(await validateSession(sessionToken))) {
if (sessionToken) {
const result = await validateSession(sessionToken);
if (result.valid && result.user) {
event.locals.user = result.user;
}
}
if (!event.locals.user) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
+126 -44
View File
@@ -1,8 +1,13 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import Plus from 'lucide-svelte/icons/plus';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
interface ChecklistItem {
id: string;
text: string;
checked: boolean;
}
@@ -15,14 +20,24 @@
let { content, onChange }: Props = $props();
let items = $state<ChecklistItem[]>(parseChecklist(content));
let doneExpanded = $state(true);
const flipDurationMs = 150;
let activeItems = $derived(items.filter((i) => !i.checked));
let doneItems = $derived(items.filter((i) => i.checked));
let doneCount = $derived(doneItems.length);
function generateId(): string {
return crypto.randomUUID();
}
function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ text: '', checked: false }];
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
const lines = text.split('\n');
return lines.map((line) => {
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
return { text: line, checked: false };
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
return { id: generateId(), text: line, checked: false };
});
}
@@ -36,78 +51,107 @@
onChange(serializeChecklist(items));
}
function toggleItem(index: number) {
items[index].checked = !items[index].checked;
emitChange();
function toggleItem(id: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.checked = !item.checked;
emitChange();
}
}
function updateText(index: number, text: string) {
items[index].text = text;
emitChange();
function updateText(id: string, text: string) {
const item = items.find((i) => i.id === id);
if (item) {
item.text = text;
emitChange();
}
}
function addItem(afterIndex: number) {
items.splice(afterIndex + 1, 0, { text: '', checked: false });
const newItem = { id: generateId(), text: '', checked: false };
items.splice(afterIndex + 1, 0, newItem);
items = [...items];
emitChange();
// Focus new input on next tick
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[afterIndex + 1]?.focus();
}, 0);
}
function removeItem(index: number) {
function removeItem(id: string) {
if (items.length <= 1) return;
const index = items.findIndex((i) => i.id === id);
items.splice(index, 1);
items = [...items];
emitChange();
}
function handleKeydown(e: KeyboardEvent, index: number) {
function handleKeydown(e: KeyboardEvent, id: string) {
const index = items.findIndex((i) => i.id === id);
if (e.key === 'Enter') {
e.preventDefault();
addItem(index);
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
e.preventDefault();
removeItem(index);
removeItem(id);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
}
}
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
// Merge reordered active items with done items
items = [...e.detail.items, ...items.filter((i) => i.checked)];
}
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
items = [...e.detail.items, ...items.filter((i) => i.checked)];
emitChange();
}
</script>
<div class="space-y-1" data-testid="checklist">
{#each items as item, index}
<div class="flex items-center gap-2">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(index)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, index)}
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
/>
<button
onclick={() => removeItem(index)}
class="opacity-0 hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
<div class="space-y-0.5" data-testid="checklist">
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-0.5"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2 py-1" animate:flip={{ duration: flipDurationMs }}>
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
</div>
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-checkbox"
/>
<input
type="text"
value={item.text}
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
onkeydown={(e) => handleKeydown(e, item.id)}
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
placeholder="List item"
data-testid="checklist-input"
/>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
</section>
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
@@ -116,4 +160,42 @@
<Plus class="h-4 w-4" />
Add item
</button>
{#if doneCount > 0}
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
<button
onclick={() => (doneExpanded = !doneExpanded)}
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
data-testid="checklist-toggle-done"
>
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
{doneCount} done
</button>
{#if doneExpanded}
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2 py-1">
<input
type="checkbox"
checked={item.checked}
onchange={() => toggleItem(item.id)}
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
data-testid="checklist-done-checkbox"
/>
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
<button
onclick={() => removeItem(item.id)}
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-done-remove"
>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
interface Props {
src: string;
alt: string;
onClose: () => void;
}
const { src, alt, onClose }: Props = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}
function handleBackgroundClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
onclick={handleBackgroundClick}
data-testid="image-lightbox"
>
<button
onclick={onClose}
class="absolute right-4 top-4 rounded-sm p-2 text-white hover:bg-white/10"
aria-label="Close lightbox"
data-testid="lightbox-close"
>
<XIcon class="h-6 w-6" />
</button>
<img
{src}
{alt}
class="max-h-[90vh] max-w-[90vw] object-contain rounded-sm animate-[pop-in_150ms_ease-out]"
data-testid="lightbox-image"
/>
</div>
+138 -32
View File
@@ -1,28 +1,83 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import Star from 'lucide-svelte/icons/star';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import ImageLightbox from './ImageLightbox.svelte';
import type { Attachment } from '$lib/types/index.js';
import { optimizeImage } from '$lib/utils/image-optimize.js';
import { browser } from '$app/environment';
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
import { showToast } from '$lib/stores/toast.js';
interface Props {
noteId: string;
attachments: Attachment[];
onUpload: (attachment: Attachment) => void;
onRemove: (attachmentId: string) => void;
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
showDropZone?: boolean;
}
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: Props = $props();
let uploading = $state(false);
let optimizing = $state(false);
let dragOver = $state(false);
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
async function handleFiles(files: FileList | null) {
if (!files || !noteId) return;
uploading = true;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
optimizing = true;
let optimized: Blob;
let thumbnail: Blob;
try {
const result = await optimizeImage(file);
optimized = result.optimized;
thumbnail = result.thumbnail;
} catch (err) {
console.error('Optimization failed, using original:', err);
optimized = file;
thumbnail = file;
}
optimizing = false;
// Offline: store in IDB and display via blob URL
if (browser && !navigator.onLine) {
const tempId = crypto.randomUUID();
const pending: PendingAttachment = {
id: tempId,
noteId,
optimized,
thumbnail,
filename: file.name,
mimeType: optimized.type || file.type,
timestamp: Date.now()
};
await addPendingAttachment(pending);
const blobUrl = URL.createObjectURL(optimized);
onUpload({
id: tempId,
noteId,
filename: file.name,
mimeType: optimized.type || file.type,
size: optimized.size,
path: blobUrl,
pending: true,
createdAt: new Date()
} as Attachment & { pending: boolean });
continue;
}
uploading = true;
const formData = new FormData();
formData.append('file', file);
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
try {
const res = await fetch(`/api/notes/${noteId}/attachments`, {
@@ -32,12 +87,16 @@
if (res.ok) {
const attachment = await res.json();
onUpload(attachment);
} else {
console.error('Upload failed:', res.status, await res.text());
showToast('Failed to upload image', 'error');
}
} catch (err) {
console.error('Upload failed:', err);
showToast('Failed to upload image', 'error');
}
uploading = false;
}
uploading = false;
}
function handleDrop(e: DragEvent) {
@@ -57,16 +116,34 @@
{#if attachments.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each attachments as attachment}
{@const isPending = 'pending' in attachment && attachment.pending}
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
<div class="group relative">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
src={imgSrc}
alt={attachment.filename}
class="h-20 w-20 rounded-sm object-cover"
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
data-testid="attachment-thumbnail"
/>
{#if isPending}
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
<RefreshCw class="h-3 w-3 text-white" />
</div>
{:else if onToggleFeatured}
<button
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 transition-opacity {attachment.featured ? 'opacity-100' : 'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100'}"
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
data-testid="toggle-featured"
>
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
</button>
{/if}
<button
onclick={() => onRemove(attachment.id)}
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
class="absolute -right-1 -top-1 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
@@ -78,29 +155,58 @@
{/if}
<!-- Upload area -->
<div
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
{#if showDropZone}
<!-- Drop zone for desktop, browse-only label for mobile -->
<div
class="hidden rounded-sm border-2 border-dashed p-4 text-center transition-colors md:block {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if optimizing}
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
{:else if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
<!-- Mobile: compact browse button -->
<div class="md:hidden">
{#if optimizing}
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
{:else if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-sm text-[var(--text-muted)] hover:border-[var(--primary)]">
<span>Add images</span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input-mobile"
/>
</label>
{/if}
</div>
{/if}
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
+52 -1
View File
@@ -6,12 +6,28 @@
interface Props {
onMenuToggle: () => void;
user: { id: number; email: string; displayName: string; role: 'admin' | 'user' } | null;
}
let { onMenuToggle }: Props = $props();
let { onMenuToggle, user }: Props = $props();
let mobileSearchOpen = $state(false);
let dropdownOpen = $state(false);
function getInitial(user: Props['user']): string {
if (!user) return '?';
if (user.displayName) return user.displayName.charAt(0).toUpperCase();
if (user.email) return user.email.charAt(0).toUpperCase();
return '?';
}
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
}
</script>
<svelte:window onclick={() => (dropdownOpen = false)} />
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
{#if mobileSearchOpen}
<!-- Mobile expanded search -->
@@ -48,6 +64,41 @@
<div class="flex items-center gap-2">
<SyncIndicator />
{#if user}
<div class="relative">
<button
onclick={(e) => { e.stopPropagation(); dropdownOpen = !dropdownOpen; }}
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--primary)] text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
aria-label="User menu"
data-testid="user-menu-btn"
>
{getInitial(user)}
</button>
{#if dropdownOpen}
<div class="absolute right-0 top-10 z-50 w-48 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]">
<div class="border-b border-[var(--border-subtle)] px-4 py-2">
<p class="text-sm font-medium text-[var(--text)]">{user.displayName || user.email}</p>
<p class="text-xs text-[var(--text-muted)]">{user.email}</p>
</div>
<a
href="/settings"
class="block px-4 py-2 text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
>
Settings
</a>
<button
onclick={handleLogout}
class="block w-full px-4 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
data-testid="logout-btn"
>
Log out
</button>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</header>
+32 -1
View File
@@ -6,18 +6,27 @@
interface Props {
open: boolean;
onClose?: () => void;
appVersion?: string;
}
let { open, onClose }: Props = $props();
let { open, onClose, appVersion }: Props = $props();
function closeMobile() {
if (window.matchMedia('(max-width: 1023px)').matches) {
onClose?.();
}
}
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
loadNotes(filter);
closeMobile();
}
function selectTag(tag: string | null) {
selectedTag.set(tag);
loadNotes('all');
closeMobile();
}
</script>
@@ -64,6 +73,23 @@
</li>
</ul>
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<ul class="space-y-1">
<li>
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
</li>
</ul>
</div>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Tags</h3>
@@ -83,4 +109,9 @@
</div>
{/if}
</nav>
{#if appVersion}
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)] px-6 py-3 text-right text-xs text-[var(--text-muted)]">
v{appVersion}
</div>
{/if}
</aside>
+49 -15
View File
@@ -3,6 +3,7 @@
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 ImageLightbox from './ImageLightbox.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';
@@ -18,9 +19,10 @@
interface Props {
note: Note;
onEdit: (note: Note) => void;
fullHeight?: boolean;
}
let { note, onEdit }: Props = $props();
let { note, onEdit, fullHeight = false }: Props = $props();
$effect(() => {
const colors = NOTE_COLORS[note.color];
@@ -28,6 +30,8 @@
});
let cardStyle = $state('');
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
const renderedContent = $derived(renderMarkdown(note.content));
@@ -40,6 +44,12 @@
: []
);
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
function stop(fn: () => void) {
return (e: Event) => {
e.stopPropagation();
@@ -47,19 +57,16 @@
};
}
function toggleChecklistItem(index: number) {
function toggleChecklistItem(item: ChecklistItem) {
const lines = note.content.split('\n');
let checkIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('- [x] ') || line.startsWith('- [ ] ')) {
if (checkIndex === index) {
lines[i] = line.startsWith('- [x] ')
? `- [ ] ${line.slice(6)}`
: `- [x] ${line.slice(6)}`;
break;
}
checkIndex++;
const text = line.replace(/^- \[[ x]\] /, '');
if (text === item.text && line.startsWith(item.checked ? '- [x] ' : '- [ ] ')) {
lines[i] = item.checked
? `- [ ] ${text}`
: `- [x] ${text}`;
break;
}
}
updateNote(note.id, { content: lines.join('\n') });
@@ -67,7 +74,7 @@
</script>
<article
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)]"
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 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)}
@@ -76,6 +83,30 @@
data-testid="note-card"
data-note-id={note.id}
>
<!-- Thumbnail strip (featured images only) -->
{#if featuredAttachments.length > 0}
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
{#each featuredAttachments.slice(0, 3) as attachment}
<div class="relative min-w-0 flex-1">
<!-- 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"
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="card-thumbnail"
/>
</div>
{/each}
{#if featuredAttachments.length > 3}
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
+{featuredAttachments.length - 3}
</div>
{/if}
</div>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
@@ -93,12 +124,12 @@
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-1" data-testid="note-checklist-preview">
{#each checklistItems.slice(0, 8) as item, index}
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
<input
type="checkbox"
checked={item.checked}
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(index); }}
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
data-testid="card-checklist-checkbox"
/>
@@ -124,7 +155,7 @@
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
@@ -183,6 +214,9 @@
{/if}
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
</article>
<style>
+118 -10
View File
@@ -3,12 +3,14 @@
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor } from '$lib/types/index.js';
import type { Note, NoteColor, Attachment } 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';
@@ -30,34 +32,115 @@
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let rawMarkdownMode = $state(false);
let showImageUpload = $state(false);
let rawMarkdownMode = $state(note?.checklistMode ?? false);
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
// Mutable note identity — allows transitioning from new → saved without closing
// svelte-ignore state_referenced_locally
let noteId = $state<string | null>(note?.id ?? null);
// svelte-ignore state_referenced_locally
let currentlyNew = $state(isNew);
// svelte-ignore state_referenced_locally
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
$effect(() => {
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
fetch(`/api/notes/${noteId}/attachments`)
.then((res) => res.ok ? res.json() : [])
.then((data: Attachment[]) => {
if (data.length > 0) attachmentsList = data;
})
.catch(() => {});
}
});
function handleAttachmentUpload(attachment: Attachment) {
attachmentsList = [...attachmentsList, attachment];
}
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
if (!noteId) return;
try {
const res = await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ featured })
});
if (res.ok) {
attachmentsList = attachmentsList.map((a) =>
a.id === attachmentId ? { ...a, featured } : a
);
}
} catch (err) {
console.error('Failed to toggle featured:', err);
}
}
async function handleAttachmentRemove(attachmentId: string) {
if (!noteId) return;
try {
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
method: 'DELETE'
});
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
} catch (err) {
console.error('Failed to remove attachment:', err);
}
}
let bgStyle = $state('');
$effect(() => {
const colors = NOTE_COLORS[color];
bgStyle = `background-color: ${colors.bg}`;
});
async function save() {
async function saveAndClose() {
if (!title.trim() && !content.trim()) {
onClose();
return;
}
if (isNew) {
if (currentlyNew) {
await createNote({ title, content, color, checklistMode });
} else if (note) {
await updateNote(note.id, { title, content, color, checklistMode });
} else if (noteId) {
await updateNote(noteId, { title, content, color, checklistMode });
}
onClose();
}
/** Auto-save a new note without closing, returns the new note ID */
async function autoSave(): Promise<string | null> {
if (!currentlyNew) return noteId;
const created = await createNote({
title: title || 'Untitled',
content,
color,
checklistMode
});
if (created) {
noteId = created.id;
currentlyNew = false;
return created.id;
}
return null;
}
async function toggleImageUpload() {
if (currentlyNew) {
const id = await autoSave();
if (!id) return;
}
showImageUpload = !showImageUpload;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
save();
saveAndClose();
}
}
@@ -77,7 +160,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
onclick={save}
onclick={saveAndClose}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
@@ -124,6 +207,20 @@
{/if}
</div>
<!-- Image attachments -->
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
onUpload={handleAttachmentUpload}
onRemove={handleAttachmentRemove}
onToggleFeatured={handleToggleFeatured}
showDropZone={showImageUpload}
/>
</div>
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
@@ -163,11 +260,22 @@
{/if}
</button>
<!-- Image attachment toggle -->
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Image attachments"
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
</button>
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
disabled={checklistMode}
data-testid="markdown-toggle"
>
{#if rawMarkdownMode}
@@ -179,7 +287,7 @@
</div>
<button
onclick={save}
onclick={saveAndClose}
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
data-testid="close-editor-btn"
>
+56 -7
View File
@@ -1,23 +1,72 @@
<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 { flip } from 'svelte/animate';
import type { Note } from '$lib/types/index.js';
interface Props {
notes: Note[];
label?: string;
onEdit: (note: Note) => void;
draggable?: boolean;
dndType?: string;
onReorder?: (noteIds: string[]) => void;
}
let { notes, label = '', onEdit }: Props = $props();
let { notes, label = '', onEdit, draggable = false, dndType = 'notes', onReorder }: Props = $props();
let localItems = $state<Note[]>([]);
$effect(() => {
localItems = [...notes];
});
const flipDurationMs = 150;
function handleConsider(e: CustomEvent<DndEvent<Note>>) {
localItems = e.detail.items;
}
function handleFinalize(e: CustomEvent<DndEvent<Note>>) {
localItems = e.detail.items;
onReorder?.(localItems.map((n) => n.id));
}
let displayItems = $derived(draggable ? localItems : notes);
</script>
{#if notes.length > 0}
{#if displayItems.length > 0}
{#if label}
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
{/if}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
{#each notes as note (note.id)}
<NoteCard {note} {onEdit} />
{/each}
</div>
{#if draggable}
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
data-testid="note-grid"
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
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>
<NoteCard {note} {onEdit} fullHeight />
</div>
{/each}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
{#each displayItems as note (note.id)}
<NoteCard {note} {onEdit} />
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,36 @@
<script lang="ts">
import zxcvbn from 'zxcvbn';
let { password = '' }: { password: string } = $props();
const result = $derived(password.length > 0 ? zxcvbn(password) : null);
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
const colors = [
'bg-[var(--destructive)]',
'bg-[var(--destructive)]',
'bg-[var(--primary)]',
'bg-[var(--success-text)]',
'bg-[var(--success-text)]'
];
</script>
{#if result}
<div class="mt-1 mb-2" data-testid="password-strength">
<div class="flex gap-1">
{#each { length: 4 } as _, i}
<div
class="h-1 flex-1 rounded-sm transition-colors {i <= result.score
? colors[result.score]
: 'bg-[var(--border-subtle)]'}"
></div>
{/each}
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]">
{labels[result.score]}
{#if result.feedback.warning}
— {result.feedback.warning}
{/if}
</p>
</div>
{/if}
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Clock from 'lucide-svelte/icons/clock';
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
import Hand from 'lucide-svelte/icons/hand';
import { sortMode, type SortMode } from '$lib/stores/sort.js';
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
{ value: 'updated', label: 'Updated', icon: Clock },
{ value: 'created', label: 'Created', icon: CalendarPlus },
{ value: 'custom', label: 'Custom', icon: Hand }
];
</script>
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
{#each modes as mode}
<button
onclick={() => sortMode.set(mode.value)}
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
{$sortMode === mode.value
? 'bg-[var(--primary)] text-white'
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
data-testid="sort-{mode.value}"
aria-label="Sort by {mode.label}"
>
<mode.icon class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{mode.label}</span>
</button>
{/each}
</div>
+4 -2
View File
@@ -57,16 +57,18 @@
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
bind:this={element}
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
data-testid="tiptap-editor"
onclick={() => editor?.commands.focus()}
></div>
<style>
.tiptap-wrapper :global(.tiptap) {
outline: none;
min-height: 80px;
flex: 1;
}
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
+16
View File
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
type EventWithLocals = {
locals: App.Locals;
};
export function getUserId(event: EventWithLocals): number {
if (!event.locals.user) throw error(401, 'Unauthorized');
return event.locals.user.id;
}
export function requireAdmin(event: EventWithLocals) {
const user = event.locals.user;
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
return user;
}
+56 -2
View File
@@ -4,7 +4,8 @@ import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
const DATA_DIR = process.env.DATA_DIR || './data';
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
@@ -16,7 +17,9 @@ if (!existsSync(ATTACHMENTS_DIR)) {
export async function saveAttachment(
noteId: string,
file: File
file: File,
userId: number,
thumbnail?: File | Blob | null
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
const ext = file.name.split('.').pop() || 'bin';
@@ -26,15 +29,25 @@ export async function saveAttachment(
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
let thumbnailPath: string | null = null;
if (thumbnail) {
const thumbFilename = `${id}_thumb.webp`;
thumbnailPath = join(ATTACHMENTS_DIR, thumbFilename);
const thumbBuffer = Buffer.from(await thumbnail.arrayBuffer());
await writeFile(thumbnailPath, thumbBuffer);
}
const [attachment] = await db
.insert(attachments)
.values({
id,
userId,
noteId,
filename: file.name,
mimeType: file.type,
size: file.size,
path: filePath,
thumbnailPath,
createdAt: new Date()
})
.returning();
@@ -42,6 +55,31 @@ export async function saveAttachment(
return attachment;
}
/**
* Batch fetch attachments for multiple notes in a single query.
* Returns a Map of noteId → Attachment[].
*/
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select()
.from(attachments)
.where(inArray(attachments.noteId, noteIds))
.all();
const map = new Map<string, Attachment[]>();
for (const row of rows) {
const existing = map.get(row.noteId);
if (existing) {
existing.push(row as Attachment);
} else {
map.set(row.noteId, [row as Attachment]);
}
}
return map;
}
export async function getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
}
@@ -50,6 +88,15 @@ export async function getAttachmentsByNote(noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function updateAttachment(id: string, data: { featured: boolean }) {
const [updated] = await db
.update(attachments)
.set({ featured: data.featured })
.where(eq(attachments.id, id))
.returning();
return updated ?? null;
}
export async function deleteAttachment(id: string) {
const attachment = await getAttachment(id);
if (attachment) {
@@ -58,6 +105,13 @@ export async function deleteAttachment(id: string) {
} catch {
// File may already be deleted
}
if (attachment.thumbnailPath) {
try {
await unlink(attachment.thumbnailPath);
} catch {
// Thumbnail may already be deleted
}
}
await db.delete(attachments).where(eq(attachments.id, id));
}
}
+12 -5
View File
@@ -41,18 +41,25 @@ describe('Auth - User Setup', () => {
const hash = await argon2.hash(password);
await db.insert(users).values({
email: 'admin@test.com',
displayName: 'Admin',
role: 'admin',
passwordHash: hash,
createdAt: new Date()
});
const user = await db.select().from(users).get();
expect(user).toBeDefined();
expect(user!.email).toBe('admin@test.com');
expect(user!.role).toBe('admin');
expect(user!.passwordHash).not.toBe(password);
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
expect(await argon2.verify(user!.passwordHash!, password)).toBe(true);
});
it('should only allow one user', async () => {
it('should only allow one admin setup', async () => {
await db.insert(users).values({
email: 'admin@test.com',
role: 'admin',
passwordHash: 'hash1',
createdAt: new Date()
});
@@ -66,7 +73,7 @@ describe('Auth - Sessions', () => {
it('should create and validate a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -85,7 +92,7 @@ describe('Auth - Sessions', () => {
it('should detect expired sessions', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
@@ -103,7 +110,7 @@ describe('Auth - Sessions', () => {
it('should delete a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
+278 -34
View File
@@ -1,65 +1,309 @@
import { db } from './db/index.js';
import { db, sqlite } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq, lt } from 'drizzle-orm';
import { eq, lt, and } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import type { User } from '$lib/types/index.js';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type SessionUser = { id: number; email: string; displayName: string; role: 'admin' | 'user' };
function toUser(row: typeof users.$inferSelect): User {
return {
id: row.id,
email: row.email,
displayName: row.displayName,
role: row.role,
authProvider: row.authProvider,
providerId: row.providerId,
createdAt: row.createdAt
};
}
export async function isSetupComplete(): Promise<boolean> {
const user = await db.select().from(users).get();
const user = db.select().from(users).get();
return !!user;
}
export async function setupUser(password: string): Promise<boolean> {
const existing = await db.select().from(users).get();
if (existing) return false; // Already set up
export async function setupUser(
email: string,
password: string,
displayName?: string
): Promise<User | null> {
const existing = db.select().from(users).get();
if (existing) return null; // Already set up
const hash = await argon2.hash(password);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
return true;
const result = db
.insert(users)
.values({
email,
displayName: displayName || email.split('@')[0] || 'Admin',
role: 'admin',
passwordHash: hash,
authProvider: 'password',
createdAt: new Date()
})
.returning()
.get();
return toUser(result);
}
export async function verifyPassword(password: string): Promise<boolean> {
const user = await db.select().from(users).get();
if (!user) return false;
return argon2.verify(user.passwordHash, password);
export async function verifyPassword(
email: string,
password: string
): Promise<User | null> {
const user = db
.select()
.from(users)
.where(and(eq(users.email, email), eq(users.authProvider, 'password')))
.get();
if (!user || !user.passwordHash) return null;
const valid = await argon2.verify(user.passwordHash, password);
return valid ? toUser(user) : null;
}
export async function createSession(): Promise<string> {
const user = await db.select().from(users).get();
if (!user) throw new Error('No user exists');
export async function createSession(
userId: number,
meta?: { userAgent?: string; ip?: string }
): Promise<string> {
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
});
const now = new Date();
db.insert(sessions)
.values({
id: token,
userId,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
createdAt: now,
userAgent: meta?.userAgent || null,
ip: meta?.ip || null,
lastUsedAt: now
})
.run();
return token;
}
export async function validateSession(token: string): Promise<boolean> {
if (!token) return false;
export async function validateSession(
token: string
): Promise<{ valid: boolean; user?: SessionUser }> {
if (!token) return { valid: false };
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) return false;
// Join sessions with users to get user data in one query
const result = sqlite
.prepare(
`SELECT s.id as session_id, s.expires_at, u.id, u.email, u.display_name, u.role
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = ?`
)
.get(token) as
| {
session_id: string;
expires_at: number;
id: number;
email: string;
display_name: string;
role: 'admin' | 'user';
}
| undefined;
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, token));
return false;
if (!result) return { valid: false };
if (result.expires_at * 1000 < Date.now()) {
db.delete(sessions).where(eq(sessions.id, token)).run();
return { valid: false };
}
return true;
// Update lastUsedAt
db.update(sessions)
.set({ lastUsedAt: new Date() })
.where(eq(sessions.id, token))
.run();
return {
valid: true,
user: {
id: result.id,
email: result.email,
displayName: result.display_name,
role: result.role
}
};
}
export async function deleteSession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, token));
db.delete(sessions).where(eq(sessions.id, token)).run();
}
export async function cleanExpiredSessions(): Promise<void> {
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run();
}
export type SessionInfo = {
id: string;
createdAt: Date | null;
userAgent: string | null;
ip: string | null;
lastUsedAt: Date | null;
expiresAt: Date;
isCurrent: boolean;
};
export function listUserSessions(userId: number, currentToken: string): SessionInfo[] {
return db
.select()
.from(sessions)
.where(eq(sessions.userId, userId))
.all()
.map((s) => ({
id: s.id,
createdAt: s.createdAt,
userAgent: s.userAgent,
ip: s.ip,
lastUsedAt: s.lastUsedAt,
expiresAt: s.expiresAt,
isCurrent: s.id === currentToken
}));
}
export function revokeSession(userId: number, sessionId: string): boolean {
const session = db
.select()
.from(sessions)
.where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId)))
.get();
if (!session) return false;
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return true;
}
export function revokeAllSessions(userId: number, exceptToken?: string): void {
if (exceptToken) {
sqlite
.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?')
.run(userId, exceptToken);
} else {
db.delete(sessions).where(eq(sessions.userId, userId)).run();
}
}
// --- User CRUD (admin operations) ---
export async function createUser(
email: string,
displayName: string,
password: string,
role: 'admin' | 'user' = 'user'
): Promise<User> {
const hash = await argon2.hash(password);
const result = db
.insert(users)
.values({
email,
displayName,
role,
passwordHash: hash,
authProvider: 'password',
createdAt: new Date()
})
.returning()
.get();
return toUser(result);
}
export function listUsers(): User[] {
return db
.select()
.from(users)
.all()
.map(toUser);
}
export function getUser(userId: number): User | null {
const row = db.select().from(users).where(eq(users.id, userId)).get();
return row ? toUser(row) : null;
}
export async function deleteUser(userId: number): Promise<void> {
// Cascade: delete user's data in dependency order
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
sqlite
.prepare(
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
)
.run(userId);
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
db.delete(users).where(eq(users.id, userId)).run();
}
export async function changePassword(
userId: number,
currentPassword: string,
newPassword: string
): Promise<boolean> {
const user = db.select().from(users).where(eq(users.id, userId)).get();
if (!user || !user.passwordHash) return false;
const valid = await argon2.verify(user.passwordHash, currentPassword);
if (!valid) return false;
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
return true;
}
export async function resetPassword(userId: number, newPassword: string): Promise<void> {
const hash = await argon2.hash(newPassword);
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
}
export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
db.update(users).set({ role }).where(eq(users.id, userId)).run();
}
/**
* Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user.
*/
export function findOrLinkOAuthUser(
provider: string,
providerId: string,
email: string,
name: string
): User | null {
// First try exact provider+providerId match
const byProvider = db
.select()
.from(users)
.where(and(eq(users.authProvider, provider), eq(users.providerId, providerId)))
.get();
if (byProvider) return toUser(byProvider);
// Try matching by email (invite-only: user must already exist)
const byEmail = db.select().from(users).where(eq(users.email, email)).get();
if (!byEmail) return null; // No matching user — not invited
// Link this OAuth provider to the existing user
db.update(users)
.set({ authProvider: provider, providerId, displayName: byEmail.displayName || name })
.where(eq(users.id, byEmail.id))
.run();
return toUser({ ...byEmail, authProvider: provider, providerId });
}
export function updateUserProfile(
userId: number,
data: { displayName?: string; email?: string }
): void {
const updates: Record<string, string> = {};
if (data.displayName !== undefined) updates.displayName = data.displayName;
if (data.email !== undefined) updates.email = data.email;
if (Object.keys(updates).length > 0) {
db.update(users).set(updates).where(eq(users.id, userId)).run();
}
}
+116 -6
View File
@@ -16,24 +16,36 @@ const sqlite = new Database(DATABASE_URL);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Auto-create tables on first run
// Disable foreign keys during schema setup to avoid constraint issues during migration
sqlite.pragma('foreign_keys = OFF');
// Auto-create tables on first run (new installs get full schema)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
expires_at INTEGER NOT NULL,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
@@ -50,9 +62,9 @@ sqlite.exec(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -61,28 +73,126 @@ sqlite.exec(`
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
thumbnail_path TEXT,
featured INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id),
user_id INTEGER NOT NULL DEFAULT 0,
note_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
email TEXT NOT NULL,
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
`);
// Migration: detect old schema (no email column on users) and add columns
const userColumns = sqlite
.prepare("PRAGMA table_info('users')")
.all() as { name: string }[];
const hasEmail = userColumns.some((col) => col.name === 'email');
if (!hasEmail) {
sqlite.exec(`
ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'password';
ALTER TABLE users ADD COLUMN provider_id TEXT;
`);
}
// Ensure user_id column exists on data tables (migration from old schema)
const notesColumns = sqlite
.prepare("PRAGMA table_info('notes')")
.all() as { name: string }[];
if (!notesColumns.some((col) => col.name === 'user_id')) {
sqlite.exec(`
ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE attachments ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
`);
}
// Backfill: promote existing user to admin and assign all data to them
if (!hasEmail) {
const existingUser = sqlite.prepare('SELECT id FROM users LIMIT 1').get() as
| { id: number }
| undefined;
if (existingUser) {
const uid = existingUser.id;
sqlite.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', uid);
for (const table of ['notes', 'tags', 'attachments', 'sync_log']) {
sqlite.prepare(`UPDATE ${table} SET user_id = ? WHERE user_id = 0`).run(uid);
}
}
}
// Migration: add session tracking columns
const sessionColumns = sqlite
.prepare("PRAGMA table_info('sessions')")
.all() as { name: string }[];
if (!sessionColumns.some((col) => col.name === 'created_at')) {
sqlite.exec(`
ALTER TABLE sessions ADD COLUMN created_at INTEGER;
ALTER TABLE sessions ADD COLUMN user_agent TEXT;
ALTER TABLE sessions ADD COLUMN ip TEXT;
ALTER TABLE sessions ADD COLUMN last_used_at INTEGER;
`);
}
// Create indexes AFTER migration ensures columns exist
sqlite.exec(`
CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id);
CREATE INDEX IF NOT EXISTS notes_trashed_archived_idx ON notes(trashed, archived);
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
CREATE INDEX IF NOT EXISTS note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
`);
// Replace old tags unique index with compound index including user_id
try {
sqlite.exec(`DROP INDEX IF EXISTS tags_name_unique`);
} catch {
// ignore
}
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS tags_name_user_unique ON tags(name, user_id)`
);
// Idempotent migrations: add columns that may be missing from older DBs
try {
sqlite.exec(`ALTER TABLE attachments ADD COLUMN thumbnail_path TEXT;`);
} catch {
// Column already exists
}
try {
sqlite.exec(`ALTER TABLE attachments ADD COLUMN featured INTEGER NOT NULL DEFAULT 0;`);
} catch {
// Column already exists
}
// Re-enable foreign keys
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export { sqlite };
+2 -1
View File
@@ -235,7 +235,8 @@ describe('Notes with Tags', () => {
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note
// Delete note_tags first (FK cascade is disabled in test DB), then note
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
await db.delete(notes).where(eq(notes.id, noteId));
// note_tags should be gone
+43 -3
View File
@@ -2,7 +2,12 @@ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqli
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
passwordHash: text('password_hash').notNull(),
email: text('email').notNull().default(''),
displayName: text('display_name').notNull().default(''),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
passwordHash: text('password_hash'),
authProvider: text('auth_provider').notNull().default('password'),
providerId: text('provider_id'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
@@ -11,13 +16,21 @@ export const sessions = sqliteTable('sessions', {
userId: integer('user_id')
.references(() => users.id)
.notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }),
userAgent: text('user_agent'),
ip: text('ip'),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
});
export const notes = sqliteTable(
'notes',
{
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
title: text('title').default('').notNull(),
content: text('content').default('').notNull(),
color: text('color').default('default').notNull(),
@@ -32,6 +45,7 @@ export const notes = sqliteTable(
version: integer('version').default(1).notNull()
},
(table) => [
index('notes_user_id_idx').on(table.userId),
index('notes_trashed_archived_idx').on(table.trashed, table.archived),
index('notes_updated_at_idx').on(table.updatedAt)
]
@@ -41,9 +55,13 @@ export const tags = sqliteTable(
'tags',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
(table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)]
);
export const noteTags = sqliteTable(
@@ -64,6 +82,10 @@ export const noteTags = sqliteTable(
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
@@ -71,6 +93,8 @@ export const attachments = sqliteTable('attachments', {
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
path: text('path').notNull(),
thumbnailPath: text('thumbnail_path'),
featured: integer('featured', { mode: 'boolean' }).default(false).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
@@ -78,6 +102,10 @@ export const syncLog = sqliteTable(
'sync_log',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id')
.references(() => users.id)
.notNull()
.default(0),
noteId: text('note_id')
.references(() => notes.id)
.notNull(),
@@ -87,3 +115,15 @@ export const syncLog = sqliteTable(
},
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
);
export const loginAttempts = sqliteTable(
'login_attempts',
{
id: integer('id').primaryKey({ autoIncrement: true }),
ip: text('ip').notNull(),
email: text('email').notNull(),
success: integer('success', { mode: 'boolean' }).notNull(),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull()
},
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
);
+33 -5
View File
@@ -9,24 +9,34 @@ import * as schema from './schema.js';
export function createTestDb() {
const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('foreign_keys = OFF');
// Create tables
// Create tables matching the multi-user schema
sqlite.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'password',
provider_id TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
expires_at INTEGER NOT NULL,
created_at INTEGER,
user_agent TEXT,
ip TEXT,
last_used_at INTEGER
);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
@@ -40,20 +50,27 @@ export function createTestDb() {
updated_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX notes_user_id_idx ON notes(user_id);
CREATE INDEX notes_trashed_archived_idx ON notes(trashed, archived);
CREATE INDEX notes_updated_at_idx ON notes(updated_at);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
name TEXT NOT NULL
);
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
CREATE UNIQUE INDEX tags_name_user_unique ON tags(name, user_id);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
@@ -64,11 +81,22 @@ export function createTestDb() {
CREATE TABLE sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
CREATE INDEX sync_log_timestamp_idx ON sync_log(timestamp);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
email TEXT NOT NULL,
success INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
`);
const db = drizzle(sqlite, { schema });
+51
View File
@@ -0,0 +1,51 @@
import * as arctic from 'arctic';
import { getGithubClient } from './providers.js';
export function createGithubAuthUrl(): { url: URL; state: string } | null {
const github = getGithubClient();
if (!github) return null;
const state = arctic.generateState();
const scopes = ['user:email'];
const url = github.createAuthorizationURL(state, scopes);
return { url, state };
}
export async function validateGithubCallback(
code: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const github = getGithubClient();
if (!github) return null;
const tokens = await github.validateAuthorizationCode(code);
const accessToken = tokens.accessToken();
// Fetch user profile
const userRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
});
if (!userRes.ok) return null;
const user = (await userRes.json()) as { id: number; login: string; name?: string; email?: string };
// GitHub may not return email in profile — fetch from emails endpoint
let email = user.email;
if (!email) {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
});
if (emailsRes.ok) {
const emails = (await emailsRes.json()) as { email: string; primary: boolean; verified: boolean }[];
const primary = emails.find((e) => e.primary && e.verified);
email = primary?.email || emails.find((e) => e.verified)?.email || undefined;
}
}
if (!email) return null;
return {
email,
name: user.name || user.login,
providerId: String(user.id)
};
}
+38
View File
@@ -0,0 +1,38 @@
import * as arctic from 'arctic';
import { getGoogleClient } from './providers.js';
export function createGoogleAuthUrl(): { url: URL; state: string; codeVerifier: string } | null {
const google = getGoogleClient();
if (!google) return null;
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
const scopes = ['openid', 'profile', 'email'];
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
return { url, state, codeVerifier };
}
export async function validateGoogleCallback(
code: string,
codeVerifier: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const google = getGoogleClient();
if (!google) return null;
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
const idToken = tokens.idToken();
const claims = arctic.decodeIdToken(idToken) as {
sub: string;
email?: string;
name?: string;
};
if (!claims.email) return null;
return {
email: claims.email,
name: claims.name || claims.email.split('@')[0],
providerId: claims.sub
};
}
+119
View File
@@ -0,0 +1,119 @@
import * as arctic from 'arctic';
let oidcClient: arctic.OAuth2Client | null = null;
let oidcConfig: {
authorizationEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
} | null = null;
function getOidcEnv() {
return {
issuer: process.env.AUTH_OIDC_ISSUER || '',
clientId: process.env.AUTH_OIDC_CLIENT_ID || '',
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET || ''
};
}
function getBaseUrl(): string {
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
}
async function ensureOidcClient(): Promise<arctic.OAuth2Client | null> {
const env = getOidcEnv();
if (!env.issuer || !env.clientId || !env.clientSecret) return null;
if (oidcClient && oidcConfig) return oidcClient;
// Discover OIDC endpoints
const discoveryUrl = env.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
const res = await fetch(discoveryUrl);
if (!res.ok) return null;
const config = (await res.json()) as {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
};
oidcConfig = {
authorizationEndpoint: config.authorization_endpoint,
tokenEndpoint: config.token_endpoint,
userinfoEndpoint: config.userinfo_endpoint
};
const redirectURI = `${getBaseUrl()}/api/auth/oauth/oidc/callback`;
oidcClient = new arctic.OAuth2Client(env.clientId, env.clientSecret, redirectURI);
return oidcClient;
}
export async function createOidcAuthUrl(): Promise<{ url: URL; state: string; codeVerifier: string } | null> {
const client = await ensureOidcClient();
if (!client || !oidcConfig) return null;
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
const scopes = ['openid', 'profile', 'email'];
const url = client.createAuthorizationURLWithPKCE(
oidcConfig.authorizationEndpoint,
state,
arctic.CodeChallengeMethod.S256,
codeVerifier,
scopes
);
return { url, state, codeVerifier };
}
export async function validateOidcCallback(
code: string,
codeVerifier: string
): Promise<{ email: string; name: string; providerId: string } | null> {
const client = await ensureOidcClient();
if (!client || !oidcConfig) return null;
const tokens = await client.validateAuthorizationCode(
oidcConfig.tokenEndpoint,
code,
codeVerifier
);
// Try to decode ID token first
const idToken = tokens.idToken();
if (idToken) {
const claims = arctic.decodeIdToken(idToken) as {
sub: string;
email?: string;
name?: string;
};
if (claims.email) {
return {
email: claims.email,
name: claims.name || claims.email.split('@')[0],
providerId: claims.sub
};
}
}
// Fallback to userinfo endpoint
const accessToken = tokens.accessToken();
const userRes = await fetch(oidcConfig.userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!userRes.ok) return null;
const userInfo = (await userRes.json()) as {
sub: string;
email?: string;
name?: string;
};
if (!userInfo.email) return null;
return {
email: userInfo.email,
name: userInfo.name || userInfo.email.split('@')[0],
providerId: userInfo.sub
};
}
+50
View File
@@ -0,0 +1,50 @@
import * as arctic from 'arctic';
export type OAuthProviderInfo = {
id: string;
name: string;
icon?: string;
};
let googleClient: arctic.Google | null = null;
let githubClient: arctic.GitHub | null = null;
function getBaseUrl(): string {
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
}
export function getGoogleClient(): arctic.Google | null {
if (googleClient) return googleClient;
const clientId = process.env.AUTH_GOOGLE_CLIENT_ID;
const clientSecret = process.env.AUTH_GOOGLE_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
googleClient = new arctic.Google(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/google/callback`);
return googleClient;
}
export function getGithubClient(): arctic.GitHub | null {
if (githubClient) return githubClient;
const clientId = process.env.AUTH_GITHUB_CLIENT_ID;
const clientSecret = process.env.AUTH_GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
githubClient = new arctic.GitHub(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/github/callback`);
return githubClient;
}
export function getEnabledProviders(): OAuthProviderInfo[] {
const providers: OAuthProviderInfo[] = [];
if (getGoogleClient()) providers.push({ id: 'google', name: 'Google' });
if (getGithubClient()) providers.push({ id: 'github', name: 'GitHub' });
const oidcIssuer = process.env.AUTH_OIDC_ISSUER;
const oidcClientId = process.env.AUTH_OIDC_CLIENT_ID;
const oidcClientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
if (oidcIssuer && oidcClientId && oidcClientSecret) {
providers.push({
id: 'oidc',
name: process.env.AUTH_OIDC_DISPLAY_NAME || 'SSO'
});
}
return providers;
}
+79
View File
@@ -0,0 +1,79 @@
import { db } from './db/index.js';
import { loginAttempts } from './db/schema.js';
import { and, eq, gte, 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(
ip: string,
email: string
): { 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);
// Count failed attempts from this IP in the lockout window
const result = db
.select({ count: sql<number>`count(*)` })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ip, ip),
eq(loginAttempts.success, false),
gte(loginAttempts.timestamp, windowStart)
)
)
.get();
const failedCount = result?.count ?? 0;
if (failedCount >= MAX_ATTEMPTS) {
// Find the most recent failed attempt to calculate retry-after
const latest = db
.select({ timestamp: loginAttempts.timestamp })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ip, ip),
eq(loginAttempts.success, false),
gte(loginAttempts.timestamp, windowStart)
)
)
.orderBy(sql`${loginAttempts.timestamp} DESC`)
.limit(1)
.get();
if (latest) {
const retryAfter = Math.ceil(
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
);
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
}
return { allowed: false, retryAfter: 900 };
}
return { allowed: true };
}
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
db.insert(loginAttempts)
.values({
ip,
email,
success,
timestamp: new Date()
})
.run();
}
export function clearOldAttempts(): void {
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
db.delete(loginAttempts)
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
.run();
}
+71 -12
View File
@@ -3,6 +3,7 @@ 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');
@@ -27,17 +28,32 @@ export const filteredNotes = derived(
}
);
export const pinnedNotes = derived(filteredNotes, ($notes) =>
$notes.filter((n) => n.pinned)
function sortNotes(list: Note[], mode: SortMode): 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
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 unpinnedNotes = derived(filteredNotes, ($notes) =>
$notes.filter((n) => !n.pinned)
export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
sortNotes($notes.filter((n) => !n.pinned), $sortMode)
);
export const allTags = derived(notes, ($notes) => {
const tagSet = new Set<string>();
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
$notes
.filter((n) => !n.archived && !n.trashed)
.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
return [...tagSet].sort();
});
@@ -47,9 +63,13 @@ function isNetworkError(err: unknown): boolean {
export async function loadNotes(filter: NoteFilter = 'all') {
// Load from IDB first for instant display
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
try {
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
}
} catch {
// IDB unavailable — continue with server fetch
}
currentFilter.set(filter);
notesLoaded.set(true);
@@ -63,8 +83,16 @@ export async function loadNotes(filter: NoteFilter = 'all') {
currentFilter.set(filter);
// Update IDB with server state
await clearIdbNotes();
await Promise.all(data.map((n) => putNote(n)));
try {
await clearIdbNotes();
await Promise.all(data.map((n) => putNote(n)));
} catch {
// IDB write failed — server data is still in the store
}
} else if (res.status === 401) {
window.location.href = '/login';
} else {
showToast('Failed to load notes', 'error');
}
} catch {
// Offline — IDB data already loaded above
@@ -84,6 +112,11 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
await putNote(created);
return created;
}
if (res.status === 401) {
window.location.href = '/login';
} else {
showToast('Failed to save note', 'error');
}
return null;
} catch (err) {
if (isNetworkError(err)) {
@@ -179,7 +212,9 @@ export async function deleteNote(id: string): Promise<boolean> {
}
export async function trashNote(id: string): Promise<Note | null> {
return updateNote(id, { trashed: true });
const result = await updateNote(id, { trashed: true });
if (result) notes.update((list) => list.filter((n) => n.id !== id));
return result;
}
export async function restoreNote(id: string): Promise<Note | null> {
@@ -187,7 +222,9 @@ export async function restoreNote(id: string): Promise<Note | null> {
}
export async function archiveNote(id: string): Promise<Note | null> {
return updateNote(id, { archived: true });
const result = await updateNote(id, { archived: true });
if (result) notes.update((list) => list.filter((n) => n.id !== id));
return result;
}
export async function unarchiveNote(id: string): Promise<Note | null> {
@@ -197,3 +234,25 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
return updateNote(id, { pinned: !currentPinned });
}
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
// Optimistic update
notes.update((list) =>
list.map((n) => {
const order = orders.find((o) => o.id === n.id);
return order ? { ...n, sortOrder: order.sortOrder } : n;
})
);
try {
const res = await fetch('/api/notes/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orders })
});
return res.ok;
} catch {
// Offline — optimistic update stands
return false;
}
}
+24
View File
@@ -0,0 +1,24 @@
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);
});
}
+55 -14
View File
@@ -1,6 +1,6 @@
import { browser } from '$app/environment';
import type { Note } from '$lib/types/index.js';
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta, getPendingAttachments, removePendingAttachment } from './idb.js';
import { mergeNotes } from './crdt.js';
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
import { get } from 'svelte/store';
@@ -29,7 +29,12 @@ export function getSyncStatus(): SyncStatus {
* Push local changes to server.
*/
async function pushChanges(): Promise<boolean> {
const queue = await getSyncQueue();
let queue;
try {
queue = await getSyncQueue();
} catch {
return true; // IDB unavailable — nothing to push
}
if (queue.length === 0) return true;
try {
@@ -49,31 +54,66 @@ async function pushChanges(): Promise<boolean> {
}
}
/**
* Push pending offline attachments to server.
*/
async function pushPendingAttachments(): Promise<void> {
const pending = await getPendingAttachments();
for (const item of pending) {
try {
const formData = new FormData();
formData.append('file', new File([item.optimized], item.filename, { type: item.mimeType }));
formData.append('thumbnail', new File([item.thumbnail], `${item.filename}_thumb.webp`, { type: 'image/webp' }));
const res = await fetch(`/api/notes/${item.noteId}/attachments`, {
method: 'POST',
body: formData
});
if (res.ok) {
await removePendingAttachment(item.id);
}
} catch {
// Will retry on next sync cycle
}
}
}
/**
* Pull remote changes and merge with local state.
*/
async function pullChanges(): Promise<Note[]> {
const lastSync = (await getMeta('lastSyncTimestamp')) || '0';
let lastSync = '0';
try {
lastSync = (await getMeta('lastSyncTimestamp')) || '0';
} catch {
// IDB unavailable — pull everything
}
try {
const res = await fetch(`/api/sync?since=${lastSync}`);
if (!res.ok) return [];
const remoteNotes: Note[] = await res.json();
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const merged = mergeNotes(local, remote);
await putNote(merged);
} else {
await putNote(remote);
try {
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const merged = mergeNotes(local, remote);
await putNote(merged);
} else {
await putNote(remote);
}
}
}
await setMeta('lastSyncTimestamp', String(Date.now()));
await setMeta('lastSyncTimestamp', String(Date.now()));
} catch {
// IDB write failed — server data still returned for store use
}
return remoteNotes;
} catch {
return [];
@@ -98,6 +138,7 @@ export async function sync(): Promise<void> {
return;
}
await pushPendingAttachments();
await pullChanges();
setStatus('synced');
+74 -4
View File
@@ -1,8 +1,18 @@
import { openDB, type IDBPDatabase } from 'idb';
import { openDB, deleteDB, type IDBPDatabase } from 'idb';
import type { Note } from '$lib/types/index.js';
const DB_NAME = 'crumbs';
const DB_VERSION = 1;
const DB_PREFIX = 'crumbs';
const DB_VERSION = 2;
export interface PendingAttachment {
id: string;
noteId: string;
optimized: Blob;
thumbnail: Blob;
filename: string;
mimeType: string;
timestamp: number;
}
interface CrumbsDB {
notes: {
@@ -23,6 +33,13 @@ interface CrumbsDB {
key: string;
value: { key: string; value: string };
};
pendingAttachments: {
key: string;
value: PendingAttachment;
indexes: {
'by-noteId': string;
};
};
}
export interface SyncQueueItem {
@@ -34,10 +51,23 @@ export interface SyncQueueItem {
}
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
let currentUserId: number | null = null;
/**
* Initialize the IDB for a specific user. Must be called before any
* other IDB operation (typically after login when userId is known).
*/
export function initDb(userId: number): void {
if (currentUserId !== userId) {
dbPromise = null;
currentUserId = userId;
}
}
export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
if (!dbPromise) {
dbPromise = openDB<CrumbsDB>(DB_NAME, DB_VERSION, {
const dbName = currentUserId ? `${DB_PREFIX}-${currentUserId}` : DB_PREFIX;
dbPromise = openDB<CrumbsDB>(dbName, DB_VERSION, {
upgrade(db) {
// Notes store
if (!db.objectStoreNames.contains('notes')) {
@@ -58,12 +88,33 @@ export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' });
}
// Pending attachments (offline uploads)
if (!db.objectStoreNames.contains('pendingAttachments')) {
const pendingStore = db.createObjectStore('pendingAttachments', { keyPath: 'id' });
pendingStore.createIndex('by-noteId', 'noteId');
}
}
});
}
return dbPromise;
}
/**
* Delete the current user's IDB (call on logout).
*/
export async function destroyDb(): Promise<void> {
if (dbPromise) {
const db = await dbPromise;
db.close();
dbPromise = null;
}
if (currentUserId) {
await deleteDB(`${DB_PREFIX}-${currentUserId}`);
currentUserId = null;
}
}
// Notes operations
export async function getAllNotes(): Promise<Note[]> {
const db = await getDb();
@@ -122,3 +173,22 @@ export async function setMeta(key: string, value: string): Promise<void> {
const db = await getDb();
await db.put('meta', { key, value });
}
// Pending attachment operations
export async function addPendingAttachment(item: PendingAttachment): Promise<void> {
const db = await getDb();
await db.put('pendingAttachments', item);
}
export async function getPendingAttachments(noteId?: string): Promise<PendingAttachment[]> {
const db = await getDb();
if (noteId) {
return db.getAllFromIndex('pendingAttachments', 'by-noteId', noteId);
}
return db.getAll('pendingAttachments');
}
export async function removePendingAttachment(id: string): Promise<void> {
const db = await getDb();
await db.delete('pendingAttachments', id);
}
+11 -9
View File
@@ -1,6 +1,6 @@
import { db } from '$lib/server/db/index.js';
import { notes, syncLog } from '$lib/server/db/schema.js';
import { eq, gt } from 'drizzle-orm';
import { eq, gt, and } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import type { SyncQueueItem } from './idb.js';
@@ -8,7 +8,7 @@ import type { SyncQueueItem } from './idb.js';
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise<void> {
const noteIdsToSyncTags: string[] = [];
db.transaction((tx) => {
@@ -20,7 +20,7 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
const existing = tx
.select()
.from(notes)
.where(eq(notes.id, change.noteId))
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.get();
if (existing) {
@@ -31,13 +31,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
updatedAt: new Date(change.timestamp),
version: existing.version + 1
})
.where(eq(notes.id, change.noteId))
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
.run();
}
} else if (change.operation === 'create' && change.data) {
tx.insert(notes)
.values({
id: change.noteId,
userId,
title: change.data.title || '',
content: change.data.content || '',
color: change.data.color || 'default',
@@ -59,13 +60,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
break;
}
case 'delete': {
tx.delete(notes).where(eq(notes.id, change.noteId)).run();
tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run();
break;
}
}
tx.insert(syncLog)
.values({
userId,
noteId: change.noteId,
operation: change.operation,
timestamp: new Date(change.timestamp),
@@ -76,7 +78,7 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
});
for (const noteId of noteIdsToSyncTags) {
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
if (note) {
const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content));
@@ -85,12 +87,12 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
}
/**
* Get all notes updated since a given timestamp.
* Get all notes updated since a given timestamp for a specific user.
*/
export async function getChangesSince(sinceTimestamp: number) {
export async function getChangesSince(sinceTimestamp: number, userId: number) {
return db
.select()
.from(notes)
.where(gt(notes.updatedAt, new Date(sinceTimestamp)))
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp))))
.all();
}
+12
View File
@@ -42,6 +42,8 @@ export interface Attachment {
mimeType: string;
size: number;
path: string;
thumbnailPath?: string | null;
featured: boolean;
createdAt: Date;
}
@@ -50,6 +52,16 @@ export interface Tag {
name: string;
}
export interface User {
id: number;
email: string;
displayName: string;
role: 'admin' | 'user';
authProvider: string;
providerId: string | null;
createdAt: Date;
}
export interface SyncChange {
noteId: string;
operation: 'create' | 'update' | 'delete';
+124
View File
@@ -0,0 +1,124 @@
const MAX_DIMENSION = 1920;
const THUMB_SIZE = 200;
const QUALITY = 0.8;
const THUMB_QUALITY = 0.6;
const SKIP_THRESHOLD = 200 * 1024; // 200KB
interface OptimizeResult {
optimized: Blob;
thumbnail: Blob;
}
/**
* Check if a PNG has transparency by examining the alpha channel.
* Reads a sample of pixels to determine if any are non-opaque.
*/
function hasTransparency(canvas: HTMLCanvasElement | OffscreenCanvas, ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, width: number, height: number): boolean {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
// Check every 4th value (alpha channel) with stride for performance
const stride = Math.max(1, Math.floor(data.length / (4 * 10000))) * 4;
for (let i = 3; i < data.length; i += stride) {
if (data[i] < 255) return true;
}
return false;
}
function createCanvas(width: number, height: number): { canvas: HTMLCanvasElement | OffscreenCanvas; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D } {
if (typeof OffscreenCanvas !== 'undefined') {
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d')!;
return { canvas, ctx };
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
return { canvas, ctx };
}
async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas, type: string, quality: number): Promise<Blob> {
if (canvas instanceof OffscreenCanvas) {
return canvas.convertToBlob({ type, quality });
}
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), type, quality);
});
}
async function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(img.src);
resolve(img);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
function fitDimensions(width: number, height: number, maxDim: number): { width: number; height: number } {
if (width <= maxDim && height <= maxDim) return { width, height };
const ratio = Math.min(maxDim / width, maxDim / height);
return {
width: Math.round(width * ratio),
height: Math.round(height * ratio)
};
}
/**
* Optimize an image file for upload:
* - Resize to fit within 1920x1920 (preserve aspect ratio)
* - Skip optimization if already < 200KB and within size limits
* - Compress JPEG/WebP to ~80% quality
* - Convert non-transparent PNG/BMP to WebP; keep transparent PNG as PNG
* - Pass GIF/SVG through unmodified
* - Generate a 200x200 thumbnail at 60% quality WebP
*/
export async function optimizeImage(file: File): Promise<OptimizeResult> {
const type = file.type;
// GIF and SVG pass through unmodified (but still generate thumbnail for GIF)
if (type === 'image/svg+xml') {
return { optimized: file, thumbnail: file };
}
const img = await loadImage(file);
const { width, height } = img;
// Generate thumbnail
const thumbDims = fitDimensions(width, height, THUMB_SIZE);
const { canvas: thumbCanvas, ctx: thumbCtx } = createCanvas(thumbDims.width, thumbDims.height);
thumbCtx.drawImage(img, 0, 0, thumbDims.width, thumbDims.height);
const thumbnail = await canvasToBlob(thumbCanvas, 'image/webp', THUMB_QUALITY);
// GIF: pass original through but use generated thumbnail
if (type === 'image/gif') {
return { optimized: file, thumbnail };
}
// Check if optimization can be skipped
const fittedDims = fitDimensions(width, height, MAX_DIMENSION);
const needsResize = fittedDims.width !== width || fittedDims.height !== height;
if (!needsResize && file.size < SKIP_THRESHOLD) {
return { optimized: file, thumbnail };
}
// Draw at target size
const { canvas, ctx } = createCanvas(fittedDims.width, fittedDims.height);
ctx.drawImage(img, 0, 0, fittedDims.width, fittedDims.height);
// Determine output format
let outputType = 'image/webp';
let quality = QUALITY;
if (type === 'image/png' && hasTransparency(canvas, ctx, fittedDims.width, fittedDims.height)) {
outputType = 'image/png';
quality = 1; // PNG is lossless, quality param ignored but set for clarity
}
const optimized = await canvasToBlob(canvas, outputType, quality);
return { optimized, thumbnail };
}
+7 -3
View File
@@ -1,5 +1,9 @@
import { version } from '../../../package.json';
import type { LayoutServerLoad } from './$types';
export function load() {
return { appVersion: version };
}
export const load: LayoutServerLoad = (event) => {
return {
appVersion: version,
user: event.locals.user
};
};
+7 -3
View File
@@ -4,6 +4,7 @@
import Toast from '$lib/components/Layout/Toast.svelte';
import { loadNotes } from '$lib/stores/notes.js';
import { startSync, stopSync } from '$lib/sync/client.js';
import { initDb } from '$lib/sync/idb.js';
import { onMount, onDestroy } from 'svelte';
let { data, children } = $props();
@@ -11,6 +12,9 @@
onMount(() => {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
if (data.user) {
initDb(data.user.id);
}
loadNotes();
startSync();
});
@@ -21,8 +25,8 @@
</script>
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} user={data.user} />
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
<div class="mx-auto max-w-7xl px-4">
@@ -31,7 +35,7 @@
</main>
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
Crumbs by Bretzel v{data.appVersion} &mdash; made with 🥨 in Strasbourg
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>
<Toast />
+22 -5
View File
@@ -2,7 +2,9 @@
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded } from '$lib/stores/notes.js';
import SortSelector from '$lib/components/SortSelector.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, updateSortOrders } from '$lib/stores/notes.js';
import { sortMode } from '$lib/stores/sort.js';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import type { Note } from '$lib/types/index.js';
@@ -22,6 +24,11 @@
history.replaceState(null, '', location.pathname);
}
function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders);
}
onMount(() => {
const hash = location.hash.slice(1);
if (!hash) return;
@@ -53,9 +60,16 @@
</div>
{/if}
<div class="mb-4">
<TagFilter />
</div>
{#if $currentFilter === '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}
{#if $currentFilter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
@@ -65,7 +79,7 @@
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} />
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
</div>
{/if}
@@ -73,6 +87,9 @@
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable={$sortMode === 'custom'}
dndType="unpinned-notes"
onReorder={handleReorder}
/>
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
+286
View File
@@ -0,0 +1,286 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
type SessionInfo = {
id: string;
createdAt: string | null;
userAgent: string | null;
ip: string | null;
lastUsedAt: string | null;
expiresAt: string;
isCurrent: boolean;
};
let { data } = $props();
let displayName = $state(data.user?.displayName || '');
let email = $state(data.user?.email || '');
let profileMsg = $state('');
let profileError = $state(false);
let currentPassword = $state('');
let newPassword = $state('');
let confirmNewPassword = $state('');
let passwordMsg = $state('');
let passwordError = $state(false);
let activeSessions = $state<SessionInfo[]>([]);
let sessionsLoaded = $state(false);
async function loadSessions() {
try {
const res = await fetch('/api/auth/sessions');
if (res.ok) activeSessions = await res.json();
sessionsLoaded = true;
} catch {
// ignore
}
}
async function revokeSession(sessionId: string) {
try {
await fetch('/api/auth/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId })
});
activeSessions = activeSessions.filter((s) => s.id !== sessionId);
} catch {
// ignore
}
}
async function revokeAllOtherSessions() {
try {
await fetch('/api/auth/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true })
});
activeSessions = activeSessions.filter((s) => s.isCurrent);
} catch {
// ignore
}
}
function formatAgent(ua: string | null): string {
if (!ua) return 'Unknown';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Edg/')) return 'Edge';
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Safari')) return 'Safari';
return ua.slice(0, 40);
}
function formatDate(d: string | null): string {
if (!d) return 'Unknown';
return new Date(d).toLocaleString();
}
$effect(() => {
loadSessions();
});
async function saveProfile() {
profileMsg = '';
try {
const res = await fetch('/api/auth/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName, email })
});
if (res.ok) {
profileMsg = 'Profile updated';
profileError = false;
} else {
const d = await res.json();
profileMsg = d.message || 'Failed to update';
profileError = true;
}
} catch {
profileMsg = 'Connection error';
profileError = true;
}
}
async function changePassword() {
passwordMsg = '';
if (newPassword.length < 8) {
passwordMsg = 'Password must be at least 8 characters';
passwordError = true;
return;
}
if (newPassword !== confirmNewPassword) {
passwordMsg = 'Passwords do not match';
passwordError = true;
return;
}
try {
const res = await fetch('/api/auth/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
if (res.ok) {
passwordMsg = 'Password changed';
passwordError = false;
currentPassword = '';
newPassword = '';
confirmNewPassword = '';
} else {
const d = await res.json();
passwordMsg = d.message || 'Failed to change password';
passwordError = true;
}
} catch {
passwordMsg = 'Connection error';
passwordError = true;
}
}
</script>
<svelte:head>
<title>Settings - Crumbs</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="mb-8 text-2xl font-bold text-[var(--text)]">Settings</h1>
<!-- Profile -->
<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)]">Profile</h2>
{#if profileMsg}
<div class="mb-4 rounded-sm border p-3 text-sm {profileError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
{profileMsg}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="display-name" class="mb-1 block text-sm font-medium text-[var(--text)]">Display Name</label>
<input
id="display-name"
type="text"
bind:value={displayName}
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)]"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-[var(--text)]">Email</label>
<input
id="email"
type="email"
bind:value={email}
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)]"
/>
</div>
<button
onclick={saveProfile}
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
>
Save profile
</button>
</div>
</section>
<!-- Change Password -->
<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)]">Change Password</h2>
{#if passwordMsg}
<div class="mb-4 rounded-sm border p-3 text-sm {passwordError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
{passwordMsg}
</div>
{/if}
<div class="space-y-4">
<input
type="password"
bind:value={currentPassword}
placeholder="Current password"
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="New 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} />
<input
type="password"
bind:value={confirmNewPassword}
placeholder="Confirm new password"
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)]"
/>
<button
onclick={changePassword}
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
>
Change password
</button>
</div>
</section>
<!-- Active Sessions -->
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[var(--text)]">Active Sessions</h2>
{#if activeSessions.length > 1}
<button
onclick={revokeAllOtherSessions}
class="text-xs text-[var(--destructive)] hover:underline"
>
Log out everywhere else
</button>
{/if}
</div>
{#if !sessionsLoaded}
<p class="text-sm text-[var(--text-muted)]">Loading sessions...</p>
{:else if activeSessions.length === 0}
<p class="text-sm text-[var(--text-muted)]">No active sessions</p>
{:else}
<div class="space-y-3">
{#each activeSessions as session (session.id)}
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-3">
<div>
<p class="text-sm font-medium text-[var(--text)]">
{formatAgent(session.userAgent)}
{#if session.isCurrent}
<span class="ml-2 rounded-sm bg-[var(--success-bg)] px-2 py-0.5 text-xs text-[var(--success-text)]">current</span>
{/if}
</p>
<p class="text-xs text-[var(--text-muted)]">
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
</p>
</div>
{#if !session.isCurrent}
<button
onclick={() => revokeSession(session.id)}
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Revoke
</button>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<!-- Admin: User Management Link -->
{#if data.user?.role === 'admin'}
<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)]">Administration</h2>
<a
href="/settings/users"
class="text-sm text-[var(--primary)] hover:text-[var(--primary-hover)] hover:underline"
>
Manage users →
</a>
</section>
{/if}
</div>
@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { listUsers } from '$lib/server/auth.js';
export const load: PageServerLoad = ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(302, '/settings');
}
return { users: listUsers() };
};
@@ -0,0 +1,181 @@
<script lang="ts">
import type { User } from '$lib/types/index.js';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
let { data } = $props();
let users = $state<User[]>(data.users);
// Create user form
let newEmail = $state('');
let newDisplayName = $state('');
let newPassword = $state('');
let newRole = $state<'admin' | 'user'>('user');
let createMsg = $state('');
let createError = $state(false);
async function createUser() {
createMsg = '';
if (!newEmail || !newPassword) {
createMsg = 'Email and password are required';
createError = true;
return;
}
if (newPassword.length < 8) {
createMsg = 'Password must be at least 8 characters';
createError = true;
return;
}
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: newEmail,
displayName: newDisplayName || newEmail.split('@')[0],
password: newPassword,
role: newRole
})
});
if (res.ok) {
const user = await res.json();
users = [...users, user];
newEmail = '';
newDisplayName = '';
newPassword = '';
newRole = 'user';
createMsg = 'User created';
createError = false;
} else {
const d = await res.json();
createMsg = d.message || 'Failed to create user';
createError = true;
}
} catch {
createMsg = 'Connection error';
createError = true;
}
}
async function deleteUser(userId: number) {
if (!confirm('Are you sure you want to delete this user and all their data?')) return;
try {
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
}
} catch {
// ignore
}
}
async function toggleRole(user: User) {
const newRoleValue = user.role === 'admin' ? 'user' : 'admin';
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRoleValue })
});
if (res.ok) {
users = users.map((u) => (u.id === user.id ? { ...u, role: newRoleValue } : u));
}
} catch {
// ignore
}
}
</script>
<svelte:head>
<title>User Management - Crumbs</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<div class="mb-8 flex items-center gap-4">
<a href="/settings" class="text-sm text-[var(--text-muted)] hover:text-[var(--primary)]">← Settings</a>
<h1 class="text-2xl font-bold text-[var(--text)]">User Management</h1>
</div>
<!-- Create User -->
<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)]">Create User</h2>
{#if createMsg}
<div class="mb-4 rounded-sm border p-3 text-sm {createError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
{createMsg}
</div>
{/if}
<div class="space-y-3">
<input
type="email"
bind:value={newEmail}
placeholder="Email"
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="text"
bind:value={newDisplayName}
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} />
<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)]"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onclick={createUser}
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
>
Create user
</button>
</div>
</section>
<!-- User List -->
<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)]">Users ({users.length})</h2>
<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)]">
{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>
</div>
<div class="flex 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"
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Make user' : 'Make admin'}
</button>
{#if user.id !== data.user?.id}
<button
onclick={() => deleteUser(user.id)}
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
>
Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
</section>
</div>
+8
View File
@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types.js';
import { getEnabledProviders } from '$lib/server/oauth/providers.js';
export const load: PageServerLoad = async () => {
return {
providers: getEnabledProviders()
};
};
+66 -5
View File
@@ -1,8 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
let { data } = $props();
let email = $state('');
let password = $state('');
let errorMsg = $state('');
let loading = $state(false);
// Check for OAuth error in URL
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
if (urlParams?.get('error') === 'no_account') {
errorMsg = 'No account found for this email. Ask your admin for an invite.';
}
async function handleLogin(e: Event) {
e.preventDefault();
errorMsg = '';
@@ -12,14 +23,17 @@
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
body: JSON.stringify({ email, password })
});
if (res.ok) {
window.location.href = '/';
goto('/');
} else if (res.status === 429) {
const d = await res.json();
errorMsg = d.error || 'Too many attempts. Please try again later.';
} else {
const data = await res.json();
errorMsg = data.message || 'Invalid password';
const d = await res.json();
errorMsg = d.message || 'Invalid email or password';
}
} catch {
errorMsg = 'Connection error';
@@ -27,6 +41,8 @@
loading = false;
}
}
const providers = data.providers ?? [];
</script>
<svelte:head>
@@ -37,7 +53,7 @@
<div class="mb-6 text-center">
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Crumbs</h1>
<p class="mt-2 text-sm text-[var(--text-muted)]">Enter your password to continue</p>
<p class="mt-2 text-sm text-[var(--text-muted)]">Sign in to continue</p>
</div>
<form onsubmit={handleLogin}>
@@ -47,6 +63,15 @@
</div>
{/if}
<input
type="email"
bind:value={email}
placeholder="Email"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="email-input"
required
/>
<input
type="password"
bind:value={password}
@@ -65,4 +90,40 @@
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{#if providers.length > 0}
<div class="my-6 flex items-center gap-3">
<div class="h-px flex-1 bg-[var(--border-subtle)]"></div>
<span class="text-xs text-[var(--text-muted)]">or continue with</span>
<div class="h-px flex-1 bg-[var(--border-subtle)]"></div>
</div>
<div class="space-y-2">
{#each providers as provider (provider.id)}
<a
href="/api/auth/oauth/{provider.id}"
class="flex w-full items-center justify-center gap-2 rounded-sm border border-[var(--border-subtle)] px-4 py-3 text-sm font-medium text-[var(--text)] transition-colors hover:border-[var(--primary)] hover:bg-[var(--bg-base)]"
data-testid="oauth-{provider.id}-btn"
>
{#if provider.id === 'google'}
<svg class="h-5 w-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
{:else if provider.id === 'github'}
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{:else}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{/if}
{provider.name}
</a>
{/each}
</div>
{/if}
</div>
+32 -6
View File
@@ -1,4 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
let email = $state('');
let displayName = $state('');
let password = $state('');
let confirmPassword = $state('');
let errorMsg = $state('');
@@ -8,6 +12,11 @@
e.preventDefault();
errorMsg = '';
if (!email) {
errorMsg = 'Email is required';
return;
}
if (password.length < 8) {
errorMsg = 'Password must be at least 8 characters';
return;
@@ -24,11 +33,11 @@
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
body: JSON.stringify({ email, displayName, password })
});
if (res.ok) {
window.location.href = '/';
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Setup failed';
@@ -49,7 +58,7 @@
<div class="mb-6 text-center">
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Welcome to Crumbs</h1>
<p class="mt-2 text-sm text-[var(--text-muted)]">Set up your password to get started</p>
<p class="mt-2 text-sm text-[var(--text-muted)]">Create your admin account to get started</p>
</div>
<form onsubmit={handleSetup}>
@@ -59,6 +68,23 @@
</div>
{/if}
<input
type="email"
bind:value={email}
placeholder="Email"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="email-input"
required
/>
<input
type="text"
bind:value={displayName}
placeholder="Display name (optional)"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="display-name-input"
/>
<input
type="password"
bind:value={password}
@@ -66,7 +92,7 @@
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="password-input"
required
/>
/>
<input
type="password"
@@ -75,7 +101,7 @@
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="confirm-password-input"
required
/>
/>
<button
type="submit"
@@ -83,7 +109,7 @@
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
data-testid="setup-btn"
>
{loading ? 'Setting up...' : 'Set password & start'}
{loading ? 'Setting up...' : 'Create account & start'}
</button>
</form>
</div>
+5
View File
@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types.js';
export const load: LayoutServerLoad = ({ locals }) => {
return { user: locals.user };
};
+25
View File
@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { listUsers, createUser } from '$lib/server/auth.js';
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
return json(listUsers());
};
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 (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');
return json(user, { status: 201 });
};
@@ -0,0 +1,46 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/api-utils.js';
import { getUser, deleteUser, updateUserRole, resetPassword } from '$lib/server/auth.js';
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const admin = requireAdmin(event);
const userId = parseInt(params.id, 10);
const body = await request.json();
const user = getUser(userId);
if (!user) throw error(404, 'User not found');
if (body.role !== undefined) {
// Prevent admin from demoting themselves
if (userId === admin.id && body.role !== 'admin') {
throw error(400, 'Cannot change your own role');
}
updateUserRole(userId, body.role);
}
if (body.newPassword !== undefined) {
if (body.newPassword.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
await resetPassword(userId, body.newPassword);
}
const updated = getUser(userId);
return json(updated);
};
export const DELETE: RequestHandler = async ({ params, ...event }) => {
const admin = requireAdmin(event);
const userId = parseInt(params.id, 10);
if (userId === admin.id) {
throw error(400, 'Cannot delete your own account');
}
const user = getUser(userId);
if (!user) throw error(404, 'User not found');
await deleteUser(userId);
return json({ success: true });
};
+22 -8
View File
@@ -1,20 +1,34 @@
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';
export const POST: RequestHandler = async ({ request, cookies }) => {
const { password } = await request.json();
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const ip = getClientAddress();
const { email, password } = await request.json();
if (!password) {
throw error(400, 'Password is required');
if (!email || !password) {
throw error(400, 'Email and password are required');
}
const valid = await verifyPassword(password);
if (!valid) {
throw error(401, 'Invalid password');
const rateCheck = checkRateLimit(ip, email);
if (!rateCheck.allowed) {
return json(
{ error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter },
{ status: 429, headers: { 'Retry-After': String(rateCheck.retryAfter) } }
);
}
const token = await createSession();
const user = await verifyPassword(email, password);
if (!user) {
recordLoginAttempt(ip, email, false);
throw error(401, 'Invalid email or password');
}
recordLoginAttempt(ip, email, true);
const userAgent = request.headers.get('user-agent') || undefined;
const token = await createSession(user.id, { userAgent, ip });
cookies.set('session', token, {
path: '/',
httpOnly: true,
@@ -0,0 +1,65 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { createGoogleAuthUrl } from '$lib/server/oauth/google.js';
import { createGithubAuthUrl } from '$lib/server/oauth/github.js';
import { createOidcAuthUrl } from '$lib/server/oauth/oidc.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const { provider } = params;
if (provider === 'google') {
const result = createGoogleAuthUrl();
if (!result) throw error(404, 'Google OAuth not configured');
cookies.set('oauth_state', result.state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
cookies.set('oauth_code_verifier', result.codeVerifier, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
throw redirect(302, result.url.toString());
}
if (provider === 'github') {
const result = createGithubAuthUrl();
if (!result) throw error(404, 'GitHub OAuth not configured');
cookies.set('oauth_state', result.state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
throw redirect(302, result.url.toString());
}
if (provider === 'oidc') {
const result = await createOidcAuthUrl();
if (!result) throw error(404, 'OIDC not configured');
cookies.set('oauth_state', result.state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
cookies.set('oauth_code_verifier', result.codeVerifier, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 600
});
throw redirect(302, result.url.toString());
}
throw error(404, 'Unknown provider');
};
@@ -0,0 +1,63 @@
import { redirect, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { validateGoogleCallback } from '$lib/server/oauth/google.js';
import { validateGithubCallback } from '$lib/server/oauth/github.js';
import { validateOidcCallback } from '$lib/server/oauth/oidc.js';
import { findOrLinkOAuthUser, createSession } from '$lib/server/auth.js';
export const GET: RequestHandler = async ({ params, url, cookies, request, getClientAddress }) => {
const { provider } = params;
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies.get('oauth_state');
if (!code || !state || !storedState || state !== storedState) {
throw error(400, 'Invalid OAuth callback');
}
// Clean up OAuth cookies
cookies.delete('oauth_state', { path: '/' });
let profile: { email: string; name: string; providerId: string } | null = null;
if (provider === 'google') {
const codeVerifier = cookies.get('oauth_code_verifier');
cookies.delete('oauth_code_verifier', { path: '/' });
if (!codeVerifier) throw error(400, 'Missing code verifier');
profile = await validateGoogleCallback(code, codeVerifier);
} else if (provider === 'github') {
profile = await validateGithubCallback(code);
} else if (provider === 'oidc') {
const codeVerifier = cookies.get('oauth_code_verifier');
cookies.delete('oauth_code_verifier', { path: '/' });
if (!codeVerifier) throw error(400, 'Missing code verifier');
profile = await validateOidcCallback(code, codeVerifier);
} else {
throw error(404, 'Unknown provider');
}
if (!profile) {
throw error(400, 'Failed to get profile from OAuth provider');
}
// Find or link user (invite-only)
const user = findOrLinkOAuthUser(provider, profile.providerId, profile.email, profile.name);
if (!user) {
// User not found — not invited
throw redirect(302, '/login?error=no_account');
}
// Create session
const userAgent = request.headers.get('user-agent') || undefined;
const ip = getClientAddress();
const token = await createSession(user.id, { userAgent, ip });
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60
});
throw redirect(302, '/');
};
+24
View File
@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { changePassword } from '$lib/server/auth.js';
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { currentPassword, newPassword } = await request.json();
if (!currentPassword || !newPassword) {
throw error(400, 'Current password and new password are required');
}
if (newPassword.length < 8) {
throw error(400, 'New password must be at least 8 characters');
}
const success = await changePassword(userId, currentPassword, newPassword);
if (!success) {
throw error(401, 'Current password is incorrect');
}
return json({ success: true });
};
+16
View File
@@ -0,0 +1,16 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { updateUserProfile } from '$lib/server/auth.js';
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { displayName, email } = await request.json();
if (email !== undefined && !email) {
throw error(400, 'Email cannot be empty');
}
updateUserProfile(userId, { displayName, email });
return json({ success: true });
};
+33
View File
@@ -0,0 +1,33 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { listUserSessions, revokeSession, revokeAllSessions } from '$lib/server/auth.js';
export const GET: RequestHandler = async (event) => {
const userId = getUserId(event);
const currentToken = event.cookies.get('session') || '';
const sessions = listUserSessions(userId, currentToken);
return json(sessions);
};
export const DELETE: RequestHandler = async (event) => {
const userId = getUserId(event);
const { sessionId, all } = await event.request.json();
if (all) {
const currentToken = event.cookies.get('session') || '';
revokeAllSessions(userId, currentToken);
return json({ success: true });
}
if (!sessionId) {
throw error(400, 'sessionId is required');
}
const revoked = revokeSession(userId, sessionId);
if (!revoked) {
throw error(404, 'Session not found');
}
return json({ success: true });
};
+11 -5
View File
@@ -2,24 +2,30 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { setupUser, createSession, isSetupComplete } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
const alreadySetup = await isSetupComplete();
if (alreadySetup) {
throw error(400, 'Setup already completed');
}
const { password } = await request.json();
const { email, displayName, password } = await request.json();
if (!email) {
throw error(400, 'Email is required');
}
if (!password || password.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
const success = await setupUser(password);
if (!success) {
const user = await setupUser(email, password, displayName);
if (!user) {
throw error(500, 'Failed to create user');
}
const token = await createSession();
const userAgent = request.headers.get('user-agent') || undefined;
const ip = getClientAddress();
const token = await createSession(user.id, { userAgent, ip });
cookies.set('session', token, {
path: '/',
httpOnly: true,
+15 -7
View File
@@ -6,20 +6,23 @@ 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';
export const GET: RequestHandler = async ({ url }) => {
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.archived, true), eq(notes.trashed, false));
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
conditions = eq(notes.trashed, true);
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
break;
default:
conditions = and(eq(notes.archived, false), eq(notes.trashed, false));
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
}
const result = db
@@ -29,22 +32,27 @@ export const GET: RequestHandler = async ({ url }) => {
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
const tagMap = fetchTagsForNotes(result.map((n) => n.id));
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) ?? []
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
return json(notesWithTags);
};
export const POST: RequestHandler = async ({ request }) => {
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',
+39 -12
View File
@@ -2,25 +2,42 @@ 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 } from 'drizzle-orm';
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';
export const GET: RequestHandler = async ({ params }) => {
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
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();
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) ?? [] });
return json({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
});
};
export const PATCH: RequestHandler = async ({ params, request }) => {
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
const body = await request.json();
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
const existing = db
.select()
.from(notes)
.where(and(eq(notes.id, params.id), eq(notes.userId, userId)))
.get();
if (!existing) {
throw error(404, 'Note not found');
@@ -44,9 +61,9 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
db.update(notes).set(updates).where(eq(notes.id, params.id)).run();
db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
const updated = db.select().from(notes).where(eq(notes.id, params.id)).get();
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}`;
@@ -55,17 +72,27 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
}
const tagMap = fetchTagsForNotes([params.id]);
const attachmentMap = fetchAttachmentsForNotes([params.id]);
return json({ ...updated, tags: tagMap.get(params.id) ?? [] });
return json({
...updated,
tags: tagMap.get(params.id) ?? [],
attachments: attachmentMap.get(params.id) ?? []
});
};
export const DELETE: RequestHandler = async ({ params }) => {
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
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();
if (!existing) {
throw error(404, 'Note not found');
}
db.delete(notes).where(eq(notes.id, params.id)).run();
db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run();
return json({ success: true });
};
@@ -1,20 +1,43 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment } from '$lib/server/attachments.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;
}
export const GET: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
export const GET: RequestHandler = async ({ params, url }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) {
const attachment = await getAttachment(attachmentId);
if (!attachment) throw error(404, 'Attachment not found');
const buffer = await readFile(attachment.path);
const wantThumb = url.searchParams.get('thumb') === '1';
const filePath = wantThumb && attachment.thumbnailPath ? attachment.thumbnailPath : attachment.path;
const mimeType = wantThumb && attachment.thumbnailPath ? 'image/webp' : attachment.mimeType;
const buffer = await readFile(filePath);
return new Response(buffer, {
headers: {
'Content-Type': attachment.mimeType,
'Content-Disposition': `inline; filename="${attachment.filename}"`
'Content-Type': mimeType,
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=31536000, immutable'
}
});
}
@@ -23,7 +46,10 @@ export const GET: RequestHandler = async ({ params, url }) => {
return json(list);
};
export const POST: RequestHandler = async ({ params, request }) => {
export const POST: RequestHandler = async ({ params, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
const formData = await request.formData();
const file = formData.get('file') as File;
@@ -40,11 +66,31 @@ export const POST: RequestHandler = async ({ params, request }) => {
throw error(400, 'File too large (max 10MB)');
}
const attachment = await saveAttachment(params.id, file);
const thumbnail = formData.get('thumbnail') as File | null;
const attachment = await saveAttachment(params.id, file, userId, thumbnail);
return json(attachment, { status: 201 });
};
export const DELETE: RequestHandler = async ({ url }) => {
export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
const body = await request.json();
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
const updated = await updateAttachment(attachmentId, { featured: body.featured });
if (!updated) throw error(404, 'Attachment not found');
return json(updated);
};
export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const userId = getUserId(event);
await verifyNoteOwnership(params.id, userId);
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
+22
View File
@@ -0,0 +1,22 @@
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 } from 'drizzle-orm';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const orders: { id: string; sortOrder: number }[] = body.orders;
if (!Array.isArray(orders)) {
return json({ error: 'Invalid payload' }, { status: 400 });
}
db.transaction((tx) => {
for (const { id, sortOrder } of orders) {
tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run();
}
});
return json({ ok: true });
};
+6 -3
View File
@@ -4,8 +4,10 @@ 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';
export const GET: RequestHandler = async ({ url }) => {
export const GET: RequestHandler = async ({ url, ...event }) => {
const userId = getUserId(event);
const query = url.searchParams.get('q')?.trim();
if (!query) {
@@ -19,6 +21,7 @@ export const GET: RequestHandler = async ({ url }) => {
.from(notes)
.where(
and(
eq(notes.userId, userId),
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
@@ -30,7 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(like(tags.name, pattern))
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
.all();
const resultIds = new Set(results.map((n) => n.id));
@@ -43,7 +46,7 @@ export const GET: RequestHandler = async ({ url }) => {
extraNotes = db
.select()
.from(notes)
.where(and(inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.all();
}
+20 -5
View File
@@ -1,20 +1,35 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
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';
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { changes } = await request.json();
if (!Array.isArray(changes)) {
return json({ error: 'Invalid changes' }, { status: 400 });
}
await processSyncPush(changes);
await processSyncPush(changes, userId);
return json({ success: true });
};
export const GET: RequestHandler = async ({ url }) => {
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);
return json(changes);
const changes = await getChangesSince(since, userId);
const noteIds = changes.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const changesWithTags = changes.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
return json(changesWithTags);
};
+5 -2
View File
@@ -2,8 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { tags } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { getUserId } from '$lib/server/api-utils.js';
export const GET: RequestHandler = async () => {
const allTags = await db.select().from(tags);
export const GET: RequestHandler = async (event) => {
const userId = getUserId(event);
const allTags = await db.select().from(tags).where(eq(tags.userId, userId));
return json(allTags);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+166 -5
View File
@@ -1,11 +1,172 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
/** Helper: create a note, close editor, reopen it */
async function createAndReopenNote(page: import('@playwright/test').Page, title: string) {
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill(title);
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
await editor.click();
await editor.pressSequentially('Test content');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, title)).toBeVisible();
// Reopen for attachment work (note must be saved first)
await noteCard(page, title).click();
await expect(page.getByTestId('note-editor')).toBeVisible();
}
test.describe('Image Attachments', () => {
test('Scenario: Note editor is available for creating a new note', async ({ authenticatedPage: page }) => {
// When the user starts creating a new note
await page.getByTestId('new-note-btn').click();
test('Scenario: Uploaded image appears as thumbnail in the editor', async ({ authenticatedPage: page }) => {
// Given a saved note exists
await createAndReopenNote(page, 'Attachment Test');
// Then the note editor is displayed
// When the user opens the image panel and uploads an image
await page.getByTestId('image-toggle').click();
const fileInput = page.getByTestId('file-input');
await fileInput.setInputFiles(TEST_IMAGE_PATH);
// Then a thumbnail appears in the editor
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
});
test('Scenario: Image persists after closing and reopening a note', async ({ authenticatedPage: page }) => {
// Given a note with an uploaded image exists
await createAndReopenNote(page, 'Persist Test');
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// When the user closes and reopens the note
await page.getByTestId('close-editor-btn').click();
await noteCard(page, 'Persist Test').click();
// Then the image is still visible in the editor
await expect(page.getByTestId('note-editor').locator('img')).toBeVisible();
});
test('Scenario: Uploaded images are hidden from card header by default', async ({ authenticatedPage: page }) => {
// Given a note with an uploaded image exists
await createAndReopenNote(page, 'Hidden Default Test');
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// When the user closes the editor without featuring the image
await page.getByTestId('close-editor-btn').click();
// Then the card does NOT show a thumbnail strip
const card = noteCard(page, 'Hidden Default Test');
await expect(card).toBeVisible();
await expect(card.getByTestId('card-thumbnails')).not.toBeVisible();
});
test('Scenario: Featuring an image makes it appear on the card header', async ({ authenticatedPage: page }) => {
// Given a note with an uploaded image exists
await createAndReopenNote(page, 'Feature Test');
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// When the user features the image by clicking the star
await page.getByTestId('attachment-thumbnail').hover();
await page.getByTestId('toggle-featured').click();
// And closes the editor
await page.getByTestId('close-editor-btn').click();
// Then the card shows the featured image in its header
const card = noteCard(page, 'Feature Test');
await expect(card.getByTestId('card-thumbnails')).toBeVisible();
await expect(card.getByTestId('card-thumbnail')).toBeVisible();
});
test('Scenario: Unfeaturing an image removes it from the card header', async ({ authenticatedPage: page }) => {
// Given a note with a featured image exists
await createAndReopenNote(page, 'Unfeature Test');
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// Feature the image
await page.getByTestId('attachment-thumbnail').hover();
await page.getByTestId('toggle-featured').click();
await page.getByTestId('close-editor-btn').click();
const card = noteCard(page, 'Unfeature Test');
await expect(card.getByTestId('card-thumbnails')).toBeVisible();
// When the user unfeatures the image
await card.getByText('Unfeature Test').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
await page.getByTestId('image-toggle').click();
await page.getByTestId('attachment-thumbnail').hover();
await page.getByTestId('toggle-featured').click();
await page.getByTestId('close-editor-btn').click();
// Then the card no longer shows the thumbnail strip
await expect(card.getByTestId('card-thumbnails')).not.toBeVisible();
});
test('Scenario: Removed image disappears from editor and card', async ({ authenticatedPage: page }) => {
// Given a note with an uploaded image exists
await createAndReopenNote(page, 'Remove Test');
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// When the user removes the image
await page.getByTestId('attachment-thumbnail').hover();
await page.getByTestId('remove-attachment').click();
// Then the thumbnail is gone from the editor
await expect(page.getByTestId('attachment-thumbnail')).not.toBeVisible();
// And after closing, no thumbnail strip on the card
await page.getByTestId('close-editor-btn').click();
const card = noteCard(page, 'Remove Test');
await expect(card.getByTestId('card-thumbnail')).not.toBeVisible();
});
test('Scenario: Clicking an image thumbnail opens a full-size lightbox', async ({ authenticatedPage: page }) => {
// Given a note with an uploaded image exists
await createAndReopenNote(page, 'Lightbox Test');
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail').first()).toBeVisible();
// When the user clicks the thumbnail
await page.getByTestId('attachment-thumbnail').first().click();
// Then a full-size lightbox overlay is displayed
await expect(page.getByTestId('image-lightbox')).toBeVisible();
await expect(page.getByTestId('lightbox-image')).toBeVisible();
// When the user presses Escape
await page.keyboard.press('Escape');
// Then the lightbox closes but the editor remains open
await expect(page.getByTestId('image-lightbox')).not.toBeVisible();
await expect(page.getByTestId('note-editor')).toBeVisible();
});
test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => {
// When the user opens a new note and clicks the image toggle
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-Save Test');
await page.getByTestId('image-toggle').click();
// Then the upload panel is visible (note was auto-saved)
await expect(page.getByTestId('image-upload')).toBeVisible();
// Cleanup
await page.getByTestId('close-editor-btn').click();
// And the note exists in the list
await expect(noteCard(page, 'Auto-Save Test')).toBeVisible();
});
});
+26 -30
View File
@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
test.describe.serial('Authentication', () => {
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
@@ -15,7 +15,8 @@ test.describe.serial('Authentication', () => {
// When the user visits the setup page
await page.goto('/setup');
// Then the password and confirmation fields and setup action are available
// Then the email, password, and confirmation fields and setup action are available
await expect(page.getByTestId('email-input')).toBeVisible();
await expect(page.getByTestId('password-input')).toBeVisible();
await expect(page.getByTestId('confirm-password-input')).toBeVisible();
await expect(page.getByTestId('setup-btn')).toBeVisible();
@@ -26,6 +27,7 @@ test.describe.serial('Authentication', () => {
await page.goto('/setup');
// When they submit a password shorter than 8 characters
await page.getByTestId('email-input').fill('test@test.com');
await page.getByTestId('password-input').fill('short');
await page.getByTestId('confirm-password-input').fill('short');
await page.getByTestId('setup-btn').click();
@@ -39,6 +41,7 @@ test.describe.serial('Authentication', () => {
await page.goto('/setup');
// When they submit two different passwords
await page.getByTestId('email-input').fill('test@test.com');
await page.getByTestId('password-input').fill('longpassword1');
await page.getByTestId('confirm-password-input').fill('longpassword2');
await page.getByTestId('setup-btn').click();
@@ -48,39 +51,14 @@ test.describe.serial('Authentication', () => {
});
test('Scenario: User creates an account and gains access to the application', async ({ page }) => {
// Given the user accesses the application
await page.goto('/');
const url = page.url();
// When they authenticate with valid credentials
if (url.includes('/setup')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('confirm-password-input').fill('testpassword123');
await page.getByTestId('setup-btn').click();
} else if (url.includes('/login')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('login-btn').click();
}
// Then they have access to the main application
await page.waitForURL('/');
// Use the shared setupAndLogin helper which handles race conditions
await setupAndLogin(page);
await expect(page.getByTestId('new-note-btn')).toBeVisible();
});
test('Scenario: Logged-out user loses access to the application', async ({ page }) => {
// Given the user is authenticated
await page.goto('/');
const url = page.url();
if (url.includes('/setup')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('confirm-password-input').fill('testpassword123');
await page.getByTestId('setup-btn').click();
await page.waitForURL('/');
} else if (url.includes('/login')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
}
await setupAndLogin(page);
// When they log out
await page.evaluate(() => fetch('/api/auth/logout', { method: 'POST' }));
@@ -90,3 +68,21 @@ test.describe.serial('Authentication', () => {
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('Multi-user', () => {
test('admin can create a new user via API', async ({ authenticatedPage: page }) => {
// authenticatedPage fixture already handles setup/login
// Create a new user via admin API
const response = await page.request.post('/api/admin/users', {
data: {
email: 'user2@test.com',
displayName: 'User Two',
password: 'userpassword123',
role: 'user'
}
});
expect(response.status()).toBe(201);
});
});
+29 -3
View File
@@ -44,12 +44,15 @@ test.describe('Checklist', () => {
// When the user reopens the note and checks the item
await noteCard(page, 'Tasks').click();
await page.getByTestId('checklist-checkbox').first().check();
await page.getByTestId('checklist-checkbox').first().click();
// Then the item moves to the done section
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
await page.getByTestId('close-editor-btn').click();
// Then reopening the note shows the item is still checked
// And reopening the note shows the item in the done section (expanded by default)
await noteCard(page, 'Tasks').click();
await expect(page.getByTestId('checklist-checkbox').first()).toBeChecked();
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
});
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => {
@@ -80,6 +83,29 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
});
test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => {
// Given a checklist note with a completed item and an uncompleted item
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Hide Done Test');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Done task');
await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
await page.getByTestId('checklist-checkbox').first().click();
// Then the completed item is separated from the active list
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
// And the "done" toggle shows the count
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
// Then the completed items are shown in the done section (expanded by default)
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
});
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
// Given the user has checklist mode enabled
await page.getByTestId('new-note-btn').click();
+9 -3
View File
@@ -1,6 +1,8 @@
import { test as base, expect, type Page, type Locator } from '@playwright/test';
const TEST_EMAIL = 'admin@test.com';
const TEST_PASSWORD = 'testpassword123';
const TEST_DISPLAY_NAME = 'Test Admin';
/** Find a specific note card by its title text */
export function noteCard(page: Page, title: string): Locator {
@@ -23,7 +25,9 @@ export async function setupAndLogin(page: Page) {
const url = page.url();
if (url.includes('/setup')) {
// First-time setup - may race with other workers
// First-time setup with email
await page.getByTestId('email-input').fill(TEST_EMAIL);
await page.getByTestId('display-name-input').fill(TEST_DISPLAY_NAME);
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('confirm-password-input').fill(TEST_PASSWORD);
await page.getByTestId('setup-btn').click();
@@ -34,12 +38,14 @@ export async function setupAndLogin(page: Page) {
} catch {
// Setup was completed by another worker - go to login instead
await page.goto('/login');
await page.getByTestId('email-input').fill(TEST_EMAIL);
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
}
} else if (url.includes('/login')) {
// Login
// Login with email
await page.getByTestId('email-input').fill(TEST_EMAIL);
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
@@ -51,4 +57,4 @@ export async function setupAndLogin(page: Page) {
await page.waitForLoadState('networkidle');
}
export { expect };
export { expect, TEST_EMAIL, TEST_PASSWORD };
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

+1 -1
View File
@@ -42,7 +42,7 @@ test.describe('Notes CRUD', () => {
// When the user trashes the note
const card = noteCard(page, 'Delete Me');
await card.hover();
await card.getByTestId('trash-btn').click();
await card.getByTestId('trash-btn').click({ force: true });
// Then the note is no longer visible
await expect(page.getByText('Delete Me')).not.toBeVisible();
+64 -3
View File
@@ -10,7 +10,7 @@ test.describe('Organization Features', () => {
// When the user pins the note
const pinCard = noteCard(page, 'Pin Me');
await pinCard.hover();
await pinCard.getByTestId('pin-btn').click();
await pinCard.getByTestId('pin-btn').first().click({ force: true });
// Then the "Pinned" section is visible
await expect(page.getByText('Pinned')).toBeVisible();
@@ -25,7 +25,7 @@ test.describe('Organization Features', () => {
// When the user archives the note
const archiveCard = noteCard(page, 'Archive Me');
await archiveCard.hover();
await archiveCard.getByTestId('archive-btn').click();
await archiveCard.getByTestId('archive-btn').click({ force: true });
// Then the note is no longer visible in the main view
await expect(page.getByText('Archive Me')).not.toBeVisible();
@@ -64,7 +64,68 @@ test.describe('Organization Features', () => {
await tagChip.click();
// Then only the tagged note is visible
await expect(page.getByText('Tagged Note')).toBeVisible();
await expect(page.getByText('Tagged Note').first()).toBeVisible();
}
});
});
test.describe('Note Sorting', () => {
test('Scenario: Notes can be sorted by last updated', 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');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Sort-First')).toBeVisible();
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Sort-Second');
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');
const firstBox = await sortSecond.boundingBox();
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\)\]/);
});
});

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