Compare commits
@@ -0,0 +1,2 @@
|
||||
# Default owner for everything in the repo
|
||||
* @fredicious
|
||||
@@ -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.).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:*)'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
# 🥨 Crumbs by Bretzel
|
||||
[](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://github.com/bretzel-app/crumbs/releases)
|
||||
[](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)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| latest | Yes |
|
||||
| < latest | No |
|
||||
|
||||
We recommend always running the latest version of Crumbs.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via [GitHub's private vulnerability reporting](https://github.com/bretzel-app/crumbs/security/advisories/new).
|
||||
|
||||
Include as much of the following as possible:
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Impact assessment
|
||||
- Suggested fix (if any)
|
||||
|
||||
You should receive an initial response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
|
||||
|
||||
## Scope
|
||||
|
||||
Since Crumbs is a self-hosted application, the following are in scope:
|
||||
|
||||
- Authentication bypass or session hijacking
|
||||
- SQL injection or data leaks
|
||||
- Cross-site scripting (XSS)
|
||||
- Server-side request forgery (SSRF)
|
||||
- Path traversal or file access vulnerabilities
|
||||
- Denial of service via crafted input
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow coordinated disclosure. Once a fix is available, we will:
|
||||
|
||||
1. Release a patched version
|
||||
2. Publish a security advisory on GitHub
|
||||
3. Credit the reporter (unless anonymity is requested)
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:0.4.0
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.1.8",
|
||||
"version": "0.4.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
"homepage": "https://crumbs.bretzel.app",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bretzel-app/crumbs.git"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -22,43 +29,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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} — 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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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} — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
{@render children()}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 144 KiB |
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
crumbs.bretzel.app
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||