Compare commits

...
60 Commits
Author SHA1 Message Date
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
78 changed files with 3104 additions and 1125 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
+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 -2
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.
@@ -62,6 +71,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 +84,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.4.2
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:
+15 -4
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
+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:
+42 -30
View File
@@ -1,8 +1,15 @@
{
"name": "crumbs",
"private": true,
"version": "0.1.8",
"version": "0.4.3",
"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,43 @@
"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",
"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 +73,11 @@
"argon2",
"better-sqlite3",
"esbuild"
]
],
"overrides": {
"serialize-javascript": ">=7.0.3",
"esbuild@<0.25.0": "0.25.12",
"cookie": ">=0.7.0"
}
}
}
}
+300 -531
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
#!/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")
git add package.json
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>
+5 -6
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import Plus from 'lucide-svelte/icons/plus';
interface ChecklistItem {
text: string;
checked: boolean;
@@ -101,9 +104,7 @@
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>
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
</button>
</div>
{/each}
@@ -112,9 +113,7 @@
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>
</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>
+12 -2
View File
@@ -5,9 +5,10 @@
interface Props {
open: boolean;
onClose?: () => void;
}
let { open }: Props = $props();
let { open, onClose }: Props = $props();
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
@@ -20,8 +21,17 @@
}
</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">
+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}
+61 -40
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;
@@ -40,10 +46,28 @@
fn();
};
}
function toggleChecklistItem(index: number) {
const lines = note.content.split('\n');
let checkIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('- [x] ') || line.startsWith('- [ ] ')) {
if (checkIndex === index) {
lines[i] = line.startsWith('- [x] ')
? `- [ ] ${line.slice(6)}`
: `- [x] ${line.slice(6)}`;
break;
}
checkIndex++;
}
}
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)]"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
@@ -52,15 +76,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 checklistItems.slice(0, 8) as item, index}
<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(index); }}
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,9 +118,7 @@
{#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}
@@ -93,9 +132,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 +140,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 +160,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 +169,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 +178,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>
+47 -38
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;
@@ -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,29 @@
<!-- 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"
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
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>
+1 -1
View File
@@ -13,7 +13,7 @@
{#if notes.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}
<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)}
+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>
+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}
+125 -30
View File
@@ -1,9 +1,13 @@
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';
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],
@@ -37,50 +41,141 @@ export const allTags = derived(notes, ($notes) => {
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> {
+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)} />
<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 v{data.appVersion} &mdash; made with 🥨 in Strasbourg
</footer>
<Toast />
</div>
@@ -2,20 +2,38 @@
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 { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded } from '$lib/stores/notes.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);
}
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,9 +44,10 @@
<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>
@@ -56,9 +75,9 @@
onEdit={openEditor}
/>
{#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()}
+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

+4 -4
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,12 +43,12 @@ 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 noteCard(page, 'Tasks').click();
await page.getByTestId('checklist-checkbox').first().check();
await page.getByTestId('close-editor-btn').click();
// Then reopening the note shows the item is still checked
await page.getByTestId('note-card').click();
await noteCard(page, 'Tasks').click();
await expect(page.getByTestId('checklist-checkbox').first()).toBeChecked();
});
+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();
});
});
+9 -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();
+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
+40
View File
@@ -0,0 +1,40 @@
<!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">
<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

+266
View File
@@ -0,0 +1,266 @@
<!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">
<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">
<div class="screenshot-placeholder"></div>
</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>
<div class="screenshots-grid">
<div class="screenshot-card animate-in delay-1">
<div class="screenshot-placeholder"></div>
<div class="screenshot-label">Notes grid view</div>
</div>
<div class="screenshot-card animate-in delay-2">
<div class="screenshot-placeholder"></div>
<div class="screenshot-label">Rich text editor</div>
</div>
<div class="screenshot-card animate-in delay-3">
<div class="screenshot-placeholder"></div>
<div class="screenshot-label">Mobile view</div>
</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.3.3</span>
</div>
</footer>
<script>
// 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>
+654
View File
@@ -0,0 +1,654 @@
@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(--text);
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: 3rem;
}
.screenshots-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1.5rem;
}
.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;
}
/* ── 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;
}
}
@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;
}
}