Compare commits

...
86 Commits
Author SHA1 Message Date
github-actions[bot] 9c0ea59431 chore: bump version to 0.6.2 2026-03-08 18:16:27 +00:00
Frederic CabassutandClaude Opus 4.6 8ea46bd745 ci: always upload Playwright test results as artifacts
Upload both playwright-report/ and test-results/ (screenshots, traces)
on every run, not just failures.

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

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

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

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

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

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

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

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

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

Closes #12

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

Closes #11

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

Closes #9

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

Closes #10

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:40:48 +01:00
88 changed files with 3673 additions and 1203 deletions
+2
View File
@@ -0,0 +1,2 @@
# Default owner for everything in the repo
* @fredicious
+40
View File
@@ -0,0 +1,40 @@
---
name: Bug Report
about: Something isn't working as expected
title: "bug: "
labels: bug
assignees: ""
---
## Description
A clear summary of the bug.
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What should happen.
## Actual Behavior
What happens instead.
## Environment
- **Crumbs version**: (e.g. 0.3.2)
- **Browser**: (e.g. Chrome 120, Firefox 121)
- **OS**: (e.g. macOS 15, Ubuntu 24.04)
- **Deployment**: (Docker / Node.js / dev server)
## Screenshots
If applicable, add screenshots to help explain the problem.
## Additional Context
Any other relevant information (logs, related issues, etc.).
+27
View File
@@ -0,0 +1,27 @@
---
name: Feature Request
about: Suggest an idea or improvement
title: "feat: "
labels: enhancement
assignees: ""
---
## Description
A clear description of the feature you'd like to see.
## Motivation
Why is this feature important? What problem does it solve?
## Proposed Solution
How do you think this should work?
## Alternatives Considered
Have you considered other approaches? If so, why were they ruled out?
## Additional Context
Any mockups, links, or references that help illustrate the request.
+18
View File
@@ -0,0 +1,18 @@
## Summary
What does this PR do and why?
## Test Plan
- [ ] Unit tests added/updated
- [ ] E2E tests added/updated (if user-facing)
- [ ] `make check` passes
- [ ] `make test` passes
## Related Issues
Closes #
## Screenshots
If this changes the UI, include before/after screenshots.
+122
View File
@@ -0,0 +1,122 @@
name: Bump & Release
on:
workflow_dispatch:
inputs:
bump:
description: 'Override bump type (leave empty to auto-detect)'
required: false
type: choice
options:
- ''
- patch
- minor
- major
jobs:
bump:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Run release script
env:
BUMP_TYPE: ${{ github.event.inputs.bump }}
run: bash scripts/release.sh "$BUMP_TYPE"
- name: Output new tag
id: release-tag
run: echo "tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
ci:
needs: bump
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [bump, ci]
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump.outputs.tag }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
type=raw,value=latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.bump.outputs.tag }}
generate_release_notes: true
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: [bump, release]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update docker-compose.yml
env:
TAG: ${{ needs.bump.outputs.tag }}
run: |
VERSION="${TAG#v}"
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docker-compose.yml
git diff --cached --quiet && exit 0
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
git push origin main
+5 -5
View File
@@ -51,7 +51,6 @@ jobs:
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
@@ -86,16 +85,17 @@ jobs:
CI: true
- uses: actions/upload-artifact@v4
if: failure()
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
path: |
playwright-report/
test-results/
retention-days: 30
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
+44
View File
@@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+50
View File
@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+21 -4
View File
@@ -55,10 +55,27 @@ jobs:
with:
generate_release_notes: true
deploy:
name: Deploy
pin-docker-compose:
name: Pin docker-compose to released version
runs-on: ubuntu-latest
needs: release
permissions:
contents: write
steps:
- name: Trigger Dokploy redeploy
run: curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
- uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update docker-compose.yml
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docker-compose.yml
git diff --cached --quiet && exit 0
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
git push origin main
+36
View File
@@ -0,0 +1,36 @@
name: Deploy Website
on:
push:
branches: [main]
paths: ['website/**']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: website
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+5 -2
View File
@@ -32,5 +32,8 @@ vite.config.ts.timestamp-*
/test-results
/playwright-report
# Skills CLI (actual files are in .agents/skills/)
/.claude/skills
# Claude Code
/.claude/
# Local notes
todo.md
+42
View File
@@ -0,0 +1,42 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.2] - 2026-03-07
### Fixed
- Update docker-compose only after Docker image is pushed
## [0.3.1] - 2026-03-07
### Fixed
- Include tags in sync pull response and update API spec
### Changed
- Update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md documentation
## [0.3.0] - 2026-03-06
### Added
- Tag management system with auto-extraction from note content
- Full-text search across titles, content, and tags
- Dark mode with system preference detection
- Note pinning, archive, and trash workflows
- Image attachments with drag-and-drop upload
- 12 color themes for note cards
- PWA support with offline-first IndexedDB sync
- LWW CRDT-based conflict resolution
- Docker deployment with multi-stage build
- CI/CD pipeline with GitHub Actions
- Comprehensive E2E test suite with Playwright
- OpenAPI spec and auto-generated API docs
### Changed
- Initial public release
[0.3.2]: https://github.com/bretzel-app/crumbs/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/bretzel-app/crumbs/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/bretzel-app/crumbs/releases/tag/v0.3.0
+78 -7
View File
@@ -14,21 +14,73 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
- **Package manager**: pnpm
## Design system: Retro Parchment
The UI follows a **parchment + 8-bit retro** aesthetic. All design decisions must preserve this character.
### Color palette
All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colors (e.g. `text-gray-500`, `bg-red-600`) — always reference CSS variables:
| Variable | Value | Usage |
|---|---|---|
| `--bg-base` | `#f0e6d3` | Page background (warm parchment) |
| `--bg-surface` | `#faf5eb` | Cards, panels, inputs |
| `--primary` | `#C8860A` | Brand gold — links, active states, accents |
| `--primary-hover` | `#E8A020` | Gold hover state |
| `--text` | `#1a1a2e` | Primary text (near-black) |
| `--text-muted` | `#6b6272` | Secondary text, placeholders |
| `--border` | `#1a1a2e` | Strong borders (editor, login card) |
| `--border-subtle` | `#d4cabb` | Default card borders, dividers |
| `--destructive` | `#a63d2f` | Delete actions, error icons |
| `--error-bg/border/text` | warm reds | Error states (themed, not clinical red) |
| `--success-bg/text` | warm greens | Success states |
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
Note colors for cards are defined in `src/lib/utils/colors.ts`.
### Typography
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
- **Brand / empty states**: `Press Start 2P` (pixel font) — the 8-bit accent
- These two fonts are intentional. Do not replace them or add others.
### Visual rules
- **Shadows**: Always hard-offset (`2px 2px 0px`) with zero blur. Never use `shadow-sm`, `shadow-md` etc. — they look too modern.
- **Corners**: `rounded-sm` everywhere. Sharp, not rounded. Exception: color picker circles use `rounded-full`.
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
### Layout groups
- `(auth)` — Login/setup pages: minimal centered layout, no app chrome
- `(app)` — Main app: header, sidebar, footer, toast notifications
## Project structure
```
src/
routes/ # SvelteKit pages and API routes
+page.svelte # Main notes view
login/ # Login page
setup/ # First-time password setup
routes/
+layout.svelte # Root layout (imports CSS only)
(auth)/ # Layout group: login/setup (minimal, no app chrome)
+layout.svelte # Centered parchment background
login/ # Login page
setup/ # First-time password setup
(app)/ # Layout group: main app (header, sidebar, footer)
+layout.svelte # App shell with Header, Sidebar, Toast
+page.svelte # Main notes view
api/auth/ # Login, logout, setup endpoints
api/notes/ # CRUD + attachments
api/search/ # Full-text search
api/sync/ # Offline sync
api/tags/ # Tag management
lib/
components/ # Svelte components (NoteCard, NoteEditor, ColorPicker, etc.)
components/ # Svelte components (NoteCard, NoteEditor, TagChip, etc.)
stores/ # Svelte stores (notes.ts, theme.ts)
server/db/ # Drizzle schema + connection (schema.ts, index.ts)
server/ # Auth logic, attachment handling
@@ -67,6 +119,10 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
| `make docker-down` | — | Stop containers |
| `make docker-logs` | — | Tail container logs |
| `make clean` | — | Remove build artifacts + test DBs |
| `make release` | — | Auto-bump version from commits, tag, and push |
| `make release-patch` | — | Bump patch version, tag, and push |
| `make release-minor` | — | Bump minor version, tag, and push |
| `make release-major` | — | Bump major version, tag, and push |
## CI/CD
@@ -79,10 +135,14 @@ GitHub Actions with two workflows:
4. **E2E Tests** — Playwright with Chromium (depends on step 3, uploads report on failure)
5. **Docker Build** — validates the Docker image builds (depends on step 3)
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags:
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io` (GitHub Container Registry)
**Bump & Release** (`.github/workflows/bump.yml`) — workflow_dispatch (primary release method):
- Runs `scripts/release.sh` to bump version in `package.json` + `docker-compose.yml`, tag, and push
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io`
- Creates a GitHub Release with auto-generated notes
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags (fallback for manual/local tag pushes):
- Same as above: CI pipeline, Docker build/push, GitHub Release
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
## Testing philosophy: BDD/TDD
@@ -132,3 +192,14 @@ E2E tests follow Cucumber BDD conventions via comments. Follow the [Better Gherk
```
**Resilience test** — ask: *"Will this comment need to change if the UI implementation changes?"* If yes, rewrite it to remove implementation details. The Playwright code underneath handles the *how*; the comments describe the *what*.
## Documentation
Keep docs in `docs/` up to date when making changes. If a feature, API endpoint, deployment config, or architectural decision changes, update the relevant doc file in the same commit or PR:
| Doc | Covers |
|-----|--------|
| `docs/FEATURES.md` | User-facing feature list |
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
+31
View File
@@ -0,0 +1,31 @@
# Code of Conduct
## Our Commitment
Crumbs is a community project, and we want everyone to feel welcome regardless of background, identity, or experience level. We are committed to providing a harassment-free experience for all participants.
## Expected Behavior
- Be respectful and considerate in communication
- Give and accept constructive feedback gracefully
- Focus on what is best for the project and community
- Show empathy towards others
## Unacceptable Behavior
- Harassment, intimidation, or discrimination of any kind
- Personal attacks, insults, or derogatory language
- Publishing others' private information without consent
- Any conduct that would be considered inappropriate in a professional setting
## Scope
This code of conduct applies to all project spaces — issues, pull requests, discussions, and any other communication channel associated with Crumbs.
## Enforcement
Instances of unacceptable behavior may be reported to the maintainers at **security@bretzel.app**. All reports will be reviewed and handled confidentially. Maintainers reserve the right to remove, edit, or reject contributions that violate this code of conduct, and to temporarily or permanently ban contributors for repeated or severe violations.
## Attribution
This code of conduct is inspired by the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1.
+88
View File
@@ -0,0 +1,88 @@
# Contributing to Crumbs
Thanks for your interest in contributing! This guide will help you get started.
## Development Setup
```bash
# Clone the repo
git clone https://github.com/bretzel-app/crumbs.git
cd crumbs
# Install dependencies
pnpm install
# Start the dev server
pnpm dev
```
Open http://localhost:5173 and set your password on first visit.
Run `make help` to see all available commands.
## Project Structure
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system design. Key directories:
- `src/routes/` — SvelteKit pages and API endpoints
- `src/lib/components/` — Svelte 5 components (runes, not stores)
- `src/lib/server/db/` — Drizzle ORM schema and database connection
- `src/lib/sync/` — Offline sync and CRDT logic
- `tests/unit/` — Vitest unit tests
- `tests/e2e/` — Playwright end-to-end tests
## Making Changes
1. **Fork and branch** — Create a feature branch from `main`
2. **Follow existing patterns** — Match the code style you see in the codebase
3. **Write tests** — Add unit tests for logic, E2E tests for user-facing features
4. **Run checks before pushing:**
```bash
make check # Type checking
make lint # Linting
make test-unit # Unit tests
make test-e2e # E2E tests
```
5. **Open a PR** — Target `main`, describe what and why
## Commit Messages
We use conventional-style commit messages:
```
feat: add tag autocomplete
fix: prevent duplicate notes on sync
docs: update deployment guide
chore: bump dependencies
```
Keep the subject line under 70 characters. Use the body for context when needed.
## Code Style
- **Svelte 5 runes** — Use `$state`, `$derived`, `$props`, `$effect` (not Svelte 4 stores)
- **TypeScript** — Strict mode, no `any` unless absolutely necessary
- **Tailwind CSS v4** — Use CSS variables from `src/app.css`, never hardcoded Tailwind colors
- **Design system** — Follow the Retro Parchment aesthetic described in `CLAUDE.md`
## Testing
- **Unit tests**: Pure logic (parsing, auth, sync). Run with `make test-unit`
- **E2E tests**: User workflows in the browser. Run with `make test-e2e`
- E2E tests use Gherkin-style Given/When/Then comments (see `CLAUDE.md` for conventions)
## Reporting Bugs
Use [GitHub Issues](https://github.com/bretzel-app/crumbs/issues) with the bug report template. Include:
- Steps to reproduce
- Expected vs actual behavior
- Browser/OS and Crumbs version
## Security Issues
**Do not open public issues for security vulnerabilities.** See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present Bretzel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -1
View File
@@ -1,6 +1,7 @@
.PHONY: help dev build preview check test test-unit test-e2e lint \
db-push db-generate db-migrate db-studio docs-api \
install docker-build docker-up docker-down docker-logs clean
install docker-build docker-up docker-down docker-logs clean \
release release-patch release-minor release-major
help: ## List all targets
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
@@ -62,3 +63,15 @@ docker-logs: ## Tail container logs
clean: ## Remove build artifacts and test DBs
rm -rf build .svelte-kit node_modules/.vite
rm -f data/test-crumbs.db
release: ## Auto-bump version from commits, tag, and push
bash scripts/release.sh $(BUMP)
release-patch: ## Bump patch version, tag, and push
bash scripts/release.sh patch
release-minor: ## Bump minor version, tag, and push
bash scripts/release.sh minor
release-major: ## Bump major version, tag, and push
bash scripts/release.sh major
+14 -3
View File
@@ -1,4 +1,13 @@
# 🥨 Crumbs by Bretzel
[![CI](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/github/v/release/bretzel-app/crumbs)](https://github.com/bretzel-app/crumbs/releases)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://github.com/bretzel-app/crumbs/pkgs/container/crumbs)
<p align="center">
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
</p>
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
@@ -32,7 +41,6 @@ pnpm build # Production build
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
- Organize with #tags, pinning, archive, and trash
- Full-text search across titles, content, and tags
- Dark mode with system detection
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
- Single-user password auth (Argon2)
- Docker deployment with a single command
@@ -62,6 +70,9 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
| [API](docs/API.md) | REST API reference (auto-generated) |
| [Contributing](CONTRIBUTING.md) | How to contribute |
| [Security](SECURITY.md) | Vulnerability reporting policy |
| [Changelog](CHANGELOG.md) | Release history |
## CI/CD
@@ -72,4 +83,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
## License
MIT
[MIT](LICENSE)
+44
View File
@@ -0,0 +1,44 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | Yes |
| < latest | No |
We recommend always running the latest version of Crumbs.
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via [GitHub's private vulnerability reporting](https://github.com/bretzel-app/crumbs/security/advisories/new).
Include as much of the following as possible:
- Description of the vulnerability
- Steps to reproduce
- Impact assessment
- Suggested fix (if any)
You should receive an initial response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
## Scope
Since Crumbs is a self-hosted application, the following are in scope:
- Authentication bypass or session hijacking
- SQL injection or data leaks
- Cross-site scripting (XSS)
- Server-side request forgery (SSRF)
- Path traversal or file access vulnerabilities
- Denial of service via crafted input
## Disclosure Policy
We follow coordinated disclosure. Once a fix is available, we will:
1. Release a patched version
2. Publish a security advisory on GitHub
3. Credit the reporter (unless anonymity is requested)
+2 -2
View File
@@ -1,6 +1,6 @@
services:
crumbs:
build: .
image: ghcr.io/bretzel-app/crumbs:0.6.1
container_name: crumbs
ports:
- "3000:3000"
@@ -10,7 +10,7 @@ services:
- NODE_ENV=production
- DATA_DIR=/data
- DATABASE_URL=/data/crumbs.db
- ORIGIN=http://localhost:3000
- ORIGIN=${ORIGIN:-http://localhost:3000}
restart: unless-stopped
volumes:
+24
View File
@@ -103,6 +103,7 @@ List notes
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -125,6 +126,7 @@ Create a note
```json
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -147,6 +149,7 @@ Create a note
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -181,6 +184,7 @@ Get a note
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -212,6 +216,7 @@ Partial update — only include changed fields.
```json
{
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -234,6 +239,7 @@ Partial update — only include changed fields.
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -288,6 +294,19 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
**Response:** `200`
```json
[
{
"id": "id",
"noteId": "noteId",
"filename": "filename",
"mimeType": "mimeType",
"size": 1,
"createdAt": "2025-01-01T00:00:00.000Z"
}
]
```
---
### `POST /api/notes/{id}/attachments`
@@ -366,6 +385,7 @@ Searches across note titles, content, and tags. Excludes trashed notes.
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -423,6 +443,7 @@ Pull changes since timestamp
"pinned": true,
"archived": true,
"trashed": true,
"trashedAt": "2025-01-01T00:00:00.000Z",
"checklistMode": true,
"sortOrder": 1,
"createdAt": "2025-01-01T00:00:00.000Z",
@@ -451,6 +472,7 @@ Push local changes
"operation": "create",
"timestamp": 1,
"data": {
"id": "uuid",
"title": "title",
"content": "content",
"color": "default",
@@ -473,4 +495,6 @@ Push local changes
}
```
**Errors:** `400` Invalid changes payload
---
+1 -1
View File
@@ -16,7 +16,7 @@ Open http://localhost:3000 and set your password.
# docker-compose.yml
services:
crumbs:
build: .
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "8080:3000" # Change external port
volumes:
+16 -12
View File
@@ -8,16 +8,27 @@
- Content supports full Markdown syntax
- Auto-save on editor close
### Markdown Support
- **Bold** (`**text**`)
- *Italic* (`*text*`)
### Rich Text Editor
- WYSIWYG editing powered by Tiptap
- Formatting toolbar with visual controls
- Raw Markdown mode toggle for direct editing
- Undo/Redo with keyboard shortcuts
### Text Formatting
- **Bold** (`**text**` / Ctrl+B)
- *Italic* (`*text*` / Ctrl+I)
- Underline (Ctrl+U)
- ~~Strikethrough~~ (`~~text~~` / Ctrl+Shift+X)
- Headings (`# H1` through `###### H6`)
- Blockquotes (`> quote`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Inline code (single backtick)
- Tables
- Links (auto-linkified URLs)
- Links — insert, edit, and remove with URL input
- Dividers (horizontal rules)
- Text alignment (left, center, right, justify)
- Line breaks
### Checklists / Task Lists
@@ -45,7 +56,7 @@
12 colors matching Google Keep's palette:
- Default, Coral, Peach, Sand, Mint, Sage
- Fog, Storm, Dusk, Blossom, Clay, Chalk
- Colors adapt to light/dark mode
- Colors adapt to note theme
### Pin Notes
- Pin important notes to always appear at top
@@ -69,13 +80,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)
+17 -1
View File
@@ -187,7 +187,13 @@ paths:
type: string
responses:
'200':
description: Attachment list or file content
description: Attachment list (without attachmentId) or file content (with attachmentId)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Attachment'
post:
summary: Upload an image attachment
tags: [Attachments]
@@ -306,6 +312,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Success'
'400':
description: Invalid changes payload
components:
securitySchemes:
@@ -340,6 +348,10 @@ components:
type: boolean
trashed:
type: boolean
trashedAt:
type: string
format: date-time
nullable: true
checklistMode:
type: boolean
sortOrder:
@@ -360,6 +372,10 @@ components:
NoteInput:
type: object
properties:
id:
type: string
format: uuid
description: Optional — generated if omitted
title:
type: string
content:
+43 -30
View File
@@ -1,8 +1,15 @@
{
"name": "crumbs",
"private": true,
"version": "0.1.8",
"version": "0.6.2",
"type": "module",
"license": "MIT",
"author": "Bretzel (https://bretzel.app)",
"homepage": "https://crumbs.bretzel.app",
"repository": {
"type": "git",
"url": "https://github.com/bretzel-app/crumbs.git"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -22,43 +29,44 @@
"docs:api": "tsx scripts/generate-api-docs.ts"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"@tiptap/core": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-task-item": "^3.20.0",
"@tiptap/extension-task-list": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"argon2": "^0.43.0",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"idb": "^8.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@tiptap/core": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-task-item": "^3.20.1",
"@tiptap/extension-task-list": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/extension-underline": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"argon2": "^0.43.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.7",
"idb": "^8.0.3",
"lucide-svelte": "^0.577.0",
"markdown-it": "^14.0.0",
"markdown-it": "^14.1.1",
"svelte-dnd-action": "^0.9.69",
"tiptap-markdown": "^0.9.0",
"uuid": "^11.0.0"
"uuid": "^11.1.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@sveltejs/kit": "^2.50.2",
"@playwright/test": "^1.58.2",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/markdown-it": "^14.0.0",
"@tailwindcss/vite": "^4.2.1",
"@types/better-sqlite3": "^7.6.13",
"@types/markdown-it": "^14.1.2",
"@types/uuid": "^10.0.0",
"@vite-pwa/sveltekit": "^1.0.0",
"drizzle-kit": "^0.31.0",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.0.0",
"@vite-pwa/sveltekit": "^1.1.0",
"drizzle-kit": "^0.31.9",
"svelte": "^5.53.7",
"svelte-check": "^4.4.4",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^3.0.0",
"vitest": "^3.2.4",
"yaml": "^2.8.2"
},
"pnpm": {
@@ -66,6 +74,11 @@
"argon2",
"better-sqlite3",
"esbuild"
]
],
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0"
}
}
}
}
+312 -531
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Automate version bump, tag, and push based on conventional commits.
# Usage: bash scripts/release.sh [patch|minor|major]
# If no argument is given, the bump type is inferred from commits since the last tag.
BUMP="${1:-}"
# --- Safety checks ---
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" != "main" ]]; then
echo "Error: must be on the main branch (currently on '$BRANCH')" >&2
exit 1
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean" >&2
exit 1
fi
git fetch origin main --tags --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [[ "$LOCAL" != "$REMOTE" ]]; then
echo "Error: local main is not in sync with origin/main" >&2
echo " local: $LOCAL" >&2
echo " remote: $REMOTE" >&2
exit 1
fi
# --- Bump type inference ---
if [[ -z "$BUMP" ]]; then
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "")
if [[ -z "$LAST_TAG" ]]; then
echo "No previous v* tag found, defaulting to patch"
BUMP="patch"
else
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'%s%n%b')
if echo "$COMMITS" | grep -qiE 'BREAKING CHANGE:|^[a-z]+(\(.*\))?!:'; then
BUMP="major"
elif echo "$COMMITS" | grep -qE '^feat(\(.*\))?:'; then
BUMP="minor"
else
BUMP="patch"
fi
fi
fi
if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then
echo "Error: invalid bump type '$BUMP' (must be patch, minor, or major)" >&2
exit 1
fi
# --- Bump, tag, push ---
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")
# 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"
echo "Pushing to origin..."
git push origin main --tags
echo ""
echo "Released v$NEW_VERSION ($BUMP bump)"
echo "GitHub Actions release workflow will handle the rest."
+20
View File
@@ -12,10 +12,19 @@
--text-muted: #6b6272;
--border: #1a1a2e;
--border-subtle: #d4cabb;
--destructive: #a63d2f;
--error-bg: #f5e0db;
--error-border: #d4a69e;
--error-text: #8b2e1f;
--success-bg: #dce8d4;
--success-text: #3a5a2e;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
}
body {
font-family: 'JetBrains Mono', monospace;
accent-color: var(--primary);
}
body::before {
@@ -29,6 +38,17 @@ body::before {
z-index: 0;
}
/* Editor entrance animations */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pop-in {
from { opacity: 0; transform: scale(0.95) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Prose overrides for parchment theme */
.prose {
--tw-prose-body: var(--text);
+6 -4
View File
@@ -6,10 +6,12 @@
<meta name="theme-color" content="#f0e6d3" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥨</text></svg>"
/>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Crumbs" />
<link rel="manifest" href="/site.webmanifest" />
<title>Crumbs</title>
%sveltekit.head%
</head>
+129 -48
View File
@@ -1,5 +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;
}
@@ -12,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 };
});
}
@@ -33,88 +51,151 @@
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"
>
<svg class="h-4 w-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
<section
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-1"
>
{#each activeItems as item (item.id)}
<div class="group flex items-center gap-2" 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)]"
data-testid="checklist-add"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<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-1 pl-5" data-testid="checklist-done-section">
{#each doneItems as item (item.id)}
<div class="group flex items-center gap-2">
<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>
+1 -1
View File
@@ -15,7 +15,7 @@
{@const colors = NOTE_COLORS[option.value]}
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-transparent'}"
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
style="background-color: {colors.bg}"
title={option.label}
data-testid="color-{option.value}"
+101 -108
View File
@@ -22,7 +22,8 @@
AlignRight,
AlignJustify,
Minus,
ChevronDown
ChevronDown,
Ellipsis
} from 'lucide-svelte';
interface Props {
@@ -80,11 +81,11 @@
const chevronSize = 14;
function btnClass(active: boolean = false, disabled: boolean = false): string {
return `rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
}
function dropdownBtnClass(active: boolean = false): string {
return `flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
}
function dropdownItemClass(active: boolean = false): string {
@@ -109,30 +110,44 @@
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
data-testid="formatting-toolbar"
>
<!-- History -->
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().undo().run()}
disabled={!canUndo()}
class={btnClass(false, !canUndo())}
title="Undo (Ctrl+Z)"
data-testid="format-undo"
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(editor?.isActive('bold') ?? false)}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
<Undo2 size={iconSize} />
<Bold size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().redo().run()}
disabled={!canRedo()}
class={btnClass(false, !canRedo())}
title="Redo (Ctrl+Shift+Z)"
data-testid="format-redo"
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(editor?.isActive('italic') ?? false)}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
<Redo2 size={iconSize} />
<Italic size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(editor?.isActive('strike') ?? false)}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
<Strikethrough size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(editor?.isActive('underline') ?? false)}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
<Underline size={iconSize} />
</button>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Heading dropdown -->
<div class="relative" data-dropdown="heading">
@@ -146,7 +161,7 @@
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'heading'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
@@ -187,7 +202,7 @@
<ChevronDown size={chevronSize} />
</button>
{#if openDropdown === 'list'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
@@ -208,67 +223,6 @@
{/if}
</div>
<button
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
class={btnClass(editor?.isActive('blockquote') ?? false)}
title="Blockquote"
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
class={btnClass(editor?.isActive('codeBlock') ?? false)}
title="Code block"
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
</button>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<!-- Inline -->
<button
onclick={() => editor?.chain().focus().toggleBold().run()}
class={btnClass(editor?.isActive('bold') ?? false)}
title="Bold (Ctrl+B)"
data-testid="format-bold"
>
<Bold size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleItalic().run()}
class={btnClass(editor?.isActive('italic') ?? false)}
title="Italic (Ctrl+I)"
data-testid="format-italic"
>
<Italic size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleStrike().run()}
class={btnClass(editor?.isActive('strike') ?? false)}
title="Strikethrough (Ctrl+Shift+X)"
data-testid="format-strikethrough"
>
<Strikethrough size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleCode().run()}
class={btnClass(editor?.isActive('code') ?? false)}
title="Inline code (Ctrl+E)"
data-testid="format-code"
>
<Code size={iconSize} />
</button>
<button
onclick={() => editor?.chain().focus().toggleUnderline().run()}
class={btnClass(editor?.isActive('underline') ?? false)}
title="Underline (Ctrl+U)"
data-testid="format-underline"
>
<Underline size={iconSize} />
</button>
<!-- Link popover -->
<div class="relative" data-dropdown="link">
<button
@@ -280,7 +234,7 @@
<Link size={iconSize} />
</button>
{#if openDropdown === 'link'}
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
<form
class="flex items-center gap-1"
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
@@ -327,28 +281,79 @@
{/if}
</div>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
<!-- Align dropdown -->
<div class="relative" data-dropdown="align">
<!-- More dropdown -->
<div class="relative" data-dropdown="more">
<button
onclick={() => toggleDropdown('align')}
class={dropdownBtnClass(editor?.isActive({ textAlign: 'center' }) || editor?.isActive({ textAlign: 'right' }) || editor?.isActive({ textAlign: 'justify' }) ? true : false)}
title="Text alignment"
data-testid="format-align"
onclick={() => toggleDropdown('more')}
class={dropdownBtnClass(false)}
title="More options"
data-testid="format-more"
>
<AlignLeft size={iconSize} />
<ChevronDown size={chevronSize} />
<Ellipsis size={iconSize} />
</button>
{#if openDropdown === 'align'}
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
{#if openDropdown === 'more'}
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
<button
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
disabled={!canUndo()}
class={dropdownItemClass()}
data-testid="format-undo"
>
<Undo2 size={iconSize} />
<span>Undo</span>
</button>
<button
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
disabled={!canRedo()}
class={dropdownItemClass()}
data-testid="format-redo"
>
<Redo2 size={iconSize} />
<span>Redo</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('code') ?? false)}
data-testid="format-code"
>
<Code size={iconSize} />
<span>Inline code</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
data-testid="format-blockquote"
>
<TextQuote size={iconSize} />
<span>Blockquote</span>
</button>
<button
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
data-testid="format-code-block"
>
<CodeXml size={iconSize} />
<span>Code block</span>
</button>
<button
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
class={dropdownItemClass()}
data-testid="format-hr"
>
<Minus size={iconSize} />
<span>Divider</span>
</button>
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
<button
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
data-testid="format-align-left"
>
<AlignLeft size={iconSize} />
<span>Left</span>
<span>Align left</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
@@ -356,7 +361,7 @@
data-testid="format-align-center"
>
<AlignCenter size={iconSize} />
<span>Center</span>
<span>Align center</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
@@ -364,7 +369,7 @@
data-testid="format-align-right"
>
<AlignRight size={iconSize} />
<span>Right</span>
<span>Align right</span>
</button>
<button
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
@@ -377,16 +382,4 @@
</div>
{/if}
</div>
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
<!-- Insert -->
<button
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
class={btnClass()}
title="Horizontal rule"
data-testid="format-hr"
>
<Minus size={iconSize} />
</button>
</div>
+2 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import type { Attachment } from '$lib/types/index.js';
interface Props {
@@ -69,9 +70,7 @@
aria-label="Remove attachment"
data-testid="remove-attachment"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<XIcon class="h-3 w-3" />
</button>
</div>
{/each}
+37 -19
View File
@@ -1,35 +1,53 @@
<script lang="ts">
import SearchBar from '../SearchBar.svelte';
import SyncIndicator from '../SyncIndicator.svelte';
import Menu from 'lucide-svelte/icons/menu';
import Search from 'lucide-svelte/icons/search';
interface Props {
onMenuToggle: () => void;
}
let { onMenuToggle }: Props = $props();
let mobileSearchOpen = $state(false);
</script>
<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">
<button
onclick={onMenuToggle}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
aria-label="Toggle sidebar"
>
<svg class="h-6 w-6 text-[var(--text)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{#if mobileSearchOpen}
<!-- Mobile expanded search -->
<div class="flex flex-1 items-center gap-2 lg:hidden">
<SearchBar onClose={() => (mobileSearchOpen = false)} />
</div>
{:else}
<button
onclick={onMenuToggle}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
aria-label="Toggle sidebar"
>
<Menu class="h-6 w-6 text-[var(--text)]" />
</button>
<div class="flex items-center gap-2">
<span class="text-2xl">🥨</span>
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
</div>
<div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="h-8 w-8" />
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
</div>
<div class="mx-4 flex-1">
<SearchBar />
</div>
<!-- Desktop search bar -->
<div class="mx-4 hidden flex-1 lg:block">
<SearchBar />
</div>
<div class="flex items-center gap-2">
<SyncIndicator />
</div>
<!-- Mobile search icon -->
<button
onclick={() => (mobileSearchOpen = true)}
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
aria-label="Search"
>
<Search class="h-5 w-5 text-[var(--text)]" />
</button>
<div class="flex items-center gap-2">
<SyncIndicator />
</div>
{/if}
</header>
+26 -2
View File
@@ -5,23 +5,42 @@
interface Props {
open: boolean;
onClose?: () => void;
appVersion?: string;
}
let { open }: 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>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 top-16 z-10 bg-black/30 lg:hidden"
onclick={onClose}
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
></div>
{/if}
<aside
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-[var(--border)] bg-[var(--bg-surface)] transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-[var(--border)] bg-[var(--bg-surface)] transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'}"
>
<nav class="p-2">
<ul class="space-y-1">
@@ -73,4 +92,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>
+2 -29
View File
@@ -1,38 +1,11 @@
<script lang="ts">
import { writable } from 'svelte/store';
interface ToastMessage {
id: number;
text: string;
type: 'info' | 'success' | 'error';
action?: { label: string; handler: () => void };
}
let counter = 0;
export const toasts = writable<ToastMessage[]>([]);
export function showToast(
text: string,
type: 'info' | 'success' | 'error' = 'info',
action?: { label: string; handler: () => void },
duration = 4000
) {
const id = ++counter;
toasts.update((t) => [...t, { id, text, type, action }]);
if (!action) {
setTimeout(() => dismiss(id), duration);
}
}
export function dismiss(id: number) {
toasts.update((t) => t.filter((toast) => toast.id !== id));
}
import { toasts, dismiss } from '$lib/stores/toast.js';
</script>
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
{#each $toasts as toast (toast.id)}
<div
class="flex items-center gap-3 rounded-sm border border-[var(--border)] px-4 py-3 {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-[var(--text)] text-[var(--bg-base)]'}"
class="flex items-center gap-3 rounded-sm border px-4 py-3 shadow-[var(--card-shadow)] {toast.type === 'error' ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : toast.type === 'success' ? 'border-[var(--border-subtle)] bg-[var(--success-bg)] text-[var(--success-text)]' : 'border-[var(--border)] bg-[var(--text)] text-[var(--bg-base)]'}"
>
<span class="text-sm">{toast.text}</span>
{#if toast.action}
+65 -42
View File
@@ -1,8 +1,14 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
import type { Note } from '$lib/types/index.js';
import Undo2 from 'lucide-svelte/icons/undo-2';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Bookmark from 'lucide-svelte/icons/bookmark';
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
interface ChecklistItem {
text: string;
@@ -12,9 +18,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];
@@ -34,16 +41,35 @@
: []
);
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
function stop(fn: () => void) {
return (e: Event) => {
e.stopPropagation();
fn();
};
}
function toggleChecklistItem(item: ChecklistItem) {
const lines = note.content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const text = line.replace(/^- \[[ x]\] /, '');
if (text === item.text && line.startsWith(item.checked ? '- [x] ' : '- [ ] ')) {
lines[i] = item.checked
? `- [ ] ${text}`
: `- [x] ${text}`;
break;
}
}
updateNote(note.id, { content: lines.join('\n') });
}
</script>
<article
class="group relative cursor-pointer rounded-sm border border-[var(--border)] p-4 transition-colors hover:border-[var(--primary)]"
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)}
@@ -52,15 +78,32 @@
data-testid="note-card"
data-note-id={note.id}
>
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<ul class="space-y-1" data-testid="note-checklist-preview">
{#each checklistItems.slice(0, 8) as item}
{#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} disabled class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)]" />
<input
type="checkbox"
checked={item.checked}
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
data-testid="card-checklist-checkbox"
/>
<span class="truncate">{item.text}</span>
</li>
{/each}
@@ -77,15 +120,13 @@
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<span class="rounded-sm bg-[var(--border)]/10 px-2 py-0.5 text-xs text-[var(--text-muted)]">
#{tag}
</span>
<TagChip {tag} />
{/each}
</div>
{/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))}
@@ -93,9 +134,7 @@
title="Restore"
data-testid="restore-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
<Undo2 class="h-4 w-4" />
</button>
<button
onclick={stop(() => deleteNote(note.id))}
@@ -103,21 +142,19 @@
title="Delete forever"
data-testid="delete-forever-btn"
>
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
</button>
{:else}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title={note.pinned ? 'Unpin' : 'Pin'}
data-testid="pin-btn"
>
<svg class="h-4 w-4 {note.pinned ? 'text-[var(--primary)]' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{#if !note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Pin"
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
</button>
{/if}
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
@@ -125,9 +162,7 @@
title="Unarchive"
data-testid="unarchive-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<ArchiveRestore class="h-4 w-4" />
</button>
{:else}
<button
@@ -136,9 +171,7 @@
title="Archive"
data-testid="archive-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<Archive class="h-4 w-4" />
</button>
{/if}
<button
@@ -147,21 +180,11 @@
title="Delete"
data-testid="trash-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</div>
<!-- Pin indicator -->
{#if note.pinned}
<div class="absolute right-2 top-2">
<svg class="h-4 w-4 text-[var(--primary)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
{/if}
</article>
<style>
+49 -39
View File
@@ -7,6 +7,11 @@
import { NOTE_COLORS } from '$lib/utils/colors.js';
import type { Editor } from '@tiptap/core';
import type { Note, NoteColor } from '$lib/types/index.js';
import Palette from 'lucide-svelte/icons/palette';
import SquareCheck from 'lucide-svelte/icons/square-check';
import Type from 'lucide-svelte/icons/type';
import FileCode from 'lucide-svelte/icons/file-code';
import FileText from 'lucide-svelte/icons/file-text';
interface Props {
note: Note | null;
@@ -25,7 +30,7 @@
// svelte-ignore state_referenced_locally
let checklistMode = $state(note?.checklistMode ?? false);
let showColorPicker = $state(false);
let rawMarkdownMode = $state(false);
let rawMarkdownMode = $state(note?.checklistMode ?? false);
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
@@ -71,14 +76,14 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center bg-black/50 pt-20"
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}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-xl rounded-sm border border-[var(--border)]"
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
@@ -94,28 +99,30 @@
/>
<!-- Content -->
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
</div>
{:else if rawMarkdownMode}
<textarea
bind:this={textareaEl}
placeholder="Add a crumb..."
bind:value={content}
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
rows="12"
data-testid="note-content-input"
></textarea>
{:else}
<TiptapEditor
{content}
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
placeholder="Add a crumb..."
/>
{/if}
<div class="max-h-[60vh] overflow-y-auto">
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
</div>
{:else if rawMarkdownMode}
<textarea
bind:this={textareaEl}
placeholder="Add a crumb..."
bind:value={content}
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
rows="12"
data-testid="note-content-input"
></textarea>
{:else}
<TiptapEditor
{content}
onUpdate={(md) => (content = md)}
onEditor={(e) => (tiptapEditor = e)}
onTransaction={() => editorTick++}
placeholder="Add a crumb..."
/>
{/if}
</div>
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
@@ -133,12 +140,10 @@
title="Background color"
data-testid="color-picker-toggle"
>
<svg class="h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
</button>
{#if showColorPicker}
<div class="absolute left-0 top-full mt-2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
<ColorPicker selected={color} onSelect={handleColorSelect} />
</div>
{/if}
@@ -147,25 +152,30 @@
<!-- Checklist mode toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10 {checklistMode ? 'text-[var(--primary)]' : ''}"
title="Checklist mode"
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
data-testid="checklist-toggle"
>
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{#if checklistMode}
<Type class="h-5 w-5" />
{:else}
<SquareCheck class="h-5 w-5" />
{/if}
</button>
<!-- Raw markdown mode toggle -->
<button
onclick={toggleMarkdownMode}
class="rounded-sm p-2 hover:bg-[var(--border)]/10 {rawMarkdownMode ? 'text-[var(--primary)]' : ''}"
title="Markdown mode"
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"
>
<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 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
{#if rawMarkdownMode}
<FileText class="h-5 w-5" />
{:else}
<FileCode class="h-5 w-5" />
{/if}
</button>
</div>
+57 -8
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-gray-500">{label}</p>
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
{/if}
{#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}
<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}
+29 -7
View File
@@ -1,10 +1,24 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
interface Props {
onClose?: () => void;
}
let { onClose }: Props = $props();
let query = $state('');
let originalNotes: Note[] = [];
let isSearching = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
$effect(() => {
if (onClose) inputEl?.focus();
});
async function handleSearch() {
if (!query.trim()) {
@@ -34,14 +48,24 @@
isSearching = false;
}
}
function close() {
clearSearch();
onClose?.();
}
</script>
<div class="relative max-w-2xl">
<div class="relative flex-1 max-w-2xl">
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
<svg class="mr-3 h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{#if onClose}
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
<ArrowLeft class="h-5 w-5" />
</button>
{:else}
<Search class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
{/if}
<input
bind:this={inputEl}
type="text"
placeholder="Search crumbs..."
bind:value={query}
@@ -51,9 +75,7 @@
/>
{#if query}
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
<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="M6 18L18 6M6 6l12 12" />
</svg>
<X class="h-5 w-5" />
</button>
{/if}
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Clock from 'lucide-svelte/icons/clock';
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
import Hand from 'lucide-svelte/icons/hand';
import { sortMode, type SortMode } from '$lib/stores/sort.js';
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
{ value: 'updated', label: 'Updated', icon: Clock },
{ value: 'created', label: 'Created', icon: CalendarPlus },
{ value: 'custom', label: 'Custom', icon: Hand }
];
</script>
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
{#each modes as mode}
<button
onclick={() => sortMode.set(mode.value)}
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
{$sortMode === mode.value
? 'bg-[var(--primary)] text-white'
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
data-testid="sort-{mode.value}"
aria-label="Sort by {mode.label}"
>
<mode.icon class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{mode.label}</span>
</button>
{/each}
</div>
+50 -17
View File
@@ -1,24 +1,57 @@
<script lang="ts">
// Placeholder for Phase 6 - will be connected to sync store
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
import { onMount, onDestroy } from 'svelte';
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
import CloudCheck from 'lucide-svelte/icons/cloud-check';
import CloudSync from 'lucide-svelte/icons/cloud-sync';
import CloudOff from 'lucide-svelte/icons/cloud-off';
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
let status: SyncStatus = $state(getSyncStatus());
let displayStatus: SyncStatus = $state(getSyncStatus());
let minDisplayTimeout: ReturnType<typeof setTimeout> | undefined;
let pendingStatus: SyncStatus | undefined;
function applyStatus(s: SyncStatus) {
if (displayStatus === s) return;
if (minDisplayTimeout) {
pendingStatus = s;
return;
}
displayStatus = s;
minDisplayTimeout = setTimeout(() => {
minDisplayTimeout = undefined;
if (pendingStatus !== undefined && pendingStatus !== displayStatus) {
applyStatus(pendingStatus);
pendingStatus = undefined;
}
}, 300);
}
let unsubscribe: (() => void) | undefined;
onMount(() => {
unsubscribe = onSyncStatusChange((s) => {
status = s;
applyStatus(s);
});
});
onDestroy(() => {
unsubscribe?.();
if (minDisplayTimeout) clearTimeout(minDisplayTimeout);
});
</script>
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
{#if status === 'synced'}
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{:else if status === 'syncing'}
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{:else if status === 'offline'}
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
{#if displayStatus === 'synced'}
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
{:else if displayStatus === 'syncing'}
<CloudSync class="h-5 w-5 text-[var(--primary)] opacity-60" />
{:else if displayStatus === 'offline'}
<CloudOff class="h-5 w-5 text-[var(--text-muted)]" />
{:else}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
{/if}
</div>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
tag: string;
active?: boolean;
onclick?: () => void;
}
let { tag, active = false, onclick }: Props = $props();
</script>
{#if onclick}
<button
{onclick}
class="rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
? 'bg-[var(--primary)] text-white'
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
data-testid="tag-chip"
>
#{tag}
</button>
{:else}
<span
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs font-medium text-[var(--text-muted)]"
data-testid="tag-chip"
>
#{tag}
</span>
{/if}
+2 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { allTags, selectedTag } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
function toggleTag(tag: string) {
selectedTag.update((current) => (current === tag ? null : tag));
@@ -9,13 +10,7 @@
{#if $allTags.length > 0}
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
{#each $allTags as tag}
<button
onclick={() => toggleTag(tag)}
class="rounded-sm px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-[var(--primary)] text-white' : 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
data-testid="tag-chip"
>
#{tag}
</button>
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
{/each}
</div>
{/if}
+168 -35
View File
@@ -1,9 +1,14 @@
import { writable, derived } from 'svelte/store';
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
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');
export const selectedTag = writable<string | null>(null);
export const notesLoaded = writable(false);
export const filteredNotes = derived(
[notes, selectedTag, currentFilter],
@@ -23,64 +28,170 @@ 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();
});
function isNetworkError(err: unknown): boolean {
return err instanceof TypeError;
}
export async function loadNotes(filter: NoteFilter = 'all') {
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data = await res.json();
notes.set(data);
currentFilter.set(filter);
// Load from IDB first for instant display
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
}
currentFilter.set(filter);
notesLoaded.set(true);
// Then fetch from server in the background
try {
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data: Note[] = await res.json();
notes.set(data);
currentFilter.set(filter);
// Update IDB with server state
await clearIdbNotes();
await Promise.all(data.map((n) => putNote(n)));
}
} catch {
// Offline — IDB data already loaded above
}
}
export async function createNote(note: NoteCreate): Promise<Note | null> {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note)
});
if (res.ok) {
const created = await res.json();
notes.update((list) => [created, ...list]);
return created;
try {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note)
});
if (res.ok) {
const created: Note = await res.json();
notes.update((list) => [created, ...list]);
await putNote(created);
return created;
}
return null;
} catch (err) {
if (isNetworkError(err)) {
const now = new Date();
const title = note.title ?? '';
const content = note.content ?? '';
const optimistic: Note = {
id: crypto.randomUUID(),
title,
content,
color: note.color ?? 'default',
pinned: note.pinned ?? false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: note.checklistMode ?? false,
sortOrder: 0,
createdAt: now,
updatedAt: now,
version: 1,
tags: extractTags(`${title} ${content}`)
};
notes.update((list) => [optimistic, ...list]);
await putNote(optimistic);
await addToSyncQueue({ noteId: optimistic.id, operation: 'create', data: optimistic, timestamp: Date.now() });
showToast('Saved offline — will sync when reconnected', 'info');
return optimistic;
}
return null;
}
return null;
}
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
const res = await fetch(`/api/notes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (res.ok) {
const updated = await res.json();
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
return updated;
try {
const res = await fetch(`/api/notes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (res.ok) {
const updated: Note = await res.json();
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
await putNote(updated);
return updated;
}
showToast('Failed to save note', 'error');
return null;
} catch (err) {
if (isNetworkError(err)) {
let optimistic: Note | null = null;
notes.update((list) =>
list.map((n) => {
if (n.id === id) {
optimistic = { ...n, ...updates, updatedAt: new Date() };
if (updates.title !== undefined || updates.content !== undefined) {
optimistic.tags = extractTags(`${optimistic.title} ${optimistic.content}`);
}
return optimistic;
}
return n;
})
);
if (optimistic) {
await putNote(optimistic);
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
showToast('Saved offline — will sync when reconnected', 'info');
}
return optimistic;
}
return null;
}
return null;
}
export async function deleteNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
return true;
try {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
return false;
} catch (err) {
if (isNetworkError(err)) {
notes.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
showToast('Saved offline — will sync when reconnected', 'info');
return true;
}
return false;
}
return false;
}
export async function trashNote(id: string): Promise<Note | null> {
@@ -102,3 +213,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);
});
}
+28
View File
@@ -0,0 +1,28 @@
import { writable } from 'svelte/store';
export interface ToastMessage {
id: number;
text: string;
type: 'info' | 'success' | 'error';
action?: { label: string; handler: () => void };
}
let counter = 0;
export const toasts = writable<ToastMessage[]>([]);
export function showToast(
text: string,
type: 'info' | 'success' | 'error' = 'info',
action?: { label: string; handler: () => void },
duration = 4000
) {
const id = ++counter;
toasts.update((t) => [...t, { id, text, type, action }]);
if (!action) {
setTimeout(() => dismiss(id), duration);
}
}
export function dismiss(id: number) {
toasts.update((t) => t.filter((toast) => toast.id !== id));
}
+5
View File
@@ -2,6 +2,8 @@ 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 { mergeNotes } from './crdt.js';
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
import { get } from 'svelte/store';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
@@ -98,6 +100,9 @@ export async function sync(): Promise<void> {
await pullChanges();
setStatus('synced');
// Reload notes from server and persist to IDB
await loadNotes(get(currentFilter));
} catch {
setStatus('error');
}
+16
View File
@@ -1,12 +1,16 @@
import { db } from '$lib/server/db/index.js';
import { notes, syncLog } from '$lib/server/db/schema.js';
import { eq, gt } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import type { SyncQueueItem } from './idb.js';
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
const noteIdsToSyncTags: string[] = [];
db.transaction((tx) => {
for (const change of changes) {
switch (change.operation) {
@@ -48,6 +52,10 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
})
.run();
}
if (change.data.title !== undefined || change.data.content !== undefined) {
noteIdsToSyncTags.push(change.noteId);
}
break;
}
case 'delete': {
@@ -66,6 +74,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
.run();
}
});
for (const noteId of noteIdsToSyncTags) {
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
if (note) {
const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content));
}
}
}
/**
@@ -1,4 +1,4 @@
import { version } from '../../package.json';
import { version } from '../../../package.json';
export function load() {
return { appVersion: version };
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import Header from '$lib/components/Layout/Header.svelte';
import Sidebar from '$lib/components/Layout/Sidebar.svelte';
import Toast from '$lib/components/Layout/Toast.svelte';
import { loadNotes } from '$lib/stores/notes.js';
import { startSync, stopSync } from '$lib/sync/client.js';
import { onMount, onDestroy } from 'svelte';
let { data, children } = $props();
let sidebarOpen = $state(false);
onMount(() => {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
loadNotes();
startSync();
});
onDestroy(() => {
stopSync();
});
</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)} appVersion={data.appVersion} />
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
<div class="mx-auto max-w-7xl px-4">
{@render children()}
</div>
</main>
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
Crumbs by Bretzel &mdash; made with 🥨 in Strasbourg
</footer>
<Toast />
</div>
@@ -2,20 +2,45 @@
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 } 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';
import Plus from 'lucide-svelte/icons/plus';
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
function openEditor(note: Note) {
editingNote = note;
history.replaceState(null, '', `#${note.id}`);
}
function closeEditor() {
editingNote = null;
showNewNote = false;
history.replaceState(null, '', location.pathname);
}
function handleReorder(noteIds: string[]) {
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
updateSortOrders(orders);
}
onMount(() => {
const hash = location.hash.slice(1);
if (!hash) return;
// Notes may not be loaded yet — wait for them
const unsubscribe = notes.subscribe(($notes) => {
if ($notes.length === 0) return;
const note = $notes.find((n) => n.id === hash);
if (note) editingNote = note;
unsubscribe();
});
});
</script>
<svelte:head>
@@ -26,17 +51,25 @@
<div class="mx-auto mb-6 max-w-xl">
<button
onclick={() => (showNewNote = true)}
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)]"
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-testid="new-note-btn"
>
<Plus class="h-4 w-4" />
Add a crumb...
</button>
</div>
{/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>
@@ -46,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}
@@ -54,11 +87,14 @@
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
draggable={$sortMode === 'custom'}
dndType="unpinned-notes"
onReorder={handleReorder}
/>
{#if $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<span class="mb-4 text-7xl">🥨</span>
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
<p class="font-['Press_Start_2P'] text-xs">
{#if $currentFilter === 'trashed'}
No crumbs in trash
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)] text-[var(--text)]">
{@render children()}
</div>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
let password = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
errorMsg = '';
loading = true;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
window.location.href = '/';
} else {
const data = await res.json();
errorMsg = data.message || 'Invalid password';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Login - Crumbs</title>
</svelte:head>
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
<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>
</div>
<form onsubmit={handleLogin}>
{#if errorMsg}
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password"
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="password-input"
required
/>
<button
type="submit"
disabled={loading}
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="login-btn"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
let password = $state('');
let confirmPassword = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleSetup(e: Event) {
e.preventDefault();
errorMsg = '';
if (password.length < 8) {
errorMsg = 'Password must be at least 8 characters';
return;
}
if (password !== confirmPassword) {
errorMsg = 'Passwords do not match';
return;
}
loading = true;
try {
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
window.location.href = '/';
} else {
const data = await res.json();
errorMsg = data.message || 'Setup failed';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Setup - Crumbs</title>
</svelte:head>
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
<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>
</div>
<form onsubmit={handleSetup}>
{#if errorMsg}
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password (min 8 characters)"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="password-input"
required
/>
<input
type="password"
bind:value={confirmPassword}
placeholder="Confirm password"
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="confirm-password-input"
required
/>
<button
type="submit"
disabled={loading}
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'}
</button>
</form>
</div>
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 text-center">
<span class="text-5xl">🥨</span>
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
<h1 class="mt-4 font-['Press_Start_2P'] text-xl text-[var(--primary)]">
{page.status}
</h1>
+2 -27
View File
@@ -1,32 +1,7 @@
<script lang="ts">
import '../app.css';
import Header from '$lib/components/Layout/Header.svelte';
import Sidebar from '$lib/components/Layout/Sidebar.svelte';
import Toast from '$lib/components/Layout/Toast.svelte';
import { loadNotes } from '$lib/stores/notes.js';
import { onMount } from 'svelte';
let { data, children } = $props();
let sidebarOpen = $state(false);
onMount(() => {
loadNotes();
});
let { children } = $props();
</script>
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} />
<main class="flex-1 pt-4 transition-all lg:ml-64">
<div class="mx-auto max-w-7xl px-4">
{@render children()}
</div>
</main>
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] lg:ml-64">
Crumbs by Bretzel v{data.appVersion} &mdash; made with 🥨 in Strasbourg
</footer>
<Toast />
</div>
{@render children()}
+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 });
};
+9 -1
View File
@@ -1,6 +1,7 @@
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';
export const POST: RequestHandler = async ({ request }) => {
const { changes } = await request.json();
@@ -16,5 +17,12 @@ export const POST: RequestHandler = async ({ request }) => {
export const GET: RequestHandler = async ({ url }) => {
const since = parseInt(url.searchParams.get('since') || '0', 10);
const changes = await getChangesSince(since);
return json(changes);
const tagMap = fetchTagsForNotes(changes.map((n) => n.id));
const changesWithTags = changes.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
return json(changesWithTags);
};
-72
View File
@@ -1,72 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
let password = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
errorMsg = '';
loading = true;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Invalid password';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Login - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<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>
</div>
<form onsubmit={handleLogin}>
{#if errorMsg}
<div class="mb-4 rounded-sm border border-red-300 bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password"
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="password-input"
required
/>
<button
type="submit"
disabled={loading}
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="login-btn"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
-93
View File
@@ -1,93 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
let password = $state('');
let confirmPassword = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleSetup(e: Event) {
e.preventDefault();
errorMsg = '';
if (password.length < 8) {
errorMsg = 'Password must be at least 8 characters';
return;
}
if (password !== confirmPassword) {
errorMsg = 'Passwords do not match';
return;
}
loading = true;
try {
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Setup failed';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Setup - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<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>
</div>
<form onsubmit={handleSetup}>
{#if errorMsg}
<div class="mb-4 rounded-sm border border-red-300 bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password (min 8 characters)"
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="password-input"
required
/>
<input
type="password"
bind:value={confirmPassword}
placeholder="Confirm password"
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
data-testid="confirm-password-input"
required
/>
<button
type="submit"
disabled={loading}
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'}
</button>
</form>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.4 MiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "Crumbs by Bretzel",
"short_name": "Crumbs",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#f9f4ea",
"background_color": "#f9f4ea",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+33 -7
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
test.describe('Checklist', () => {
test('Scenario: Checklist UI replaces editor when checklist mode is enabled', async ({ authenticatedPage: page }) => {
@@ -27,7 +27,7 @@ test.describe('Checklist', () => {
await expect(page.getByText('Shopping List')).toBeVisible();
// When the user reopens the note
await page.getByTestId('note-card').click();
await noteCard(page, 'Shopping List').click();
// Then the checklist is displayed with the saved item
await expect(page.getByTestId('checklist')).toBeVisible();
@@ -43,13 +43,16 @@ test.describe('Checklist', () => {
await page.getByTestId('close-editor-btn').click();
// When the user reopens the note and checks the item
await page.getByTestId('note-card').click();
await page.getByTestId('checklist-checkbox').first().check();
await noteCard(page, 'Tasks').click();
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
await page.getByTestId('note-card').click();
await expect(page.getByTestId('checklist-checkbox').first()).toBeChecked();
// And reopening the note shows the item in the done section (expanded by default)
await noteCard(page, 'Tasks').click();
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();
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page, Locator } from '@playwright/test';
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
@@ -94,7 +94,7 @@ test.describe('Rich text formatting', () => {
await page.getByTestId('close-editor-btn').click();
// When the user reopens the note
await page.getByTestId('note-card').click();
await noteCard(page, 'Rich Text Note').click();
// Then the content is rendered as rich text in the TipTap editor
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
+10 -1
View File
@@ -1,7 +1,12 @@
import { test as base, expect, type Page } from '@playwright/test';
import { test as base, expect, type Page, type Locator } from '@playwright/test';
const TEST_PASSWORD = 'testpassword123';
/** Find a specific note card by its title text */
export function noteCard(page: Page, title: string): Locator {
return page.locator('[data-testid="note-card"]', { hasText: title });
}
/**
* Extended test fixture that handles setup/auth.
*/
@@ -40,6 +45,10 @@ export async function setupAndLogin(page: Page) {
await page.waitForURL('/');
}
// else: already on main page (session still valid)
// Wait for full hydration after auth redirect (window.location.href causes full reload).
// networkidle ensures JS bundles have loaded and executed.
await page.waitForLoadState('networkidle');
}
export { expect };
+6 -12
View File
@@ -1,12 +1,6 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
test.describe('Notes CRUD', () => {
test('Scenario: Fresh account shows an empty notes list', async ({ authenticatedPage: page }) => {
// Given the user has no notes
// Then the empty state message is displayed
await expect(page.getByText('No crumbs yet')).toBeVisible();
});
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => {
// When the user creates a note titled "My First Note" with content "Hello world!"
await page.getByTestId('new-note-btn').click();
@@ -17,8 +11,7 @@ test.describe('Notes CRUD', () => {
await page.getByTestId('close-editor-btn').click();
// Then the note is visible in the notes list
await expect(page.getByTestId('note-card')).toBeVisible();
await expect(page.getByText('My First Note')).toBeVisible();
await expect(noteCard(page, 'My First Note')).toBeVisible();
});
test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => {
@@ -31,7 +24,7 @@ test.describe('Notes CRUD', () => {
await page.getByTestId('close-editor-btn').click();
// When the user changes the title to "Updated Title"
await page.getByTestId('note-card').click();
await noteCard(page, 'Original Title').click();
await page.getByTestId('note-title-input').clear();
await page.getByTestId('note-title-input').fill('Updated Title');
await page.getByTestId('close-editor-btn').click();
@@ -47,8 +40,9 @@ test.describe('Notes CRUD', () => {
await page.getByTestId('close-editor-btn').click();
// When the user trashes the note
await page.getByTestId('note-card').hover();
await page.getByTestId('trash-btn').click();
const card = noteCard(page, 'Delete Me');
await card.hover();
await card.getByTestId('trash-btn').click();
// Then the note is no longer visible
await expect(page.getByText('Delete Me')).not.toBeVisible();
+102 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
test.describe('Offline Support', () => {
test('Scenario: Sync status is visible to the user', async ({ authenticatedPage: page }) => {
@@ -16,4 +16,105 @@ test.describe('Offline Support', () => {
// Then the note appears in the list
await expect(page.getByText('Offline Test Note')).toBeVisible();
});
test('Scenario: Edited note persists in the UI when offline', async ({ authenticatedPage: page }) => {
// Given a note titled "Offline Edit" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Offline Edit');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Offline Edit')).toBeVisible();
// When the network is unavailable
await page.context().setOffline(true);
// And the user edits the note title to "Offline Edited"
await noteCard(page, 'Offline Edit').click();
await page.getByTestId('note-title-input').clear();
await page.getByTestId('note-title-input').fill('Offline Edited');
await page.getByTestId('close-editor-btn').click();
// Then the note "Offline Edited" appears in the list
await expect(page.getByText('Offline Edited')).toBeVisible();
// And a "Saved offline" toast is shown
await expect(page.getByText('Saved offline')).toBeVisible();
});
test('Scenario: New note appears in the UI when created offline', async ({ authenticatedPage: page }) => {
// When the network is unavailable
await page.context().setOffline(true);
// And the user creates a note titled "Offline New"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Offline New');
await page.getByTestId('close-editor-btn').click();
// Then the note "Offline New" appears in the list
await expect(page.getByText('Offline New')).toBeVisible();
// And a "Saved offline" toast is shown
await expect(page.getByText('Saved offline')).toBeVisible();
});
test('Scenario: Offline note survives a page reload after reconnecting', async ({ authenticatedPage: page }) => {
// When the network is unavailable
await page.context().setOffline(true);
// And the user creates a note titled "Survive Reload"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Survive Reload');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Survive Reload')).toBeVisible();
// When the network is restored (triggers 'online' event + navigator.onLine = true)
await page.context().setOffline(false);
// And the sync completes
await page.waitForResponse('**/api/sync');
// And the user reloads the page
await page.reload();
// Then the note is still visible
await expect(page.getByText('Survive Reload')).toBeVisible();
});
test('Scenario: Notes load from local cache when the API is unavailable', async ({ authenticatedPage: page }) => {
// Given a note titled "Cached Note" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Cached Note');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Cached Note')).toBeVisible();
// When the API is unavailable (simulating service worker serving cached shell)
await page.route('**/api/**', (route) => route.abort());
// And the user reloads the page
await page.reload();
// Then the note is still visible (loaded from IDB cache)
await expect(page.getByText('Cached Note')).toBeVisible();
});
test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => {
// Given a note titled "Offline Trash" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Offline Trash');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Offline Trash')).toBeVisible();
// When the network is unavailable
await page.context().setOffline(true);
// And the user trashes the note
const card = noteCard(page, 'Offline Trash');
await card.hover();
await card.getByTestId('trash-btn').click();
// Then the note is no longer visible
await expect(page.getByText('Offline Trash')).not.toBeVisible();
// And a "Saved offline" toast is shown
await expect(page.getByText('Saved offline')).toBeVisible();
});
});
+70 -7
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
test.describe('Organization Features', () => {
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
@@ -8,8 +8,9 @@ test.describe('Organization Features', () => {
await page.getByTestId('close-editor-btn').click();
// When the user pins the note
await page.getByTestId('note-card').hover();
await page.getByTestId('pin-btn').click();
const pinCard = noteCard(page, 'Pin Me');
await pinCard.hover();
await pinCard.getByTestId('pin-btn').click();
// Then the "Pinned" section is visible
await expect(page.getByText('Pinned')).toBeVisible();
@@ -22,8 +23,9 @@ test.describe('Organization Features', () => {
await page.getByTestId('close-editor-btn').click();
// When the user archives the note
await page.getByTestId('note-card').hover();
await page.getByTestId('archive-btn').click();
const archiveCard = noteCard(page, 'Archive Me');
await archiveCard.hover();
await archiveCard.getByTestId('archive-btn').click();
// Then the note is no longer visible in the main view
await expect(page.getByText('Archive Me')).not.toBeVisible();
@@ -40,7 +42,7 @@ test.describe('Organization Features', () => {
await page.getByTestId('close-editor-btn').click();
// Then the note is saved and visible in the notes list
await expect(page.getByTestId('note-card')).toBeVisible();
await expect(noteCard(page, 'Colored Note')).toBeVisible();
});
test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => {
@@ -57,7 +59,7 @@ test.describe('Organization Features', () => {
await page.getByTestId('close-editor-btn').click();
// When the user filters by the #important tag
const tagChip = page.getByTestId('tag-chip').filter({ hasText: '#important' });
const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' });
if (await tagChip.isVisible()) {
await tagChip.click();
@@ -66,3 +68,64 @@ test.describe('Organization Features', () => {
}
});
});
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\)\]/);
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { test, expect } from './helpers/fixtures.js';
import { test, expect, noteCard } from './helpers/fixtures.js';
test.describe('Search', () => {
test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => {
@@ -42,6 +42,6 @@ test.describe('Search', () => {
await page.waitForTimeout(500);
// Then no notes are displayed
await expect(page.getByTestId('note-card')).not.toBeVisible();
await expect(page.getByTestId('note-card')).toHaveCount(0);
});
});
View File
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80vh;
text-align: center;
padding: 2rem;
}
.error-code {
font-size: clamp(3rem, 10vw, 6rem);
color: var(--primary);
margin-bottom: 1rem;
}
.error-message {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 2rem;
max-width: 24rem;
}
</style>
</head>
<body>
<div class="error-page">
<h1 class="error-code font-pixel">404</h1>
<p class="error-message">This note seems to have been deleted. Or maybe it never existed.</p>
<a href="/" class="btn btn-primary">Back to home</a>
</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
crumbs.bretzel.app
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

+308
View File
@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crumbs - Your notes. Your server. Your rules.</title>
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app. A privacy-respecting Google Keep alternative you can run on your own server with a single Docker command.">
<meta property="og:title" content="Crumbs by Bretzel">
<meta property="og:description" content="Self-hostable, offline-first note-taking. A Google Keep alternative that respects your privacy.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://crumbs.bretzel.app">
<meta property="og:image" content="https://crumbs.bretzel.app/assets/og-image.png">
<meta name="twitter:card" content="summary_large_image">
<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>
<!-- ── Header ── -->
<header class="site-header">
<div class="container">
<a href="/" class="logo">
<img src="assets/favicon-96x96.png" alt="Crumbs logo" width="32" height="32">
<span>Crumbs</span>
</a>
<button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<nav>
<ul class="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#screenshots">Screenshots</a></li>
<li><a href="#deploy">Deploy</a></li>
<li>
<a href="https://github.com/bretzel-app/crumbs" class="github-link" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
GitHub
</a>
</li>
</ul>
</nav>
</div>
</header>
<!-- ── Hero ── -->
<div class="hero-wrapper">
<!-- Decorative floating note cards -->
<div class="deco-card deco-card-1">Buy groceries<br>- Milk<br>- Eggs</div>
<div class="deco-card deco-card-2">Meeting notes<br>Q3 planning...</div>
<div class="deco-card deco-card-3">Project ideas<br>#startup #saas</div>
<div class="deco-card deco-card-4">Remember to<br>call the vet</div>
<section class="hero">
<div class="container">
<h1 class="animate-in">
Your notes. Your server.<br><span>Your rules.</span>
</h1>
<p class="hero-subtitle animate-in delay-1">
A self-hostable, offline-first note-taking app. The Google Keep alternative that respects your privacy.
</p>
<div class="hero-buttons animate-in delay-2">
<a href="#deploy" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
Get Started
</a>
<a href="https://github.com/bretzel-app/crumbs" class="btn btn-secondary" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
View on GitHub
</a>
</div>
<div class="hero-screenshot animate-in delay-3">
<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>
</div>
<!-- ── Features ── -->
<section class="features" id="features">
<div class="container">
<h2 class="animate-in">Everything you need,<br>nothing you don't</h2>
<div class="features-grid">
<!-- Rich Markdown Editor -->
<div class="feature-card animate-in delay-1">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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>
</div>
<!-- Offline-first PWA -->
<div class="feature-card animate-in delay-2">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h3>Offline-First</h3>
<p>Works without internet. PWA with CRDT-based sync means your notes are always available.</p>
</div>
<!-- Self-hostable -->
<div class="feature-card animate-in delay-3">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<h3>Self-Hostable</h3>
<p>One Docker command. Your data stays on your server. No cloud, no tracking, no nonsense.</p>
</div>
<!-- Tags, colors, pins -->
<div class="feature-card animate-in delay-4">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
</div>
<h3>Tags & Colors</h3>
<p>Organize with tags, 12 note colors, and pinned notes. Your notes, your way.</p>
</div>
<!-- Checklists -->
<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>
</div>
<h3>Checklists</h3>
<p>Built-in task lists with checkbox support. Track to-dos right inside your notes.</p>
</div>
<!-- Full-text search -->
<div class="feature-card animate-in delay-6">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<h3>Full-Text Search</h3>
<p>Find anything instantly. SQLite-powered search across all your notes and tags.</p>
</div>
</div>
</div>
</section>
<!-- ── Screenshots ── -->
<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">
<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">
<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">
<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>
</section>
<!-- ── Deploy ── -->
<section class="deploy" id="deploy">
<div class="container">
<h2 class="animate-in">Deploy in seconds</h2>
<p class="deploy-subtitle animate-in delay-1">One file. One command. Done.</p>
<div class="code-block animate-in delay-2">
<div class="code-header">
<span>docker-compose.yml</span>
<button class="copy-btn" onclick="copyCode()">copy</button>
</div>
<pre><code><span class="code-key">services</span>:
<span class="code-key">crumbs</span>:
<span class="code-key">image</span>: <span class="code-string">ghcr.io/bretzel-app/crumbs:latest</span>
<span class="code-key">ports</span>:
- <span class="code-string">"3000:3000"</span>
<span class="code-key">volumes</span>:
- <span class="code-string">crumbs_data:/data</span>
<span class="code-key">volumes</span>:
<span class="code-key">crumbs_data</span>:</code></pre>
</div>
<div class="deploy-thats-it animate-in delay-3">
<p class="font-pixel">That's it.</p>
<p>Open <code>localhost:3000</code>, set a password, and start writing.</p>
</div>
<div class="deploy-cta animate-in delay-4">
<a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" class="btn btn-secondary" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Full deployment docs
</a>
</div>
</div>
</section>
<!-- ── Footer ── -->
<footer class="site-footer">
<div class="container">
<p class="footer-brand font-pixel">Part of the Bretzel app universe</p>
<ul class="footer-links">
<li><a href="https://github.com/bretzel-app/crumbs" target="_blank" rel="noopener">GitHub</a></li>
<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.6.2</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');
toggle.addEventListener('click', () => {
const open = navLinks.classList.toggle('open');
toggle.setAttribute('aria-expanded', open);
});
// Close nav on link click (mobile)
navLinks.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
navLinks.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
});
});
// Copy docker-compose snippet
function copyCode() {
const code = `services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:latest
ports:
- "3000:3000"
volumes:
- crumbs_data:/data
volumes:
crumbs_data:`;
navigator.clipboard.writeText(code).then(() => {
const btn = document.querySelector('.copy-btn');
btn.textContent = 'copied!';
setTimeout(() => { btn.textContent = 'copy'; }, 2000);
});
}
// Intersection Observer for scroll animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
// Only observe elements that aren't in the hero (hero animates on load)
document.querySelectorAll('.features .animate-in, .screenshots .animate-in, .deploy .animate-in').forEach(el => {
el.style.opacity = '0';
el.classList.remove('animate-in');
observer.observe(el);
});
</script>
</body>
</html>
+724
View File
@@ -0,0 +1,724 @@
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap');
/* ── Design tokens (ported from app) ── */
:root {
--bg-base: #f0e6d3;
--bg-surface: #faf5eb;
--primary: #C8860A;
--primary-hover: #E8A020;
--text: #1a1a2e;
--text-muted: #6b6272;
--border: #1a1a2e;
--border-subtle: #d4cabb;
--destructive: #a63d2f;
--card-shadow: 2px 2px 0px var(--border-subtle);
--card-shadow-hover: 3px 3px 0px var(--primary);
/* Note colors for decorative elements */
--coral: #faafa8;
--peach: #f39f76;
--sand: #fff8b8;
--mint: #e2f6d3;
--sage: #b4ddd3;
--fog: #d4e4ed;
--storm: #aeccdc;
--dusk: #d3bfdb;
--blossom: #f6e2dd;
--clay: #e9e3d4;
--chalk: #efeff1;
}
/* ── Reset ── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base ── */
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
}
body {
font-family: 'JetBrains Mono', monospace;
color: var(--text);
background-color: var(--bg-base);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Pixel grid overlay */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.03;
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
background-size: 4px 4px;
z-index: 0;
}
body > * {
position: relative;
z-index: 1;
}
a {
color: var(--primary);
text-decoration: none;
transition: color 150ms ease-out;
}
a:hover {
color: var(--primary-hover);
}
img {
max-width: 100%;
height: auto;
display: block;
}
/* ── Typography ── */
.font-pixel {
font-family: 'Press Start 2P', cursive;
}
h1, h2, h3 {
font-family: 'Press Start 2P', cursive;
line-height: 1.4;
}
/* ── Layout ── */
.container {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem;
}
section {
padding: 5rem 0;
}
/* ── Header ── */
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-base);
border-bottom: 2px solid var(--border-subtle);
padding: 1rem 0;
}
.site-header .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--primary);
font-family: 'Press Start 2P', cursive;
font-size: 1rem;
}
.logo img {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
list-style: none;
font-size: 0.8rem;
}
.nav-links a {
color: var(--text-muted);
font-weight: 600;
}
.nav-links a:hover {
color: var(--primary);
}
.github-link {
display: flex;
align-items: center;
gap: 0.4rem;
}
.github-link svg {
width: 18px;
height: 18px;
}
/* Mobile nav toggle */
.nav-toggle {
display: none;
background: none;
border: 2px solid var(--border-subtle);
border-radius: 2px;
padding: 0.4rem;
cursor: pointer;
color: var(--text);
}
.nav-toggle svg {
width: 20px;
height: 20px;
display: block;
}
/* ── Hero ── */
.hero {
padding: 6rem 0 4rem;
text-align: center;
}
.hero h1 {
font-size: clamp(1.25rem, 4vw, 2rem);
margin-bottom: 1.5rem;
color: var(--text);
}
.hero h1 span {
color: var(--primary);
}
.hero-subtitle {
font-size: clamp(0.85rem, 2vw, 1rem);
color: var(--text-muted);
max-width: 36rem;
margin: 0 auto 2.5rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 3rem;
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 2px;
border: 2px solid var(--border);
cursor: pointer;
transition: all 150ms ease-out;
text-decoration: none;
}
.btn svg {
width: 16px;
height: 16px;
}
.btn-primary {
background: var(--primary);
color: var(--bg-surface);
border-color: var(--primary);
box-shadow: var(--card-shadow);
}
.btn-primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--bg-surface);
box-shadow: var(--card-shadow-hover);
transform: translate(-1px, -1px);
}
.btn-secondary {
background: var(--bg-surface);
color: var(--text);
box-shadow: var(--card-shadow);
}
.btn-secondary:hover {
border-color: var(--primary);
box-shadow: var(--card-shadow-hover);
transform: translate(-1px, -1px);
}
/* ── Hero screenshot ── */
.hero-screenshot {
max-width: 56rem;
margin: 0 auto;
border: 2px solid var(--border-subtle);
border-radius: 2px;
box-shadow: 4px 4px 0px var(--border-subtle);
overflow: hidden;
background: var(--bg-surface);
}
.screenshot-placeholder {
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-surface), var(--clay));
position: relative;
}
.screenshot-placeholder::after {
content: 'screenshot coming soon';
font-family: 'Press Start 2P', cursive;
font-size: 0.65rem;
color: var(--text-muted);
opacity: 0.5;
}
/* ── Decorative note cards (floating behind hero) ── */
.hero-wrapper {
position: relative;
overflow: hidden;
}
.deco-card {
position: absolute;
border: 2px solid var(--border-subtle);
border-radius: 2px;
box-shadow: var(--card-shadow);
padding: 1rem;
font-size: 0.7rem;
color: var(--text-muted);
opacity: 0.35;
pointer-events: none;
transform: rotate(var(--rotate, 0deg));
width: 10rem;
}
.deco-card-1 {
background: var(--coral);
top: 8%;
left: 3%;
--rotate: -6deg;
}
.deco-card-2 {
background: var(--mint);
top: 20%;
right: 4%;
--rotate: 4deg;
}
.deco-card-3 {
background: var(--sand);
bottom: 15%;
left: 5%;
--rotate: 3deg;
}
.deco-card-4 {
background: var(--fog);
bottom: 10%;
right: 6%;
--rotate: -3deg;
}
/* ── Features grid ── */
.features {
background: var(--bg-surface);
border-top: 2px solid var(--border-subtle);
border-bottom: 2px solid var(--border-subtle);
}
.features h2 {
text-align: center;
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
margin-bottom: 3rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1.5rem;
}
.feature-card {
background: var(--bg-base);
border: 2px solid var(--border-subtle);
border-radius: 2px;
padding: 1.5rem;
box-shadow: var(--card-shadow);
transition: all 150ms ease-out;
}
.feature-card:hover {
border-color: var(--primary);
box-shadow: var(--card-shadow-hover);
transform: translate(-1px, -1px);
}
.feature-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary);
color: var(--bg-surface);
border-radius: 2px;
margin-bottom: 1rem;
box-shadow: 2px 2px 0px var(--border-subtle);
}
.feature-icon svg {
width: 20px;
height: 20px;
}
.feature-card h3 {
font-size: 0.7rem;
margin-bottom: 0.5rem;
}
.feature-card p {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
}
/* ── Screenshots ── */
.screenshots h2 {
text-align: center;
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
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 {
border: 2px solid var(--border-subtle);
border-radius: 2px;
box-shadow: var(--card-shadow);
overflow: hidden;
background: var(--bg-surface);
}
.screenshot-card .screenshot-placeholder {
aspect-ratio: 4 / 3;
}
.screenshot-label {
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
border-top: 2px solid var(--border-subtle);
text-align: center;
}
/* ── Deploy / Quick Start ── */
.deploy {
background: var(--bg-surface);
border-top: 2px solid var(--border-subtle);
border-bottom: 2px solid var(--border-subtle);
}
.deploy h2 {
text-align: center;
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
margin-bottom: 1rem;
}
.deploy-subtitle {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 2.5rem;
}
.code-block {
max-width: 40rem;
margin: 0 auto 2rem;
background: var(--text);
color: var(--bg-surface);
border: 2px solid var(--border);
border-radius: 2px;
box-shadow: 4px 4px 0px var(--border-subtle);
overflow: hidden;
}
.code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.copy-btn {
background: none;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 2px;
padding: 0.25rem 0.5rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
cursor: pointer;
transition: all 150ms ease-out;
}
.copy-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.code-block pre {
padding: 1.25rem;
overflow-x: auto;
font-size: 0.8rem;
line-height: 1.6;
}
.code-block code {
font-family: 'JetBrains Mono', monospace;
}
.code-comment {
color: rgba(255, 255, 255, 0.35);
}
.code-key {
color: var(--primary-hover);
}
.code-string {
color: #b4ddd3;
}
.deploy-thats-it {
text-align: center;
margin-bottom: 2rem;
}
.deploy-thats-it .font-pixel {
font-size: 0.8rem;
color: var(--primary);
margin-bottom: 0.5rem;
}
.deploy-thats-it p {
color: var(--text-muted);
font-size: 0.85rem;
}
.deploy-cta {
text-align: center;
}
/* ── Footer ── */
.site-footer {
padding: 3rem 0;
border-top: 2px solid var(--border-subtle);
text-align: center;
}
.footer-brand {
font-size: 0.6rem;
color: var(--text-muted);
margin-bottom: 1.5rem;
}
.footer-links {
display: flex;
justify-content: center;
gap: 2rem;
list-style: none;
margin-bottom: 1.5rem;
font-size: 0.8rem;
}
.footer-version {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 2px;
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 {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fade-up 400ms ease-out both;
}
.delay-1 { animation-delay: 100ms; }
.delay-2 { animation-delay: 200ms; }
.delay-3 { animation-delay: 300ms; }
.delay-4 { animation-delay: 400ms; }
.delay-5 { animation-delay: 500ms; }
.delay-6 { animation-delay: 600ms; }
/* ── Responsive ── */
@media (max-width: 768px) {
.nav-links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-base);
border-bottom: 2px solid var(--border-subtle);
flex-direction: column;
padding: 1rem 1.5rem;
gap: 1rem;
}
.nav-links.open {
display: flex;
}
.nav-toggle {
display: block;
}
.hero {
padding: 4rem 0 3rem;
}
.deco-card {
display: none;
}
section {
padding: 3.5rem 0;
}
.features-grid {
grid-template-columns: 1fr;
}
.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) {
.hero-buttons {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
justify-content: center;
}
.code-block pre {
font-size: 0.7rem;
padding: 1rem;
}
}