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.
|
||||
@@ -18,6 +18,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -34,4 +36,87 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Run release script
|
||||
run: bash scripts/release.sh ${{ github.event.inputs.bump }}
|
||||
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
|
||||
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-check, unit-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -86,16 +85,17 @@ jobs:
|
||||
CI: true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 30
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-check, unit-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -54,3 +54,28 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- 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
|
||||
@@ -37,3 +37,4 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Local notes
|
||||
todo.md
|
||||
.worktrees/
|
||||
|
||||
@@ -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
|
||||
@@ -124,6 +124,12 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
| `make release-minor` | — | Bump minor version, tag, and push |
|
||||
| `make release-major` | — | Bump major version, tag, and push |
|
||||
|
||||
## Git conventions
|
||||
|
||||
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
||||
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
||||
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
||||
|
||||
## CI/CD
|
||||
|
||||
GitHub Actions with two workflows:
|
||||
@@ -135,10 +141,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
|
||||
@@ -188,3 +198,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,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.
|
||||
|
||||
@@ -32,7 +41,6 @@ pnpm build # Production build
|
||||
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
|
||||
- Organize with #tags, pinning, archive, and trash
|
||||
- Full-text search across titles, content, and tags
|
||||
- Dark mode with system detection
|
||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||
- Single-user password auth (Argon2)
|
||||
- Docker deployment with a single command
|
||||
@@ -62,6 +70,9 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
|
||||
| [API](docs/API.md) | REST API reference (auto-generated) |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute |
|
||||
| [Security](SECURITY.md) | Vulnerability reporting policy |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -72,4 +83,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -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:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
image: ghcr.io/bretzel-app/crumbs:0.6.2
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -49,6 +49,14 @@ For a single-user app with multiple devices:
|
||||
- If timestamps match: higher version wins
|
||||
- If both match: local version is preferred
|
||||
|
||||
### Attachment Data Flow
|
||||
1. User drops/picks image in NoteEditor
|
||||
2. Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
|
||||
3. **Online**: POST optimized + thumbnail to `/api/notes/{id}/attachments` → server saves to `data/attachments/` + DB
|
||||
4. **Offline**: store blobs in IDB `pendingAttachments` store → display via `URL.createObjectURL()` → on reconnect, sync pushes to server
|
||||
5. **Display**: NoteCard uses `&thumb=1` URL (200px WebP), editor shows full-size
|
||||
6. **SW Caching**: `CacheFirst` rule caches attachment URLs — previously-viewed images available offline
|
||||
|
||||
## Tech Stack Rationale
|
||||
|
||||
### SvelteKit
|
||||
@@ -89,7 +97,7 @@ Key tables:
|
||||
- `notes` - Core note data with version tracking
|
||||
- `tags` - Unique tag names
|
||||
- `note_tags` - Many-to-many note ↔ tag
|
||||
- `attachments` - Image file metadata
|
||||
- `attachments` - Image file metadata (path + thumbnailPath)
|
||||
- `sync_log` - Sync operation history
|
||||
|
||||
## File Organization
|
||||
|
||||
@@ -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
|
||||
@@ -31,7 +42,13 @@
|
||||
- Upload images via file picker or drag-and-drop
|
||||
- Supports JPEG, PNG, GIF, WebP, SVG
|
||||
- Max file size: 10MB
|
||||
- Thumbnail previews in note cards
|
||||
- **Client-side optimization**: images resized to fit 1920×1920, compressed to WebP (~80% quality), transparent PNGs preserved
|
||||
- **Auto-generated thumbnails**: 200×200 WebP at 60% quality for fast card previews
|
||||
- Thumbnail strip on note cards (up to 3 images, "+N" badge for more)
|
||||
- Full-size images in editor, thumbnails on cards (`&thumb=1`)
|
||||
- **Offline support**: optimized blobs stored in IndexedDB, displayed via blob URLs, auto-synced on reconnect
|
||||
- Pending upload indicator on offline-queued images
|
||||
- Service worker CacheFirst caching for previously-viewed images
|
||||
- Remove attachments from editor
|
||||
|
||||
### Tag Organization
|
||||
@@ -45,7 +62,7 @@
|
||||
12 colors matching Google Keep's palette:
|
||||
- Default, Coral, Peach, Sand, Mint, Sage
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to light/dark mode
|
||||
- Colors adapt to note theme
|
||||
|
||||
### Pin Notes
|
||||
- Pin important notes to always appear at top
|
||||
@@ -69,13 +86,6 @@
|
||||
- Clear search to restore original view
|
||||
- Case-insensitive matching
|
||||
|
||||
### Dark Mode
|
||||
- System preference detection (auto)
|
||||
- Manual toggle override
|
||||
- Preference persisted in localStorage
|
||||
- All components adapt to theme
|
||||
- Note colors adjust for dark backgrounds
|
||||
|
||||
### Single-User Auth
|
||||
- First-run setup: create password (min 8 chars)
|
||||
- Argon2 password hashing (industry standard)
|
||||
@@ -86,9 +96,9 @@
|
||||
### PWA / Offline-First
|
||||
- Installable as standalone app
|
||||
- Web app manifest with icons
|
||||
- Service worker for asset caching
|
||||
- IndexedDB for local data storage
|
||||
- Background sync every 30 seconds
|
||||
- Service worker for asset caching (CacheFirst for attachments, NetworkFirst for API)
|
||||
- IndexedDB for local data storage (notes + pending attachments)
|
||||
- Background sync every 30 seconds (notes + offline attachments)
|
||||
- LWW conflict resolution for multi-device use
|
||||
- Sync status indicator (synced/syncing/offline/error)
|
||||
|
||||
|
||||
@@ -185,9 +185,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: thumb
|
||||
in: query
|
||||
description: Set to "1" to return the thumbnail instead of the full image (requires attachmentId)
|
||||
schema:
|
||||
type: string
|
||||
enum: ['1']
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment list 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]
|
||||
@@ -203,6 +215,10 @@ paths:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file (max 10MB)
|
||||
thumbnail:
|
||||
type: string
|
||||
format: binary
|
||||
description: Pre-generated thumbnail (WebP, ~200px)
|
||||
responses:
|
||||
'201':
|
||||
description: Attachment metadata
|
||||
@@ -306,6 +322,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
'400':
|
||||
description: Invalid changes payload
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
@@ -340,6 +358,10 @@ components:
|
||||
type: boolean
|
||||
trashed:
|
||||
type: boolean
|
||||
trashedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
checklistMode:
|
||||
type: boolean
|
||||
sortOrder:
|
||||
@@ -356,10 +378,18 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attachment'
|
||||
|
||||
NoteInput:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Optional — generated if omitted
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
@@ -399,6 +429,10 @@ components:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
thumbnailPath:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Server path to thumbnail file (null if no thumbnail)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE `attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`thumbnail_path` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `note_tags` (
|
||||
`note_id` text NOT NULL,
|
||||
`tag_id` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_tags_note_id_idx` ON `note_tags` (`note_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_tags_tag_id_idx` ON `note_tags` (`tag_id`);--> statement-breakpoint
|
||||
CREATE TABLE `notes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`title` text DEFAULT '' NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`color` text DEFAULT 'default' NOT NULL,
|
||||
`pinned` integer DEFAULT false NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`trashed` integer DEFAULT false NOT NULL,
|
||||
`trashed_at` integer,
|
||||
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
|
||||
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sync_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
|
||||
CREATE TABLE `tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,465 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "682b02aa-a28d-4904-8996-cbc4d740f626",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"attachments": {
|
||||
"name": "attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"attachments_note_id_notes_id_fk": {
|
||||
"name": "attachments_note_id_notes_id_fk",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_tags": {
|
||||
"name": "note_tags",
|
||||
"columns": {
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"note_tags_note_id_idx": {
|
||||
"name": "note_tags_note_id_idx",
|
||||
"columns": [
|
||||
"note_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"note_tags_tag_id_idx": {
|
||||
"name": "note_tags_tag_id_idx",
|
||||
"columns": [
|
||||
"tag_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"note_tags_note_id_notes_id_fk": {
|
||||
"name": "note_tags_note_id_notes_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"note_tags_tag_id_tags_id_fk": {
|
||||
"name": "note_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"pinned": {
|
||||
"name": "pinned",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed": {
|
||||
"name": "trashed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed_at": {
|
||||
"name": "trashed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checklist_mode": {
|
||||
"name": "checklist_mode",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notes_trashed_archived_idx": {
|
||||
"name": "notes_trashed_archived_idx",
|
||||
"columns": [
|
||||
"trashed",
|
||||
"archived"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"notes_updated_at_idx": {
|
||||
"name": "notes_updated_at_idx",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sync_log": {
|
||||
"name": "sync_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation": {
|
||||
"name": "operation",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sync_log_timestamp_idx": {
|
||||
"name": "sync_log_timestamp_idx",
|
||||
"columns": [
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sync_log_note_id_notes_id_fk": {
|
||||
"name": "sync_log_note_id_notes_id_fk",
|
||||
"tableFrom": "sync_log",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1773000424830,
|
||||
"tag": "0000_wooden_vargas",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
"homepage": "https://crumbs.bretzel.app",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bretzel-app/crumbs.git"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -22,43 +29,44 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@tiptap/core": "^3.20.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-task-item": "^3.20.0",
|
||||
"@tiptap/extension-task-list": "^3.20.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/pm": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"argon2": "^0.43.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"idb": "^8.0.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -66,6 +74,11 @@
|
||||
"argon2",
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,15 @@ echo "Bumping $BUMP version..."
|
||||
npm version "$BUMP" -m "chore: bump version to %s" --no-git-tag-version
|
||||
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
git add package.json
|
||||
|
||||
# Update version in marketing website
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
sed -i '' "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
|
||||
else
|
||||
sed -i "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
|
||||
fi
|
||||
|
||||
git add package.json website/index.html
|
||||
git commit -m "chore: bump version to $NEW_VERSION"
|
||||
git tag "v$NEW_VERSION"
|
||||
|
||||
|
||||
@@ -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,8 +1,13 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
@@ -15,14 +20,24 @@
|
||||
let { content, onChange }: Props = $props();
|
||||
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
|
||||
let activeItems = $derived(items.filter((i) => !i.checked));
|
||||
let doneItems = $derived(items.filter((i) => i.checked));
|
||||
let doneCount = $derived(doneItems.length);
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ text: '', checked: false }];
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
|
||||
return { text: line, checked: false };
|
||||
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
||||
return { id: generateId(), text: line, checked: false };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,78 +51,107 @@
|
||||
onChange(serializeChecklist(items));
|
||||
}
|
||||
|
||||
function toggleItem(index: number) {
|
||||
items[index].checked = !items[index].checked;
|
||||
emitChange();
|
||||
function toggleItem(id: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.checked = !item.checked;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateText(index: number, text: string) {
|
||||
items[index].text = text;
|
||||
emitChange();
|
||||
function updateText(id: string, text: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = text;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function addItem(afterIndex: number) {
|
||||
items.splice(afterIndex + 1, 0, { text: '', checked: false });
|
||||
const newItem = { id: generateId(), text: '', checked: false };
|
||||
items.splice(afterIndex + 1, 0, newItem);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
// Focus new input on next tick
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[afterIndex + 1]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
function removeItem(id: string) {
|
||||
if (items.length <= 1) return;
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
||||
function handleKeydown(e: KeyboardEvent, id: string) {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem(index);
|
||||
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
||||
e.preventDefault();
|
||||
removeItem(index);
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
// Merge reordered active items with done items
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
emitChange();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1" data-testid="checklist">
|
||||
{#each items as item, index}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(index)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, index)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeItem(index)}
|
||||
class="opacity-0 hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<section
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-1"
|
||||
>
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<button
|
||||
onclick={() => addItem(items.length - 1)}
|
||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
@@ -116,4 +160,42 @@
|
||||
<Plus class="h-4 w-4" />
|
||||
Add item
|
||||
</button>
|
||||
|
||||
{#if doneCount > 0}
|
||||
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
|
||||
<button
|
||||
onclick={() => (doneExpanded = !doneExpanded)}
|
||||
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-toggle-done"
|
||||
>
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
{doneCount} done
|
||||
</button>
|
||||
|
||||
{#if doneExpanded}
|
||||
<div class="mt-1 space-y-1 pl-5" data-testid="checklist-done-section">
|
||||
{#each doneItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Horizontal rule</span>
|
||||
<span>Divider</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { src, alt, onClose }: Props = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackgroundClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={handleBackgroundClick}
|
||||
data-testid="image-lightbox"
|
||||
>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="absolute right-4 top-4 rounded-sm p-2 text-white hover:bg-white/10"
|
||||
aria-label="Close lightbox"
|
||||
data-testid="lightbox-close"
|
||||
>
|
||||
<XIcon class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain rounded-sm animate-[pop-in_150ms_ease-out]"
|
||||
data-testid="lightbox-image"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,28 +1,81 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||
import { browser } from '$app/environment';
|
||||
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
attachments: Attachment[];
|
||||
onUpload: (attachment: Attachment) => void;
|
||||
onRemove: (attachmentId: string) => void;
|
||||
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
|
||||
}
|
||||
|
||||
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured }: Props = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let optimizing = $state(false);
|
||||
let dragOver = $state(false);
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || !noteId) return;
|
||||
|
||||
uploading = true;
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
|
||||
optimizing = true;
|
||||
let optimized: Blob;
|
||||
let thumbnail: Blob;
|
||||
try {
|
||||
const result = await optimizeImage(file);
|
||||
optimized = result.optimized;
|
||||
thumbnail = result.thumbnail;
|
||||
} catch (err) {
|
||||
console.error('Optimization failed, using original:', err);
|
||||
optimized = file;
|
||||
thumbnail = file;
|
||||
}
|
||||
optimizing = false;
|
||||
|
||||
// Offline: store in IDB and display via blob URL
|
||||
if (browser && !navigator.onLine) {
|
||||
const tempId = crypto.randomUUID();
|
||||
const pending: PendingAttachment = {
|
||||
id: tempId,
|
||||
noteId,
|
||||
optimized,
|
||||
thumbnail,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
await addPendingAttachment(pending);
|
||||
|
||||
const blobUrl = URL.createObjectURL(optimized);
|
||||
onUpload({
|
||||
id: tempId,
|
||||
noteId,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
size: optimized.size,
|
||||
path: blobUrl,
|
||||
pending: true,
|
||||
createdAt: new Date()
|
||||
} as Attachment & { pending: boolean });
|
||||
continue;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
|
||||
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
||||
@@ -36,8 +89,8 @@
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
@@ -57,16 +110,34 @@
|
||||
{#if attachments.length > 0}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each attachments as attachment}
|
||||
{@const isPending = 'pending' in attachment && attachment.pending}
|
||||
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
||||
<div class="group relative">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 rounded-sm object-cover"
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
|
||||
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
</div>
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
|
||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 {attachment.featured ? 'block' : 'hidden group-hover:block'}"
|
||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||
data-testid="toggle-featured"
|
||||
>
|
||||
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
@@ -87,7 +158,9 @@
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if uploading}
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
@@ -104,3 +177,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -6,18 +6,27 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
let { open, onClose, appVersion }: Props = $props();
|
||||
|
||||
function closeMobile() {
|
||||
if (window.matchMedia('(max-width: 1023px)').matches) {
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
loadNotes(filter);
|
||||
closeMobile();
|
||||
}
|
||||
|
||||
function selectTag(tag: string | null) {
|
||||
selectedTag.set(tag);
|
||||
loadNotes('all');
|
||||
closeMobile();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -83,4 +92,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
{#if appVersion}
|
||||
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)] px-6 py-3 text-right text-xs text-[var(--text-muted)]">
|
||||
v{appVersion}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
@@ -18,9 +19,10 @@
|
||||
interface Props {
|
||||
note: Note;
|
||||
onEdit: (note: Note) => void;
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
let { note, onEdit }: Props = $props();
|
||||
let { note, onEdit, fullHeight = false }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[note.color];
|
||||
@@ -28,6 +30,8 @@
|
||||
});
|
||||
|
||||
let cardStyle = $state('');
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
const renderedContent = $derived(renderMarkdown(note.content));
|
||||
|
||||
@@ -40,6 +44,12 @@
|
||||
: []
|
||||
);
|
||||
|
||||
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
|
||||
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
|
||||
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
|
||||
|
||||
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
|
||||
|
||||
function stop(fn: () => void) {
|
||||
return (e: Event) => {
|
||||
e.stopPropagation();
|
||||
@@ -47,19 +57,16 @@
|
||||
};
|
||||
}
|
||||
|
||||
function toggleChecklistItem(index: number) {
|
||||
function toggleChecklistItem(item: ChecklistItem) {
|
||||
const lines = note.content.split('\n');
|
||||
let checkIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith('- [x] ') || line.startsWith('- [ ] ')) {
|
||||
if (checkIndex === index) {
|
||||
lines[i] = line.startsWith('- [x] ')
|
||||
? `- [ ] ${line.slice(6)}`
|
||||
: `- [x] ${line.slice(6)}`;
|
||||
break;
|
||||
}
|
||||
checkIndex++;
|
||||
const text = line.replace(/^- \[[ x]\] /, '');
|
||||
if (text === item.text && line.startsWith(item.checked ? '- [x] ' : '- [ ] ')) {
|
||||
lines[i] = item.checked
|
||||
? `- [ ] ${text}`
|
||||
: `- [x] ${text}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updateNote(note.id, { content: lines.join('\n') });
|
||||
@@ -67,7 +74,7 @@
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)]"
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
||||
style={cardStyle}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
@@ -76,6 +83,30 @@
|
||||
data-testid="note-card"
|
||||
data-note-id={note.id}
|
||||
>
|
||||
<!-- Thumbnail strip (featured images only) -->
|
||||
{#if featuredAttachments.length > 0}
|
||||
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
|
||||
{#each featuredAttachments.slice(0, 3) as attachment}
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||
alt={attachment.filename}
|
||||
class="h-24 w-full cursor-pointer object-cover"
|
||||
loading="lazy"
|
||||
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="card-thumbnail"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if featuredAttachments.length > 3}
|
||||
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
|
||||
+{featuredAttachments.length - 3}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
@@ -93,12 +124,12 @@
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-1" data-testid="note-checklist-preview">
|
||||
{#each checklistItems.slice(0, 8) as item, index}
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(index); }}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
||||
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
@@ -124,7 +155,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons - show on hover -->
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
@@ -183,6 +214,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor } from '$lib/types/index.js';
|
||||
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
@@ -30,10 +33,68 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let rawMarkdownMode = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
// Mutable note identity — allows transitioning from new → saved without closing
|
||||
// svelte-ignore state_referenced_locally
|
||||
let noteId = $state<string | null>(note?.id ?? null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentlyNew = $state(isNew);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
fetch(`/api/notes/${noteId}/attachments`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: Attachment[]) => {
|
||||
if (data.length > 0) attachmentsList = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
function handleAttachmentUpload(attachment: Attachment) {
|
||||
attachmentsList = [...attachmentsList, attachment];
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||
if (!noteId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
a.id === attachmentId ? { ...a, featured } : a
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachmentRemove(attachmentId: string) {
|
||||
if (!noteId) return;
|
||||
try {
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let bgStyle = $state('');
|
||||
$effect(() => {
|
||||
@@ -41,23 +102,48 @@
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
async function saveAndClose() {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (note) {
|
||||
await updateNote(note.id, { title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
if (!id) return;
|
||||
}
|
||||
showImageUpload = !showImageUpload;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
save();
|
||||
saveAndClose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +163,7 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={save}
|
||||
onclick={saveAndClose}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
@@ -124,6 +210,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#if noteId && showImageUpload}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
attachments={attachmentsList}
|
||||
onUpload={handleAttachmentUpload}
|
||||
onRemove={handleAttachmentRemove}
|
||||
onToggleFeatured={handleToggleFeatured}
|
||||
/>
|
||||
</div>
|
||||
{:else if noteId && attachmentsList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each attachmentsList as attachment}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover"
|
||||
onclick={() => { lightboxSrc = `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
@@ -163,11 +281,22 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Image attachments"
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
@@ -179,7 +308,7 @@
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={save}
|
||||
onclick={saveAndClose}
|
||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
<script lang="ts">
|
||||
import NoteCard from './NoteCard.svelte';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
label?: string;
|
||||
onEdit: (note: Note) => void;
|
||||
draggable?: boolean;
|
||||
dndType?: string;
|
||||
onReorder?: (noteIds: string[]) => void;
|
||||
}
|
||||
|
||||
let { notes, label = '', onEdit }: Props = $props();
|
||||
let { notes, label = '', onEdit, draggable = false, dndType = 'notes', onReorder }: Props = $props();
|
||||
|
||||
let localItems = $state<Note[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
localItems = [...notes];
|
||||
});
|
||||
|
||||
const flipDurationMs = 150;
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
}
|
||||
|
||||
function handleFinalize(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
onReorder?.(localItems.map((n) => n.id));
|
||||
}
|
||||
|
||||
let displayItems = $derived(draggable ? localItems : notes);
|
||||
</script>
|
||||
|
||||
{#if notes.length > 0}
|
||||
{#if displayItems.length > 0}
|
||||
{#if label}
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each notes as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if draggable}
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
data-testid="note-grid"
|
||||
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each localItems as note (note.id)}
|
||||
<div class="relative h-full" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div
|
||||
use:dragHandle
|
||||
aria-label="drag handle for {note.title || 'note'}"
|
||||
class="absolute top-2 right-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
data-testid="note-drag-handle"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<NoteCard {note} {onEdit} fullHeight />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each displayItems as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
|
||||
import Hand from 'lucide-svelte/icons/hand';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
|
||||
{ value: 'updated', label: 'Updated', icon: Clock },
|
||||
{ value: 'created', label: 'Created', icon: CalendarPlus },
|
||||
{ value: 'custom', label: 'Custom', icon: Hand }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
|
||||
{#each modes as mode}
|
||||
<button
|
||||
onclick={() => sortMode.set(mode.value)}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
|
||||
{$sortMode === mode.value
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
|
||||
data-testid="sort-{mode.value}"
|
||||
aria-label="Sort by {mode.label}"
|
||||
>
|
||||
<mode.icon class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -4,7 +4,8 @@ import { join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from './db/index.js';
|
||||
import { attachments } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
||||
@@ -16,7 +17,8 @@ if (!existsSync(ATTACHMENTS_DIR)) {
|
||||
|
||||
export async function saveAttachment(
|
||||
noteId: string,
|
||||
file: File
|
||||
file: File,
|
||||
thumbnail?: File | Blob | null
|
||||
): Promise<typeof attachments.$inferSelect> {
|
||||
const id = uuidv4();
|
||||
const ext = file.name.split('.').pop() || 'bin';
|
||||
@@ -26,6 +28,14 @@ export async function saveAttachment(
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
let thumbnailPath: string | null = null;
|
||||
if (thumbnail) {
|
||||
const thumbFilename = `${id}_thumb.webp`;
|
||||
thumbnailPath = join(ATTACHMENTS_DIR, thumbFilename);
|
||||
const thumbBuffer = Buffer.from(await thumbnail.arrayBuffer());
|
||||
await writeFile(thumbnailPath, thumbBuffer);
|
||||
}
|
||||
|
||||
const [attachment] = await db
|
||||
.insert(attachments)
|
||||
.values({
|
||||
@@ -35,6 +45,7 @@ export async function saveAttachment(
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
path: filePath,
|
||||
thumbnailPath,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning();
|
||||
@@ -42,6 +53,31 @@ export async function saveAttachment(
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch attachments for multiple notes in a single query.
|
||||
* Returns a Map of noteId → Attachment[].
|
||||
*/
|
||||
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(inArray(attachments.noteId, noteIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, Attachment[]>();
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.noteId);
|
||||
if (existing) {
|
||||
existing.push(row as Attachment);
|
||||
} else {
|
||||
map.set(row.noteId, [row as Attachment]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
||||
}
|
||||
@@ -50,6 +86,15 @@ export async function getAttachmentsByNote(noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function updateAttachment(id: string, data: { featured: boolean }) {
|
||||
const [updated] = await db
|
||||
.update(attachments)
|
||||
.set({ featured: data.featured })
|
||||
.where(eq(attachments.id, id))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
const attachment = await getAttachment(id);
|
||||
if (attachment) {
|
||||
@@ -58,6 +103,13 @@ export async function deleteAttachment(id: string) {
|
||||
} catch {
|
||||
// File may already be deleted
|
||||
}
|
||||
if (attachment.thumbnailPath) {
|
||||
try {
|
||||
await unlink(attachment.thumbnailPath);
|
||||
} catch {
|
||||
// Thumbnail may already be deleted
|
||||
}
|
||||
}
|
||||
await db.delete(attachments).where(eq(attachments.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ sqlite.exec(`
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -84,5 +85,12 @@ sqlite.exec(`
|
||||
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
||||
`);
|
||||
|
||||
// Idempotent migration: add featured column to attachments
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE attachments ADD COLUMN featured INTEGER NOT NULL DEFAULT 0;`);
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { sqlite };
|
||||
|
||||
@@ -71,6 +71,8 @@ export const attachments = sqliteTable('attachments', {
|
||||
mimeType: text('mime_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
path: text('path').notNull(),
|
||||
thumbnailPath: text('thumbnail_path'),
|
||||
featured: integer('featured', { mode: 'boolean' }).default(false).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.
|
||||
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
@@ -27,17 +28,32 @@ export const filteredNotes = derived(
|
||||
}
|
||||
);
|
||||
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => n.pinned)
|
||||
function sortNotes(list: Note[], mode: SortMode): Note[] {
|
||||
return [...list].sort((a, b) => {
|
||||
if (mode === 'created') {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
if (mode === 'custom') {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
// default: updated
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export const pinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => n.pinned), $sortMode)
|
||||
);
|
||||
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => !n.pinned)
|
||||
export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned), $sortMode)
|
||||
);
|
||||
|
||||
export const allTags = derived(notes, ($notes) => {
|
||||
const tagSet = new Set<string>();
|
||||
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
$notes
|
||||
.filter((n) => !n.archived && !n.trashed)
|
||||
.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
@@ -197,3 +213,25 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
||||
// Optimistic update
|
||||
notes.update((list) =>
|
||||
list.map((n) => {
|
||||
const order = orders.find((o) => o.id === n.id);
|
||||
return order ? { ...n, sortOrder: order.sortOrder } : n;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/notes/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
// Offline — optimistic update stands
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type SortMode = 'updated' | 'created' | 'custom';
|
||||
|
||||
const STORAGE_KEY = 'crumbs-sort-mode';
|
||||
|
||||
function getInitial(): SortMode {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'updated' || stored === 'created' || stored === 'custom') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
export const sortMode = writable<SortMode>(getInitial());
|
||||
|
||||
if (browser) {
|
||||
sortMode.subscribe((value) => {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta, getPendingAttachments, removePendingAttachment } from './idb.js';
|
||||
import { mergeNotes } from './crdt.js';
|
||||
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -49,6 +49,31 @@ async function pushChanges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push pending offline attachments to server.
|
||||
*/
|
||||
async function pushPendingAttachments(): Promise<void> {
|
||||
const pending = await getPendingAttachments();
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([item.optimized], item.filename, { type: item.mimeType }));
|
||||
formData.append('thumbnail', new File([item.thumbnail], `${item.filename}_thumb.webp`, { type: 'image/webp' }));
|
||||
|
||||
const res = await fetch(`/api/notes/${item.noteId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await removePendingAttachment(item.id);
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next sync cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull remote changes and merge with local state.
|
||||
*/
|
||||
@@ -98,6 +123,7 @@ export async function sync(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
await pushPendingAttachments();
|
||||
await pullChanges();
|
||||
setStatus('synced');
|
||||
|
||||
|
||||
@@ -2,7 +2,17 @@ import { openDB, type IDBPDatabase } from 'idb';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
const DB_NAME = 'crumbs';
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
noteId: string;
|
||||
optimized: Blob;
|
||||
thumbnail: Blob;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CrumbsDB {
|
||||
notes: {
|
||||
@@ -23,6 +33,13 @@ interface CrumbsDB {
|
||||
key: string;
|
||||
value: { key: string; value: string };
|
||||
};
|
||||
pendingAttachments: {
|
||||
key: string;
|
||||
value: PendingAttachment;
|
||||
indexes: {
|
||||
'by-noteId': string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
@@ -58,6 +75,12 @@ export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
db.createObjectStore('meta', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
// Pending attachments (offline uploads)
|
||||
if (!db.objectStoreNames.contains('pendingAttachments')) {
|
||||
const pendingStore = db.createObjectStore('pendingAttachments', { keyPath: 'id' });
|
||||
pendingStore.createIndex('by-noteId', 'noteId');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -122,3 +145,22 @@ export async function setMeta(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('meta', { key, value });
|
||||
}
|
||||
|
||||
// Pending attachment operations
|
||||
export async function addPendingAttachment(item: PendingAttachment): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('pendingAttachments', item);
|
||||
}
|
||||
|
||||
export async function getPendingAttachments(noteId?: string): Promise<PendingAttachment[]> {
|
||||
const db = await getDb();
|
||||
if (noteId) {
|
||||
return db.getAllFromIndex('pendingAttachments', 'by-noteId', noteId);
|
||||
}
|
||||
return db.getAll('pendingAttachments');
|
||||
}
|
||||
|
||||
export async function removePendingAttachment(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('pendingAttachments', id);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface Attachment {
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
thumbnailPath?: string | null;
|
||||
featured: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const MAX_DIMENSION = 1920;
|
||||
const THUMB_SIZE = 200;
|
||||
const QUALITY = 0.8;
|
||||
const THUMB_QUALITY = 0.6;
|
||||
const SKIP_THRESHOLD = 200 * 1024; // 200KB
|
||||
|
||||
interface OptimizeResult {
|
||||
optimized: Blob;
|
||||
thumbnail: Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PNG has transparency by examining the alpha channel.
|
||||
* Reads a sample of pixels to determine if any are non-opaque.
|
||||
*/
|
||||
function hasTransparency(canvas: HTMLCanvasElement | OffscreenCanvas, ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
// Check every 4th value (alpha channel) with stride for performance
|
||||
const stride = Math.max(1, Math.floor(data.length / (4 * 10000))) * 4;
|
||||
for (let i = 3; i < data.length; i += stride) {
|
||||
if (data[i] < 255) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createCanvas(width: number, height: number): { canvas: HTMLCanvasElement | OffscreenCanvas; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D } {
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas, type: string, quality: number): Promise<Blob> {
|
||||
if (canvas instanceof OffscreenCanvas) {
|
||||
return canvas.convertToBlob({ type, quality });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob!), type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fitDimensions(width: number, height: number, maxDim: number): { width: number; height: number } {
|
||||
if (width <= maxDim && height <= maxDim) return { width, height };
|
||||
const ratio = Math.min(maxDim / width, maxDim / height);
|
||||
return {
|
||||
width: Math.round(width * ratio),
|
||||
height: Math.round(height * ratio)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize an image file for upload:
|
||||
* - Resize to fit within 1920x1920 (preserve aspect ratio)
|
||||
* - Skip optimization if already < 200KB and within size limits
|
||||
* - Compress JPEG/WebP to ~80% quality
|
||||
* - Convert non-transparent PNG/BMP to WebP; keep transparent PNG as PNG
|
||||
* - Pass GIF/SVG through unmodified
|
||||
* - Generate a 200x200 thumbnail at 60% quality WebP
|
||||
*/
|
||||
export async function optimizeImage(file: File): Promise<OptimizeResult> {
|
||||
const type = file.type;
|
||||
|
||||
// GIF and SVG pass through unmodified (but still generate thumbnail for GIF)
|
||||
if (type === 'image/svg+xml') {
|
||||
return { optimized: file, thumbnail: file };
|
||||
}
|
||||
|
||||
const img = await loadImage(file);
|
||||
const { width, height } = img;
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbDims = fitDimensions(width, height, THUMB_SIZE);
|
||||
const { canvas: thumbCanvas, ctx: thumbCtx } = createCanvas(thumbDims.width, thumbDims.height);
|
||||
thumbCtx.drawImage(img, 0, 0, thumbDims.width, thumbDims.height);
|
||||
const thumbnail = await canvasToBlob(thumbCanvas, 'image/webp', THUMB_QUALITY);
|
||||
|
||||
// GIF: pass original through but use generated thumbnail
|
||||
if (type === 'image/gif') {
|
||||
return { optimized: file, thumbnail };
|
||||
}
|
||||
|
||||
// Check if optimization can be skipped
|
||||
const fittedDims = fitDimensions(width, height, MAX_DIMENSION);
|
||||
const needsResize = fittedDims.width !== width || fittedDims.height !== height;
|
||||
|
||||
if (!needsResize && file.size < SKIP_THRESHOLD) {
|
||||
return { optimized: file, thumbnail };
|
||||
}
|
||||
|
||||
// Draw at target size
|
||||
const { canvas, ctx } = createCanvas(fittedDims.width, fittedDims.height);
|
||||
ctx.drawImage(img, 0, 0, fittedDims.width, fittedDims.height);
|
||||
|
||||
// Determine output format
|
||||
let outputType = 'image/webp';
|
||||
let quality = QUALITY;
|
||||
|
||||
if (type === 'image/png' && hasTransparency(canvas, ctx, fittedDims.width, fittedDims.height)) {
|
||||
outputType = 'image/png';
|
||||
quality = 1; // PNG is lossless, quality param ignored but set for clarity
|
||||
}
|
||||
|
||||
const optimized = await canvasToBlob(canvas, outputType, quality);
|
||||
return { optimized, thumbnail };
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<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)} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
|
||||
|
||||
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
@@ -31,7 +31,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
Crumbs by Bretzel v{data.appVersion} — made with 🥨 in Strasbourg
|
||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
|
||||
<Toast />
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded } from '$lib/stores/notes.js';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
@@ -22,6 +24,11 @@
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
@@ -53,9 +60,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{#if $currentFilter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentFilter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
@@ -65,7 +79,7 @@
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} />
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -73,11 +87,14 @@
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<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
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="text-5xl">🥨</span>
|
||||
<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>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="text-5xl">🥨</span>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq, and, desc } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const filter = url.searchParams.get('filter') || 'all';
|
||||
@@ -29,10 +30,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||
.all();
|
||||
|
||||
const tagMap = fetchTagsForNotes(result.map((n) => n.id));
|
||||
const noteIds = result.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const notesWithTags = result.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(notesWithTags);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
@@ -14,8 +15,13 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([note.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([note.id]);
|
||||
|
||||
return json({ ...note, tags: tagMap.get(note.id) ?? [] });
|
||||
return json({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
@@ -55,8 +61,13 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([params.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([params.id]);
|
||||
|
||||
return json({ ...updated, tags: tagMap.get(params.id) ?? [] });
|
||||
return json({
|
||||
...updated,
|
||||
tags: tagMap.get(params.id) ?? [],
|
||||
attachments: attachmentMap.get(params.id) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment } from '$lib/server/attachments.js';
|
||||
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
@@ -10,11 +10,16 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const attachment = await getAttachment(attachmentId);
|
||||
if (!attachment) throw error(404, 'Attachment not found');
|
||||
|
||||
const buffer = await readFile(attachment.path);
|
||||
const wantThumb = url.searchParams.get('thumb') === '1';
|
||||
const filePath = wantThumb && attachment.thumbnailPath ? attachment.thumbnailPath : attachment.path;
|
||||
const mimeType = wantThumb && attachment.thumbnailPath ? 'image/webp' : attachment.mimeType;
|
||||
|
||||
const buffer = await readFile(filePath);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`
|
||||
'Content-Type': mimeType,
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -40,10 +45,24 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
throw error(400, 'File too large (max 10MB)');
|
||||
}
|
||||
|
||||
const attachment = await saveAttachment(params.id, file);
|
||||
const thumbnail = formData.get('thumbnail') as File | null;
|
||||
const attachment = await saveAttachment(params.id, file, thumbnail);
|
||||
return json(attachment, { status: 201 });
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ url, request }) => {
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
const body = await request.json();
|
||||
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
|
||||
|
||||
const updated = await updateAttachment(attachmentId, { featured: body.featured });
|
||||
if (!updated) throw error(404, 'Attachment not found');
|
||||
|
||||
return json(updated);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ url }) => {
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const orders: { id: string; sortOrder: number }[] = body.orders;
|
||||
|
||||
if (!Array.isArray(orders)) {
|
||||
return json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const { id, sortOrder } of orders) {
|
||||
tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run();
|
||||
}
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { changes } = await request.json();
|
||||
@@ -16,5 +18,15 @@ 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 noteIds = changes.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const changesWithTags = changes.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(changesWithTags);
|
||||
};
|
||||
|
||||
|
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,11 +1,172 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
|
||||
|
||||
/** Helper: create a note, close editor, reopen it */
|
||||
async function createAndReopenNote(page: import('@playwright/test').Page, title: string) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Test content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
|
||||
// Reopen for attachment work (note must be saved first)
|
||||
await noteCard(page, title).click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Image Attachments', () => {
|
||||
test('Scenario: Note editor is available for creating a new note', async ({ authenticatedPage: page }) => {
|
||||
// When the user starts creating a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
test('Scenario: Uploaded image appears as thumbnail in the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given a saved note exists
|
||||
await createAndReopenNote(page, 'Attachment Test');
|
||||
|
||||
// Then the note editor is displayed
|
||||
// When the user opens the image panel and uploads an image
|
||||
await page.getByTestId('image-toggle').click();
|
||||
const fileInput = page.getByTestId('file-input');
|
||||
await fileInput.setInputFiles(TEST_IMAGE_PATH);
|
||||
|
||||
// Then a thumbnail appears in the editor
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Image persists after closing and reopening a note', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Persist Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user closes and reopens the note
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await noteCard(page, 'Persist Test').click();
|
||||
|
||||
// Then the image is still visible in the editor
|
||||
await expect(page.getByTestId('note-editor').locator('img')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Uploaded images are hidden from card header by default', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Hidden Default Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user closes the editor without featuring the image
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the card does NOT show a thumbnail strip
|
||||
const card = noteCard(page, 'Hidden Default Test');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByTestId('card-thumbnails')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Featuring an image makes it appear on the card header', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Feature Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user features the image by clicking the star
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
|
||||
// And closes the editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the card shows the featured image in its header
|
||||
const card = noteCard(page, 'Feature Test');
|
||||
await expect(card.getByTestId('card-thumbnails')).toBeVisible();
|
||||
await expect(card.getByTestId('card-thumbnail')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Unfeaturing an image removes it from the card header', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with a featured image exists
|
||||
await createAndReopenNote(page, 'Unfeature Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// Feature the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
const card = noteCard(page, 'Unfeature Test');
|
||||
await expect(card.getByTestId('card-thumbnails')).toBeVisible();
|
||||
|
||||
// When the user unfeatures the image
|
||||
await card.getByText('Unfeature Test').click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the card no longer shows the thumbnail strip
|
||||
await expect(card.getByTestId('card-thumbnails')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Removed image disappears from editor and card', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Remove Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user removes the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('remove-attachment').click();
|
||||
|
||||
// Then the thumbnail is gone from the editor
|
||||
await expect(page.getByTestId('attachment-thumbnail')).not.toBeVisible();
|
||||
|
||||
// And after closing, no thumbnail strip on the card
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
const card = noteCard(page, 'Remove Test');
|
||||
await expect(card.getByTestId('card-thumbnail')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Clicking an image thumbnail opens a full-size lightbox', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Lightbox Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail').first()).toBeVisible();
|
||||
|
||||
// When the user clicks the thumbnail
|
||||
await page.getByTestId('attachment-thumbnail').first().click();
|
||||
|
||||
// Then a full-size lightbox overlay is displayed
|
||||
await expect(page.getByTestId('image-lightbox')).toBeVisible();
|
||||
await expect(page.getByTestId('lightbox-image')).toBeVisible();
|
||||
|
||||
// When the user presses Escape
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Then the lightbox closes but the editor remains open
|
||||
await expect(page.getByTestId('image-lightbox')).not.toBeVisible();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => {
|
||||
// When the user opens a new note and clicks the image toggle
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-Save Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
|
||||
// Then the upload panel is visible (note was auto-saved)
|
||||
await expect(page.getByTestId('image-upload')).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the note exists in the list
|
||||
await expect(noteCard(page, 'Auto-Save Test')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,12 +44,15 @@ test.describe('Checklist', () => {
|
||||
|
||||
// When the user reopens the note and checks the item
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await page.getByTestId('checklist-checkbox').first().check();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the item moves to the done section
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then reopening the note shows the item is still checked
|
||||
// And reopening the note shows the item in the done section (expanded by default)
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await expect(page.getByTestId('checklist-checkbox').first()).toBeChecked();
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
});
|
||||
|
||||
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => {
|
||||
@@ -80,6 +83,29 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with a completed item and an uncompleted item
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Done task');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the completed item is separated from the active list
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
|
||||
|
||||
// And the "done" toggle shows the count
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
|
||||
// Then the completed items are shown in the done section (expanded by default)
|
||||
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
|
||||
});
|
||||
|
||||
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
|
||||
// Given the user has checklist mode enabled
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
|
After Width: | Height: | Size: 70 B |
@@ -68,3 +68,64 @@ test.describe('Organization Features', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Note Sorting', () => {
|
||||
test('Scenario: Notes can be sorted by last updated', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist with different update times
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-First');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-First')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-Second');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-Second')).toBeVisible();
|
||||
|
||||
// When the user selects "Updated" sort mode
|
||||
await page.getByTestId('sort-updated').click();
|
||||
|
||||
// Then Sort-Second (most recently updated) appears before Sort-First
|
||||
const sortFirst = noteCard(page, 'Sort-First');
|
||||
const sortSecond = noteCard(page, 'Sort-Second');
|
||||
const firstBox = await sortSecond.boundingBox();
|
||||
const secondBox = await sortFirst.boundingBox();
|
||||
expect(firstBox!.y).toBeLessThanOrEqual(secondBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Notes can be sorted by creation date', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Older');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Older')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Newer');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Newer')).toBeVisible();
|
||||
|
||||
// When the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// Then Created-Newer appears before Created-Older
|
||||
const newer = noteCard(page, 'Created-Newer');
|
||||
const older = noteCard(page, 'Created-Older');
|
||||
const newerBox = await newer.boundingBox();
|
||||
const olderBox = await older.boundingBox();
|
||||
expect(newerBox!.y).toBeLessThanOrEqual(olderBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Sort preference persists across page reload', async ({ authenticatedPage: page }) => {
|
||||
// Given the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// When the page is reloaded
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the "Created" sort mode is still active
|
||||
const createdBtn = page.getByTestId('sort-created');
|
||||
await expect(createdBtn).toHaveClass(/bg-\[var\(--primary\)\]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,14 @@ export default defineConfig({
|
||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
|
||||
navigateFallback: '/',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'attachment-cache',
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 365 * 24 * 60 * 60 }
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https?:\/\/.*\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - Page not found | Crumbs</title>
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
.error-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.error-code {
|
||||
font-size: clamp(3rem, 10vw, 6rem);
|
||||
color: var(--primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.error-message {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<h1 class="error-code font-pixel">404</h1>
|
||||
<p class="error-message">This note seems to have been deleted. Or maybe it never existed.</p>
|
||||
<a href="/" class="btn btn-primary">Back to home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
crumbs.bretzel.app
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,308 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crumbs - Your notes. Your server. Your rules.</title>
|
||||
<meta name="description" content="Crumbs is a self-hostable, offline-first note-taking app. A privacy-respecting Google Keep alternative you can run on your own server with a single Docker command.">
|
||||
<meta property="og:title" content="Crumbs by Bretzel">
|
||||
<meta property="og:description" content="Self-hostable, offline-first note-taking. A Google Keep alternative that respects your privacy.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://crumbs.bretzel.app">
|
||||
<meta property="og:image" content="https://crumbs.bretzel.app/assets/og-image.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<a href="/" class="logo">
|
||||
<img src="assets/favicon-96x96.png" alt="Crumbs logo" width="32" height="32">
|
||||
<span>Crumbs</span>
|
||||
</a>
|
||||
<button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<nav>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#features">Features</a></li>
|
||||
<li><a href="#screenshots">Screenshots</a></li>
|
||||
<li><a href="#deploy">Deploy</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/bretzel-app/crumbs" class="github-link" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Hero ── -->
|
||||
<div class="hero-wrapper">
|
||||
<!-- Decorative floating note cards -->
|
||||
<div class="deco-card deco-card-1">Buy groceries<br>- Milk<br>- Eggs</div>
|
||||
<div class="deco-card deco-card-2">Meeting notes<br>Q3 planning...</div>
|
||||
<div class="deco-card deco-card-3">Project ideas<br>#startup #saas</div>
|
||||
<div class="deco-card deco-card-4">Remember to<br>call the vet</div>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<h1 class="animate-in">
|
||||
Your notes. Your server.<br><span>Your rules.</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle animate-in delay-1">
|
||||
A self-hostable, offline-first note-taking app. The Google Keep alternative that respects your privacy.
|
||||
</p>
|
||||
<div class="hero-buttons animate-in delay-2">
|
||||
<a href="#deploy" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
Get Started
|
||||
</a>
|
||||
<a href="https://github.com/bretzel-app/crumbs" class="btn btn-secondary" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="hero-screenshot animate-in delay-3">
|
||||
<img src="assets/screenshot-grid.png" alt="Crumbs notes grid view showing colorful note cards with checklists, tags, and rich text" width="1280" height="800" loading="eager">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Features ── -->
|
||||
<section class="features" id="features">
|
||||
<div class="container">
|
||||
<h2 class="animate-in">Everything you need,<br>nothing you don't</h2>
|
||||
<div class="features-grid">
|
||||
<!-- Rich Markdown Editor -->
|
||||
<div class="feature-card animate-in delay-1">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</div>
|
||||
<h3>Rich Editor</h3>
|
||||
<p>Tiptap-powered WYSIWYG with full Markdown support. Bold, lists, code blocks, checklists, 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>
|
||||
|
||||
<!-- Image Attachments -->
|
||||
<div class="feature-card animate-in delay-5">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
</div>
|
||||
<h3>Image Attachments</h3>
|
||||
<p>Drag-and-drop images with auto-optimization. Works offline.</p>
|
||||
</div>
|
||||
|
||||
<!-- Full-text search -->
|
||||
<div class="feature-card animate-in delay-6">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</div>
|
||||
<h3>Full-Text Search</h3>
|
||||
<p>Find anything instantly. SQLite-powered search across all your notes and tags.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Screenshots ── -->
|
||||
<section class="screenshots" id="screenshots">
|
||||
<div class="container">
|
||||
<h2 class="animate-in">See it in action</h2>
|
||||
<h3 class="screenshots-subtitle animate-in">Desktop</h3>
|
||||
<div class="screenshots-grid">
|
||||
<div class="screenshot-card animate-in delay-1">
|
||||
<img src="assets/screenshot-grid.png" alt="Notes grid view with colorful cards, checklists, and tags" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Notes grid view</div>
|
||||
</div>
|
||||
<div class="screenshot-card animate-in delay-2">
|
||||
<img src="assets/screenshot-editor.png" alt="Rich text editor with formatting toolbar" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Rich text editor</div>
|
||||
</div>
|
||||
<div class="screenshot-card animate-in delay-3">
|
||||
<img src="assets/screenshot-tags.png" alt="Tag filtering showing bretzel-tagged notes" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Tag filtering</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="screenshots-subtitle animate-in">Mobile</h3>
|
||||
<div class="screenshots-grid screenshots-grid-mobile">
|
||||
<div class="screenshot-card screenshot-card-mobile animate-in delay-1">
|
||||
<img src="assets/screenshot-mobile-grid.png" alt="Mobile grid view with notes and tags" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Notes view</div>
|
||||
</div>
|
||||
<div class="screenshot-card screenshot-card-mobile animate-in delay-2">
|
||||
<img src="assets/screenshot-mobile-editor.png" alt="Mobile checklist editor" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Checklist editor</div>
|
||||
</div>
|
||||
<div class="screenshot-card screenshot-card-mobile animate-in delay-3">
|
||||
<img src="assets/screenshot-mobile-sidebar.png" alt="Mobile sidebar with tags" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Sidebar & tags</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Deploy ── -->
|
||||
<section class="deploy" id="deploy">
|
||||
<div class="container">
|
||||
<h2 class="animate-in">Deploy in seconds</h2>
|
||||
<p class="deploy-subtitle animate-in delay-1">One file. One command. Done.</p>
|
||||
|
||||
<div class="code-block animate-in delay-2">
|
||||
<div class="code-header">
|
||||
<span>docker-compose.yml</span>
|
||||
<button class="copy-btn" onclick="copyCode()">copy</button>
|
||||
</div>
|
||||
<pre><code><span class="code-key">services</span>:
|
||||
<span class="code-key">crumbs</span>:
|
||||
<span class="code-key">image</span>: <span class="code-string">ghcr.io/bretzel-app/crumbs:latest</span>
|
||||
<span class="code-key">ports</span>:
|
||||
- <span class="code-string">"3000:3000"</span>
|
||||
<span class="code-key">volumes</span>:
|
||||
- <span class="code-string">crumbs_data:/data</span>
|
||||
|
||||
<span class="code-key">volumes</span>:
|
||||
<span class="code-key">crumbs_data</span>:</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="deploy-thats-it animate-in delay-3">
|
||||
<p class="font-pixel">That's it.</p>
|
||||
<p>Open <code>localhost:3000</code>, set a password, and start writing.</p>
|
||||
</div>
|
||||
|
||||
<div class="deploy-cta animate-in delay-4">
|
||||
<a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" class="btn btn-secondary" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Full deployment docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Footer ── -->
|
||||
<footer class="site-footer">
|
||||
<div class="container">
|
||||
<p class="footer-brand font-pixel">Part of the Bretzel app universe</p>
|
||||
<ul class="footer-links">
|
||||
<li><a href="https://github.com/bretzel-app/crumbs" target="_blank" rel="noopener">GitHub</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.7.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Lightbox -->
|
||||
<div class="lightbox" id="lightbox" role="dialog" aria-label="Image preview">
|
||||
<img src="" alt="">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Lightbox
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
const lightboxImg = lightbox.querySelector('img');
|
||||
|
||||
document.querySelectorAll('.screenshot-card img, .hero-screenshot img').forEach(img => {
|
||||
img.addEventListener('click', () => {
|
||||
lightboxImg.src = img.src;
|
||||
lightboxImg.alt = img.alt;
|
||||
lightbox.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
lightbox.addEventListener('click', () => {
|
||||
lightbox.classList.remove('active');
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') lightbox.classList.remove('active');
|
||||
});
|
||||
|
||||
// Mobile nav toggle
|
||||
const toggle = document.querySelector('.nav-toggle');
|
||||
const navLinks = document.querySelector('.nav-links');
|
||||
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,724 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap');
|
||||
|
||||
/* ── Design tokens (ported from app) ── */
|
||||
:root {
|
||||
--bg-base: #f0e6d3;
|
||||
--bg-surface: #faf5eb;
|
||||
--primary: #C8860A;
|
||||
--primary-hover: #E8A020;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b6272;
|
||||
--border: #1a1a2e;
|
||||
--border-subtle: #d4cabb;
|
||||
--destructive: #a63d2f;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
|
||||
/* Note colors for decorative elements */
|
||||
--coral: #faafa8;
|
||||
--peach: #f39f76;
|
||||
--sand: #fff8b8;
|
||||
--mint: #e2f6d3;
|
||||
--sage: #b4ddd3;
|
||||
--fog: #d4e4ed;
|
||||
--storm: #aeccdc;
|
||||
--dusk: #d3bfdb;
|
||||
--blossom: #f6e2dd;
|
||||
--clay: #e9e3d4;
|
||||
--chalk: #efeff1;
|
||||
}
|
||||
|
||||
/* ── Reset ── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Base ── */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--text);
|
||||
background-color: var(--bg-base);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Pixel grid overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
|
||||
background-size: 4px 4px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color 150ms ease-out;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
.font-pixel {
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
.container {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 2px solid var(--border-subtle);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.site-header .container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--primary);
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
list-style: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.github-link svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Mobile nav toggle */
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
padding: 0.4rem;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-toggle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Hero ── */
|
||||
.hero {
|
||||
padding: 6rem 0 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(1.25rem, 4vw, 2rem);
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hero h1 span {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: clamp(0.85rem, 2vw, 1rem);
|
||||
color: var(--text-muted);
|
||||
max-width: 36rem;
|
||||
margin: 0 auto 2.5rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 2px;
|
||||
border: 2px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease-out;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--bg-surface);
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--bg-surface);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
/* ── Hero screenshot ── */
|
||||
.hero-screenshot {
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
box-shadow: 4px 4px 0px var(--border-subtle);
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.screenshot-placeholder {
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-surface), var(--clay));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screenshot-placeholder::after {
|
||||
content: 'screenshot coming soon';
|
||||
font-family: 'Press Start 2P', cursive;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Decorative note cards (floating behind hero) ── */
|
||||
.hero-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deco-card {
|
||||
position: absolute;
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 1rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
transform: rotate(var(--rotate, 0deg));
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.deco-card-1 {
|
||||
background: var(--coral);
|
||||
top: 8%;
|
||||
left: 3%;
|
||||
--rotate: -6deg;
|
||||
}
|
||||
|
||||
.deco-card-2 {
|
||||
background: var(--mint);
|
||||
top: 20%;
|
||||
right: 4%;
|
||||
--rotate: 4deg;
|
||||
}
|
||||
|
||||
.deco-card-3 {
|
||||
background: var(--sand);
|
||||
bottom: 15%;
|
||||
left: 5%;
|
||||
--rotate: 3deg;
|
||||
}
|
||||
|
||||
.deco-card-4 {
|
||||
background: var(--fog);
|
||||
bottom: 10%;
|
||||
right: 6%;
|
||||
--rotate: -3deg;
|
||||
}
|
||||
|
||||
/* ── Features grid ── */
|
||||
.features {
|
||||
background: var(--bg-surface);
|
||||
border-top: 2px solid var(--border-subtle);
|
||||
border-bottom: 2px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.features h2 {
|
||||
text-align: center;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-base);
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: var(--bg-surface);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 2px 2px 0px var(--border-subtle);
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Screenshots ── */
|
||||
.screenshots h2 {
|
||||
text-align: center;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.screenshots-subtitle {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.screenshots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.screenshots-grid-mobile {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
max-width: 48rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.screenshot-card-mobile {
|
||||
max-width: 14rem;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.screenshot-card {
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--card-shadow);
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.screenshot-card .screenshot-placeholder {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.screenshot-label {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
border-top: 2px solid var(--border-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Deploy / Quick Start ── */
|
||||
.deploy {
|
||||
background: var(--bg-surface);
|
||||
border-top: 2px solid var(--border-subtle);
|
||||
border-bottom: 2px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.deploy h2 {
|
||||
text-align: center;
|
||||
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.deploy-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto 2rem;
|
||||
background: var(--text);
|
||||
color: var(--bg-surface);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: 4px 4px 0px var(--border-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
padding: 1.25rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.code-comment {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.code-key {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.code-string {
|
||||
color: #b4ddd3;
|
||||
}
|
||||
|
||||
.deploy-thats-it {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.deploy-thats-it .font-pixel {
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.deploy-thats-it p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.deploy-cta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.site-footer {
|
||||
padding: 3rem 0;
|
||||
border-top: 2px solid var(--border-subtle);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
list-style: none;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.footer-version {
|
||||
display: inline-block;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Lightbox ── */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(26, 26, 46, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
cursor: zoom-out;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 150ms ease-out, visibility 150ms ease-out;
|
||||
}
|
||||
|
||||
.lightbox.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
box-shadow: 4px 4px 0px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(0.95);
|
||||
transition: transform 150ms ease-out;
|
||||
}
|
||||
|
||||
.lightbox.active img {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.screenshot-card img,
|
||||
.hero-screenshot img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fade-up 400ms ease-out both;
|
||||
}
|
||||
|
||||
.delay-1 { animation-delay: 100ms; }
|
||||
.delay-2 { animation-delay: 200ms; }
|
||||
.delay-3 { animation-delay: 300ms; }
|
||||
.delay-4 { animation-delay: 400ms; }
|
||||
.delay-5 { animation-delay: 500ms; }
|
||||
.delay-6 { animation-delay: 600ms; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.nav-links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 2px solid var(--border-subtle);
|
||||
flex-direction: column;
|
||||
padding: 1rem 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-links.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 4rem 0 3rem;
|
||||
}
|
||||
|
||||
.deco-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 3.5rem 0;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.screenshots-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.screenshots-grid-mobile {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.screenshot-card-mobile {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
font-size: 0.7rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||