Compare commits

...
83 Commits
Author SHA1 Message Date
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
91 changed files with 4177 additions and 345 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
+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
+3
View File
@@ -37,3 +37,6 @@ vite.config.ts.timestamp-*
# Local notes
todo.md
# Worktrees
.worktrees/
+6
View File
@@ -124,6 +124,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:
-1
View File
@@ -41,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
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.3.3
image: ghcr.io/bretzel-app/crumbs:0.7.2
container_name: crumbs
ports:
- "3000:3000"
+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
+11 -12
View File
@@ -42,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
@@ -56,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
@@ -80,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)
@@ -97,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)
+18
View File
@@ -185,6 +185,12 @@ paths:
in: query
schema:
type: string
- name: thumb
in: query
description: Set to "1" to return the thumbnail instead of the full image (requires attachmentId)
schema:
type: string
enum: ['1']
responses:
'200':
description: Attachment list (without attachmentId) or file content (with attachmentId)
@@ -209,6 +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
@@ -368,6 +378,10 @@ components:
type: array
items:
type: string
attachments:
type: array
items:
$ref: '#/components/schemas/Attachment'
NoteInput:
type: object
@@ -415,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
}
]
}
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.4.0",
"version": "0.8.0",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
@@ -39,14 +39,17 @@
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"arctic": "^3.7.0",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.1.1",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
@@ -57,6 +60,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/uuid": "^10.0.0",
"@types/zxcvbn": "^4.4.5",
"@vite-pwa/sveltekit": "^1.1.0",
"drizzle-kit": "^0.31.9",
"svelte": "^5.53.7",
+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'
}
}
});
+77
View File
@@ -43,6 +43,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^3.20.1
version: 3.20.1
arctic:
specifier: ^3.7.0
version: 3.7.0
argon2:
specifier: ^0.43.1
version: 0.43.1
@@ -61,12 +64,18 @@ importers:
markdown-it:
specifier: ^14.1.1
version: 14.1.1
svelte-dnd-action:
specifier: ^0.9.69
version: 0.9.69(svelte@5.53.7)
tiptap-markdown:
specifier: ^0.9.0
version: 0.9.0(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
uuid:
specifier: ^11.1.0
version: 11.1.0
zxcvbn:
specifier: ^4.4.2
version: 4.4.2
devDependencies:
'@playwright/test':
specifier: ^1.58.2
@@ -92,6 +101,9 @@ importers:
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@types/zxcvbn':
specifier: ^4.4.5
version: 4.4.5
'@vite-pwa/sveltekit':
specifier: ^1.1.0
version: 1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
@@ -972,6 +984,24 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
'@oslojs/binary@1.0.0':
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
'@oslojs/crypto@1.0.1':
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
'@oslojs/encoding@0.4.1':
resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==}
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@oslojs/jwt@0.2.0':
resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
@@ -1541,6 +1571,9 @@ packages:
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/zxcvbn@4.4.5':
resolution: {integrity: sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==}
'@vite-pwa/sveltekit@1.1.0':
resolution: {integrity: sha512-mMIf2tY+7Hg8jecpu/WY+Ki2ikoXy3hVmt3tOxi0K+lYYnKQrDYthuHireI0S+26Mg9BXzL7qQF1xeB5VYlYlg==}
engines: {node: '>=18.13'}
@@ -1588,6 +1621,9 @@ packages:
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
arctic@3.7.0:
resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==}
argon2@0.43.1:
resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==}
engines: {node: '>=16.17.0'}
@@ -2900,6 +2936,11 @@ packages:
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: '>=5.0.0'
svelte-dnd-action@0.9.69:
resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==}
peerDependencies:
svelte: '>=3.23.0 || ^5.0.0-next.0'
svelte@5.53.7:
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==}
engines: {node: '>=18'}
@@ -3240,6 +3281,9 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zxcvbn@4.4.2:
resolution: {integrity: sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==}
snapshots:
'@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)':
@@ -4097,6 +4141,25 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@oslojs/asn1@1.0.0':
dependencies:
'@oslojs/binary': 1.0.0
'@oslojs/binary@1.0.0': {}
'@oslojs/crypto@1.0.1':
dependencies:
'@oslojs/asn1': 1.0.0
'@oslojs/binary': 1.0.0
'@oslojs/encoding@0.4.1': {}
'@oslojs/encoding@1.1.0': {}
'@oslojs/jwt@0.2.0':
dependencies:
'@oslojs/encoding': 0.4.1
'@phc/format@1.0.0': {}
'@playwright/test@1.58.2':
@@ -4604,6 +4667,8 @@ snapshots:
'@types/uuid@10.0.0': {}
'@types/zxcvbn@4.4.5': {}
'@vite-pwa/sveltekit@1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)':
dependencies:
'@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@@ -4667,6 +4732,12 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
arctic@3.7.0:
dependencies:
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
'@oslojs/jwt': 0.2.0
argon2@0.43.1:
dependencies:
'@phc/format': 1.0.0
@@ -6037,6 +6108,10 @@ snapshots:
transitivePeerDependencies:
- picomatch
svelte-dnd-action@0.9.69(svelte@5.53.7):
dependencies:
svelte: 5.53.7
svelte@5.53.7:
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -6482,3 +6557,5 @@ snapshots:
yaml@2.8.2: {}
zimmerframe@1.1.4: {}
zxcvbn@4.4.2: {}
+9 -1
View File
@@ -60,7 +60,15 @@ echo "Bumping $BUMP version..."
npm version "$BUMP" -m "chore: bump version to %s" --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
git add package.json
# Update version in marketing website
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
else
sed -i "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
fi
git add package.json website/index.html
git commit -m "chore: bump version to $NEW_VERSION"
git tag "v$NEW_VERSION"
+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>
+90 -8
View File
@@ -1,28 +1,82 @@
<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;
}
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured }: 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 +86,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 +115,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 {attachment.featured ? 'block' : 'hidden group-hover:block'}"
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 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
@@ -87,7 +163,9 @@
tabindex="0"
data-testid="upload-dropzone"
>
{#if uploading}
{#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">
@@ -104,3 +182,7 @@
{/if}
</div>
</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>
+139 -10
View File
@@ -3,12 +3,15 @@
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import ImageLightbox from './ImageLightbox.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,10 +33,68 @@
// 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);
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
// 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(() => {
@@ -41,23 +102,48 @@
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 +163,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 +210,38 @@
{/if}
</div>
<!-- Image attachments -->
{#if noteId && showImageUpload}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<ImageUpload
noteId={noteId}
attachments={attachmentsList}
onUpload={handleAttachmentUpload}
onRemove={handleAttachmentRemove}
onToggleFeatured={handleToggleFeatured}
/>
</div>
{:else if noteId && attachmentsList.length > 0}
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<div class="flex flex-wrap gap-2">
{#each attachmentsList as attachment}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<img
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
alt={attachment.filename}
class="h-20 w-20 cursor-pointer rounded-sm object-cover"
onclick={() => { lightboxSrc = `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="attachment-thumbnail"
/>
{/each}
</div>
</div>
{/if}
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
@@ -163,11 +281,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 +308,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 right-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
data-testid="note-drag-handle"
>
<GripVertical class="h-4 w-4" />
</div>
<NoteCard {note} {onEdit} fullHeight />
</div>
{/each}
</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-red-500',
'bg-orange-500',
'bg-yellow-500',
'bg-lime-500',
'bg-green-500'
];
</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-full transition-colors {i <= result.score
? colors[result.score]
: 'bg-gray-200 dark:bg-gray-600'}"
></div>
{/each}
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{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>
+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}
+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 -4
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,6 +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
minlength="8"
/>
<input
@@ -75,6 +102,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
minlength="8"
/>
<button
@@ -83,7 +111,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();
}
+13 -6
View File
@@ -2,26 +2,33 @@ 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);
const changes = await getChangesSince(since, userId);
const tagMap = fetchTagsForNotes(changes.map((n) => n.id));
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) ?? []
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);
};
+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 - KeepNotes</title>
</svelte:head>
<div class="mx-auto max-w-2xl py-8">
<h1 class="mb-8 text-2xl font-bold text-gray-800 dark:text-gray-100">Settings</h1>
<!-- Profile -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Profile</h2>
{#if profileMsg}
<div class="mb-4 rounded p-3 text-sm {profileError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{profileMsg}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="display-name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</label>
<input
id="display-name"
type="text"
bind:value={displayName}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input
id="email"
type="email"
bind:value={email}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<button
onclick={saveProfile}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Save profile
</button>
</div>
</section>
<!-- Change Password -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Change Password</h2>
{#if passwordMsg}
<div class="mb-4 rounded p-3 text-sm {passwordError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{passwordMsg}
</div>
{/if}
<div class="space-y-4">
<input
type="password"
bind:value={currentPassword}
placeholder="Current password"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="password"
bind:value={newPassword}
placeholder="New password (min 8 characters)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<PasswordStrengthMeter password={newPassword} />
<input
type="password"
bind:value={confirmNewPassword}
placeholder="Confirm new password"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<button
onclick={changePassword}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Change password
</button>
</div>
</section>
<!-- Active Sessions -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Active Sessions</h2>
{#if activeSessions.length > 1}
<button
onclick={revokeAllOtherSessions}
class="text-xs text-red-600 hover:text-red-700 dark:text-red-400"
>
Log out everywhere else
</button>
{/if}
</div>
{#if !sessionsLoaded}
<p class="text-sm text-gray-500">Loading sessions...</p>
{:else if activeSessions.length === 0}
<p class="text-sm text-gray-500">No active sessions</p>
{:else}
<div class="space-y-3">
{#each activeSessions as session (session.id)}
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-3 dark:border-gray-700">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">
{formatAgent(session.userAgent)}
{#if session.isCurrent}
<span class="ml-2 rounded bg-green-100 px-2 py-0.5 text-xs text-green-800 dark:bg-green-900/30 dark:text-green-200">current</span>
{/if}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
</p>
</div>
{#if !session.isCurrent}
<button
onclick={() => revokeSession(session.id)}
class="rounded px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Revoke
</button>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<!-- Admin: User Management Link -->
{#if data.user?.role === 'admin'}
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Administration</h2>
<a
href="/settings/users"
class="text-sm text-amber-600 hover:text-amber-700 hover:underline dark:text-amber-400"
>
Manage users →
</a>
</section>
{/if}
</div>
+10
View File
@@ -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() };
};
+181
View File
@@ -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 - KeepNotes</title>
</svelte:head>
<div class="mx-auto max-w-2xl py-8">
<div class="mb-8 flex items-center gap-4">
<a href="/settings" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← Settings</a>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">User Management</h1>
</div>
<!-- Create User -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Create User</h2>
{#if createMsg}
<div class="mb-4 rounded p-3 text-sm {createError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{createMsg}
</div>
{/if}
<div class="space-y-3">
<input
type="email"
bind:value={newEmail}
placeholder="Email"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="text"
bind:value={newDisplayName}
placeholder="Display name (optional)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="password"
bind:value={newPassword}
placeholder="Password (min 8 characters)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<PasswordStrengthMeter password={newPassword} />
<select
bind:value={newRole}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onclick={createUser}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Create user
</button>
</div>
</section>
<!-- User List -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Users ({users.length})</h2>
<div class="space-y-3">
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-gray-700">
<div>
<p class="font-medium text-gray-800 dark:text-gray-100">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">admin</span>
{/if}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => toggleRole(user)}
class="rounded px-3 py-1 text-xs text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
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 px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
</section>
</div>
+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\)\]/);
});
});
+8
View File
@@ -27,6 +27,14 @@ export default defineConfig({
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
navigateFallback: '/',
runtimeCaching: [
{
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
handler: 'CacheFirst',
options: {
cacheName: 'attachment-cache',
expiration: { maxEntries: 200, maxAgeSeconds: 365 * 24 * 60 * 60 }
}
},
{
urlPattern: /^https?:\/\/.*\/api\//,
handler: 'NetworkFirst',
+1
View File
@@ -6,6 +6,7 @@
<title>404 - Page not found | Crumbs</title>
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
<link rel="stylesheet" href="styles.css">
<style>
.error-page {
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+53 -11
View File
@@ -13,6 +13,7 @@
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
<link rel="stylesheet" href="styles.css">
</head>
<body>
@@ -70,7 +71,7 @@
</a>
</div>
<div class="hero-screenshot animate-in delay-3">
<div class="screenshot-placeholder"></div>
<img src="assets/screenshot-grid.png" alt="Crumbs notes grid view showing colorful note cards with checklists, tags, and rich text" width="1280" height="800" loading="eager">
</div>
</div>
</section>
@@ -87,7 +88,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</div>
<h3>Rich Editor</h3>
<p>Tiptap-powered WYSIWYG with full Markdown support. Bold, lists, code blocks, and more.</p>
<p>Tiptap-powered WYSIWYG with full Markdown support. Bold, lists, code blocks, checklists, and more.</p>
</div>
<!-- Offline-first PWA -->
@@ -117,13 +118,13 @@
<p>Organize with tags, 12 note colors, and pinned notes. Your notes, your way.</p>
</div>
<!-- Checklists -->
<!-- Image Attachments -->
<div class="feature-card animate-in delay-5">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
</div>
<h3>Checklists</h3>
<p>Built-in task lists with checkbox support. Track to-dos right inside your notes.</p>
<h3>Image Attachments</h3>
<p>Drag-and-drop images with auto-optimization. Works offline.</p>
</div>
<!-- Full-text search -->
@@ -142,18 +143,34 @@
<section class="screenshots" id="screenshots">
<div class="container">
<h2 class="animate-in">See it in action</h2>
<h3 class="screenshots-subtitle animate-in">Desktop</h3>
<div class="screenshots-grid">
<div class="screenshot-card animate-in delay-1">
<div class="screenshot-placeholder"></div>
<img src="assets/screenshot-grid.png" alt="Notes grid view with colorful cards, checklists, and tags" width="1280" height="800" loading="lazy">
<div class="screenshot-label">Notes grid view</div>
</div>
<div class="screenshot-card animate-in delay-2">
<div class="screenshot-placeholder"></div>
<img src="assets/screenshot-editor.png" alt="Rich text editor with formatting toolbar" width="1280" height="800" loading="lazy">
<div class="screenshot-label">Rich text editor</div>
</div>
<div class="screenshot-card animate-in delay-3">
<div class="screenshot-placeholder"></div>
<div class="screenshot-label">Mobile view</div>
<img src="assets/screenshot-tags.png" alt="Tag filtering showing bretzel-tagged notes" width="1280" height="800" loading="lazy">
<div class="screenshot-label">Tag filtering</div>
</div>
</div>
<h3 class="screenshots-subtitle animate-in">Mobile</h3>
<div class="screenshots-grid screenshots-grid-mobile">
<div class="screenshot-card screenshot-card-mobile animate-in delay-1">
<img src="assets/screenshot-mobile-grid.png" alt="Mobile grid view with notes and tags" width="390" height="844" loading="lazy">
<div class="screenshot-label">Notes view</div>
</div>
<div class="screenshot-card screenshot-card-mobile animate-in delay-2">
<img src="assets/screenshot-mobile-editor.png" alt="Mobile checklist editor" width="390" height="844" loading="lazy">
<div class="screenshot-label">Checklist editor</div>
</div>
<div class="screenshot-card screenshot-card-mobile animate-in delay-3">
<img src="assets/screenshot-mobile-sidebar.png" alt="Mobile sidebar with tags" width="390" height="844" loading="lazy">
<div class="screenshot-label">Sidebar & tags</div>
</div>
</div>
</div>
@@ -205,11 +222,36 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.3.3</span>
<span class="footer-version">v0.8.0</span>
</div>
</footer>
<!-- Lightbox -->
<div class="lightbox" id="lightbox" role="dialog" aria-label="Image preview">
<img src="" alt="">
</div>
<script>
// Lightbox
const lightbox = document.getElementById('lightbox');
const lightboxImg = lightbox.querySelector('img');
document.querySelectorAll('.screenshot-card img, .hero-screenshot img').forEach(img => {
img.addEventListener('click', () => {
lightboxImg.src = img.src;
lightboxImg.alt = img.alt;
lightbox.classList.add('active');
});
});
lightbox.addEventListener('click', () => {
lightbox.classList.remove('active');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') lightbox.classList.remove('active');
});
// Mobile nav toggle
const toggle = document.querySelector('.nav-toggle');
const navLinks = document.querySelector('.nav-links');
+72 -2
View File
@@ -126,7 +126,7 @@ section {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text);
color: var(--primary);
font-family: 'Press Start 2P', cursive;
font-size: 1rem;
}
@@ -404,13 +404,35 @@ section {
.screenshots h2 {
text-align: center;
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
margin-bottom: 3rem;
margin-bottom: 2rem;
}
.screenshots-subtitle {
text-align: center;
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 1.5rem;
}
.screenshots-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.screenshots-grid-mobile {
grid-template-columns: repeat(3, 1fr);
max-width: 48rem;
margin-left: auto;
margin-right: auto;
}
.screenshot-card-mobile {
max-width: 14rem;
justify-self: center;
}
.screenshot-card {
@@ -569,6 +591,46 @@ section {
padding: 0.2rem 0.6rem;
}
/* ── Lightbox ── */
.lightbox {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(26, 26, 46, 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
cursor: zoom-out;
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease-out, visibility 150ms ease-out;
}
.lightbox.active {
opacity: 1;
visibility: visible;
}
.lightbox img {
max-width: 100%;
max-height: 90vh;
border: 2px solid var(--border-subtle);
border-radius: 2px;
box-shadow: 4px 4px 0px rgba(0, 0, 0, 0.3);
transform: scale(0.95);
transition: transform 150ms ease-out;
}
.lightbox.active img {
transform: scale(1);
}
.screenshot-card img,
.hero-screenshot img {
cursor: zoom-in;
}
/* ── Animations ── */
@keyframes fade-up {
from {
@@ -634,6 +696,14 @@ section {
.screenshots-grid {
grid-template-columns: 1fr;
}
.screenshots-grid-mobile {
grid-template-columns: repeat(3, 1fr);
}
.screenshot-card-mobile {
max-width: none;
}
}
@media (max-width: 480px) {