Compare commits

...
37 Commits
Author SHA1 Message Date
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
45 changed files with 1231 additions and 552 deletions
+37
View File
@@ -0,0 +1,37 @@
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
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
run: bash scripts/release.sh ${{ github.event.inputs.bump }}
+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:*)'
+11 -7
View File
@@ -55,10 +55,14 @@ jobs:
with:
generate_release_notes: true
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: release
steps:
- name: Trigger Dokploy redeploy
run: curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
- name: Pin docker-compose image to released version
env:
VERSION: ${{ github.ref_name }}
run: |
VERSION="${VERSION#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 commit -m "chore: pin docker-compose to v${VERSION} [skip ci]"
git push origin HEAD:main
+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
+61 -5
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
+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
+2 -2
View File
@@ -1,6 +1,6 @@
services:
crumbs:
build: .
image: ghcr.io/bretzel-app/crumbs:0.2.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:
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.1.8",
"version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -68,4 +68,4 @@
"esbuild"
]
}
}
}
+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);
+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>Horizontal rule</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}
+36 -19
View File
@@ -1,35 +1,52 @@
<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">
<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,7 +75,7 @@
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>
<p class="font-['Press_Start_2P'] text-xs">
+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">
<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-[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">
<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-[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>
+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()}
-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>
+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);
});
});