feat: Crumbs by Bretzel — self-hostable notes app MVP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-05 21:16:45 +01:00
committed by Frederic Cabassut
commit 1becc17724
89 changed files with 11359 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Database path (relative to project root or absolute)
DATABASE_URL=./data/crumbs.db
# Server origin (required for CSRF protection)
ORIGIN=http://localhost:3000
# Node environment
NODE_ENV=development
+102
View File
@@ -0,0 +1,102 @@
name: CI
on:
push:
branches: [main, 'claude/**']
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-check:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache Playwright Browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright System Dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- run: pnpm test:e2e
env:
CI: true
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: [lint-and-check, unit-tests]
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t crumbs:ci .
+54
View File
@@ -0,0 +1,54 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
packages: write
jobs:
ci:
uses: ./.github/workflows/ci.yml
release:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: ci
steps:
- uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
if: env.REGISTRY_URL != ''
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
env:
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ secrets.REGISTRY_URL != '' }}
tags: |
${{ secrets.REGISTRY_URL && format('{0}/crumbs:{1}', secrets.REGISTRY_URL, steps.version.outputs.VERSION) || '' }}
${{ secrets.REGISTRY_URL && format('{0}/crumbs:latest', secrets.REGISTRY_URL) || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
+33
View File
@@ -0,0 +1,33 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Database
/data
*.db
*.db-journal
*.db-wal
# Test results
/test-results
/playwright-report
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+133
View File
@@ -0,0 +1,133 @@
# CLAUDE.md
## Project overview
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
## Tech stack
- **Framework**: SvelteKit with Svelte 5 (runes: `$state`, `$derived`, `$props`, `$effect`)
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at `src/lib/server/db/schema.ts`)
- **Auth**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
- **Package manager**: pnpm
## Project structure
```
src/
routes/ # SvelteKit pages and API routes
+page.svelte # Main notes view
login/ # Login page
setup/ # First-time password setup
api/auth/ # Login, logout, setup endpoints
api/notes/ # CRUD + attachments
api/search/ # Full-text search
api/sync/ # Offline sync
api/tags/ # Tag management
lib/
components/ # Svelte components (NoteCard, NoteEditor, ColorPicker, etc.)
stores/ # Svelte stores (notes.ts, theme.ts)
server/db/ # Drizzle schema + connection (schema.ts, index.ts)
server/ # Auth logic, attachment handling
sync/ # Client sync (idb.ts), CRDT merge (crdt.ts), server sync
types/ # TypeScript interfaces (Note, Attachment, Tag, etc.)
utils/ # Colors, markdown, tags, debounce
hooks.server.ts # Auth middleware (redirects unauthenticated users)
tests/
unit/ # Vitest unit tests
e2e/ # Playwright e2e tests (Gherkin-style)
helpers/fixtures.ts # Shared authenticatedPage fixture
```
## Commands
A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to list targets.
| Make target | pnpm equivalent | Description |
|---|---|---|
| `make dev` | `pnpm dev` | Start dev server |
| `make build` | `pnpm build` | Production build |
| `make preview` | `pnpm preview` | Preview production build |
| `make check` | `pnpm check` | Svelte type checking |
| `make test` | `pnpm test` | Run all tests (unit + e2e) |
| `make test-unit` | `pnpm test:unit` | Run unit tests only |
| `make test-e2e` | `pnpm test:e2e` | Run e2e tests only |
| `make lint` | `pnpm lint` | Run linter |
| `make db-push` | `pnpm db:push` | Push schema to DB |
| `make db-generate` | `pnpm db:generate` | Generate migrations |
| `make db-migrate` | `pnpm db:migrate` | Run migrations |
| `make db-studio` | `pnpm db:studio` | Open Drizzle Studio |
| `make install` | `pnpm install` | Install dependencies |
| `make docker-build` | — | Build Docker image |
| `make docker-up` | — | Start containers |
| `make docker-down` | — | Stop containers |
| `make docker-logs` | — | Tail container logs |
| `make clean` | — | Remove build artifacts + test DBs |
## CI/CD
GitHub Actions with two workflows:
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
1. **Lint & Type Check** — `pnpm check`
2. **Unit Tests** — `pnpm test:unit`
3. **Build** — `pnpm build` (depends on steps 1+2)
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 configured registry
- Creates a GitHub Release with auto-generated notes
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
## Testing philosophy: BDD/TDD
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
### Unit tests (Vitest)
- Location: `tests/unit/*.test.ts`
- Config: `vitest.config.ts` (node environment)
- Cover pure logic: markdown parsing, tag extraction, auth, search, sync
### E2E tests (Playwright)
- Location: `tests/e2e/*.spec.ts`
- Config: `playwright.config.ts` (Chromium only, builds + previews app on port 4173)
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
- Auth tests use `test.describe.serial` because they depend on sequential database state
### Gherkin-style Given/When/Then
E2E tests follow Cucumber BDD conventions via comments. Follow the [Better Gherkin](https://cucumber.io/docs/bdd/better-gherkin) guidelines:
**Declarative, not imperative** — describe *what* the system does, not *how* the user interacts with the UI:
```
// Good: "When the user trashes the note"
// Bad: "When the user hovers over the note card and clicks the trash button"
```
**Given describes state, not navigation:**
```
// Good: "Given a note titled 'Delete Me' exists"
// Bad: "Given the user clicks the new note button and fills in the title"
```
**Collapse multi-step UI actions into a single intent:**
```
// Good: "When the user creates a note titled 'My Note' with content 'Hello'"
// Bad: "When they click new note / And fill in the title / And fill in the content / And close the editor"
```
**Scenario names describe outcomes/behavior, not actions:**
```
// Good: "Scenario: Trashed note disappears from the main view"
// Bad: "Scenario: User moves a note to trash"
```
**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*.
+57
View File
@@ -0,0 +1,57 @@
# Stage 1: Build
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies (with native module builds)
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the SvelteKit app
RUN pnpm run build
# Prune dev dependencies
RUN pnpm prune --prod
# Stage 2: Runtime
FROM node:22-slim AS runtime
WORKDIR /app
# Install only what's needed for native modules
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy built app and production dependencies
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
# Create data directory
RUN mkdir -p /data && chown -R node:node /data
# Use non-root user
USER node
# Configuration
ENV NODE_ENV=production
ENV DATA_DIR=/data
ENV DATABASE_URL=/data/crumbs.db
ENV PORT=3000
ENV ORIGIN=http://localhost:3000
EXPOSE 3000
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD node -e "fetch('http://localhost:3000/login').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
CMD ["node", "build"]
+61
View File
@@ -0,0 +1,61 @@
.PHONY: help dev build preview check test test-unit test-e2e lint \
db-push db-generate db-migrate db-studio \
install docker-build docker-up docker-down docker-logs clean
help: ## List all targets
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
dev: ## Start dev server
pnpm dev
build: ## Production build
pnpm build
preview: ## Preview production build
pnpm preview
check: ## Svelte type checking
pnpm check
test: ## Run all tests (unit + e2e)
pnpm test
test-unit: ## Run unit tests only
pnpm test:unit
test-e2e: ## Run e2e tests only
pnpm test:e2e
lint: ## Run linter
pnpm lint
db-push: ## Push schema to DB
pnpm db:push
db-generate: ## Generate migrations
pnpm db:generate
db-migrate: ## Run migrations
pnpm db:migrate
db-studio: ## Open Drizzle Studio
pnpm db:studio
install: ## Install dependencies
pnpm install
docker-build: ## Build Docker image
docker build -t crumbs .
docker-up: ## Start containers
docker compose up -d
docker-down: ## Stop containers
docker compose down
docker-logs: ## Tail container logs
docker compose logs -f
clean: ## Remove build artifacts and test DBs
rm -rf build .svelte-kit node_modules/.vite
rm -f data/test-crumbs.db
+151
View File
@@ -0,0 +1,151 @@
# Crumbs by Bretzel
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe. Built with SvelteKit, SQLite, and Tailwind CSS.
## Features
- **Notes CRUD** - Create, edit, and delete notes with rich Markdown support
- **Markdown** - Bold, italic, headings, lists, code blocks, tables, links
- **Checklists** - Interactive task lists with checkboxes
- **Image Attachments** - Upload and display images in notes
- **Tags** - Organize notes with inline #hashtags
- **Colors** - 12 color-coded note backgrounds (matching Google Keep)
- **Pin** - Pin important notes to the top
- **Archive** - Archive notes to declutter your main view
- **Trash** - Soft delete with restore capability
- **Search** - Full-text search across titles, content, and tags
- **Dark Mode** - System-aware with manual toggle
- **Single-User Auth** - Password-protected access
- **PWA** - Installable as a standalone app with offline support
- **Local-First** - Notes stored in IndexedDB, synced to server via LWW CRDTs
- **Docker** - Single command deployment with `docker compose up`
## Quick Start
### Docker (Recommended)
```bash
docker compose up -d
```
Open http://localhost:3000 and set your password on first visit.
### Development
```bash
# Install dependencies
pnpm install
# Start dev server
pnpm dev
# Run unit tests
pnpm test:unit
# Run E2E tests (requires Playwright browsers)
npx playwright install chromium
pnpm test:e2e
# Type check
pnpm check
# Build for production
pnpm build
```
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | SvelteKit 2 |
| Language | TypeScript (strict) |
| UI | Tailwind CSS 4 |
| Database | SQLite (better-sqlite3) |
| ORM | Drizzle ORM |
| Client DB | IndexedDB (idb) |
| Sync | LWW CRDTs |
| Auth | Argon2 + session cookies |
| Markdown | markdown-it |
| PWA | @vite-pwa/sveltekit |
| Testing | Vitest + Playwright |
| Container | Docker (multi-stage) |
| CI/CD | GitHub Actions |
## Project Structure
```
src/
├── lib/
│ ├── components/ # Svelte UI components
│ ├── server/db/ # Drizzle schema & connection
│ ├── server/auth.ts # Authentication logic
│ ├── sync/ # CRDT sync engine (client + server)
│ ├── stores/ # Svelte reactive stores
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Markdown, tags, colors, debounce
├── routes/
│ ├── api/ # REST API endpoints
│ ├── login/ # Login page
│ ├── setup/ # First-run setup page
│ └── +page.svelte # Main notes view
└── hooks.server.ts # Auth middleware
tests/
├── unit/ # Vitest unit tests
└── e2e/ # Playwright E2E tests
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/notes?filter=all\|archived\|trashed` | List notes |
| POST | `/api/notes` | Create a note |
| GET | `/api/notes/:id` | Get a note |
| PATCH | `/api/notes/:id` | Update a note |
| DELETE | `/api/notes/:id` | Delete a note permanently |
| GET | `/api/search?q=query` | Full-text search |
| GET | `/api/tags` | List all tags |
| POST | `/api/sync` | Push sync changes |
| GET | `/api/sync?since=timestamp` | Pull changes since timestamp |
| POST | `/api/auth/setup` | First-time password setup |
| POST | `/api/auth/login` | Login |
| POST | `/api/auth/logout` | Logout |
| GET/POST/DELETE | `/api/notes/:id/attachments` | Manage image attachments |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database file path |
| `DATA_DIR` | `./data` | Data directory for attachments |
| `ORIGIN` | `http://localhost:3000` | Server origin (CSRF protection) |
| `PORT` | `3000` | Server port |
| `NODE_ENV` | `development` | Node environment |
## CI/CD
GitHub Actions workflows are included:
- **CI** (`ci.yml`): Runs on every push/PR - lint, type check, unit tests, build, E2E tests, Docker build
- **Release** (`release.yml`): Runs on version tags (`v*`) - builds and pushes Docker image to a configurable registry
Configure your registry via GitHub Secrets:
- `REGISTRY_URL` - Container registry URL
- `REGISTRY_USER` - Registry username
- `REGISTRY_TOKEN` - Registry access token
## Sync Architecture
Crumbs uses a local-first architecture with Last-Write-Wins (LWW) conflict resolution:
1. All reads come from IndexedDB (instant, works offline)
2. Writes go to IndexedDB + a sync queue
3. Background sync pushes queued changes to server every 30s
4. Server resolves conflicts using timestamps (newer wins)
5. Client pulls server changes and merges with local state
This ensures the app works fully offline and syncs automatically when connectivity is restored.
## License
MIT
+17
View File
@@ -0,0 +1,17 @@
services:
crumbs:
build: .
container_name: crumbs
ports:
- "3000:3000"
volumes:
- crumbs-data:/data
environment:
- NODE_ENV=production
- DATA_DIR=/data
- DATABASE_URL=/data/crumbs.db
- ORIGIN=http://localhost:3000
restart: unless-stopped
volumes:
crumbs-data:
+166
View File
@@ -0,0 +1,166 @@
# API Reference
All API endpoints require authentication (session cookie) except `/api/auth/setup` and `/api/auth/login`.
## Authentication
### POST /api/auth/setup
First-time setup. Creates the single user account.
**Request:**
```json
{ "password": "min8characters" }
```
**Response:** `201 Created`
```json
{ "success": true }
```
Sets `session` cookie.
### POST /api/auth/login
**Request:**
```json
{ "password": "yourpassword" }
```
**Response:** `200 OK`
```json
{ "success": true }
```
Sets `session` cookie.
### POST /api/auth/logout
Clears session.
**Response:** `200 OK`
```json
{ "success": true }
```
## Notes
### GET /api/notes
List notes by filter.
**Query Parameters:**
- `filter` - `all` (default), `archived`, `trashed`
**Response:** `200 OK`
```json
[
{
"id": "uuid",
"title": "My Note",
"content": "**Markdown** content",
"color": "coral",
"pinned": true,
"archived": false,
"trashed": false,
"checklistMode": false,
"sortOrder": 0,
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z",
"version": 1,
"tags": ["work", "important"]
}
]
```
### POST /api/notes
Create a new note.
**Request:**
```json
{
"title": "My Note",
"content": "Note content with #tags",
"color": "default",
"pinned": false,
"checklistMode": false
}
```
**Response:** `201 Created` - Returns the created note.
### GET /api/notes/:id
Get a single note.
### PATCH /api/notes/:id
Update a note.
**Request:** (partial update - only include changed fields)
```json
{
"title": "Updated Title",
"color": "coral",
"pinned": true
}
```
**Response:** `200 OK` - Returns the updated note.
### DELETE /api/notes/:id
Permanently delete a note.
## Search
### GET /api/search
Full-text search across notes.
**Query Parameters:**
- `q` - Search query
**Response:** `200 OK` - Array of matching notes (excluding trashed).
## Tags
### GET /api/tags
List all tags.
**Response:** `200 OK`
```json
[
{ "id": 1, "name": "work" },
{ "id": 2, "name": "personal" }
]
```
## Attachments
### GET /api/notes/:id/attachments
List attachments for a note.
### POST /api/notes/:id/attachments
Upload an image attachment.
**Request:** `multipart/form-data` with `file` field.
**Response:** `201 Created` - Returns attachment metadata.
### DELETE /api/notes/:id/attachments?attachmentId=xxx
Delete an attachment.
## Sync
### POST /api/sync
Push local changes to server.
**Request:**
```json
{
"changes": [
{
"noteId": "uuid",
"operation": "create|update|delete",
"timestamp": 1704067200000,
"data": { "title": "...", "content": "..." }
}
]
}
```
### GET /api/sync?since=timestamp
Pull changes since a timestamp (milliseconds).
**Response:** `200 OK` - Array of notes updated since the given timestamp.
+106
View File
@@ -0,0 +1,106 @@
# Architecture
## Overview
Crumbs follows a **local-first** architecture where the client (browser) is the primary data store and the server acts as a sync target and backup.
```
┌─────────────────────────────┐
│ Browser (PWA) │
│ ┌───────────┐ ┌──────────┐ │
│ │ Svelte │ │ IndexedDB│ │
│ │ Stores │◄│ (idb) │ │
│ └─────┬─────┘ └────┬─────┘ │
│ │ │ │
│ ┌─────▼─────────────▼─────┐ │
│ │ Sync Engine │ │
│ │ (LWW CRDT + Queue) │ │
│ └──────────┬──────────────┘ │
└─────────────┼────────────────┘
│ HTTP (JSON)
┌─────────────▼────────────────┐
│ Server (Node.js) │
│ ┌──────────┐ ┌────────────┐ │
│ │ SvelteKit│ │ SQLite │ │
│ │ API │◄│ (Drizzle) │ │
│ └──────────┘ └────────────┘ │
└──────────────────────────────┘
```
## Data Flow
### Read Path
1. User opens app
2. Svelte stores read from IndexedDB (instant)
3. Background sync pulls latest from server
4. If newer data found, IDB is updated, stores react
### Write Path
1. User creates/edits note
2. Change written to IndexedDB immediately
3. Change added to sync queue
4. Background sync pushes queue to server
5. Server applies changes with LWW resolution
### Conflict Resolution (LWW)
For a single-user app with multiple devices:
- Each note has an `updatedAt` timestamp and `version` number
- When merging: newer timestamp wins
- If timestamps match: higher version wins
- If both match: local version is preferred
## Tech Stack Rationale
### SvelteKit
- Smallest bundle size of major frameworks
- Built-in SSR, routing, API routes
- Svelte 5 runes for reactive state
- Native service worker support via @vite-pwa/sveltekit
### SQLite + Drizzle
- Zero-config database (single file)
- WAL mode for concurrent reads
- Drizzle provides type-safe queries without ORM overhead
- Perfect for single-user self-hosted apps
### IndexedDB (idb)
- Browser-native storage (no size limits like localStorage)
- Async API doesn't block main thread
- `idb` library provides Promise-based wrapper
- Survives browser restarts
### Tailwind CSS 4
- Utility-first, tree-shakeable
- Built-in dark mode support (`dark:` variants)
- New v4 uses Vite plugin (faster builds)
### Argon2
- Current best practice for password hashing
- Memory-hard (resists GPU attacks)
- Winner of the Password Hashing Competition
## Database Schema
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
Key tables:
- `users` - Single user (password hash)
- `sessions` - Auth sessions (30-day TTL)
- `notes` - Core note data with version tracking
- `tags` - Unique tag names
- `note_tags` - Many-to-many note ↔ tag
- `attachments` - Image file metadata
- `sync_log` - Sync operation history
## File Organization
```
src/lib/server/ # Server-only code (DB, auth, attachments)
src/lib/sync/ # Sync engine (shared types, server + client implementations)
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
src/lib/components/ # Svelte UI components
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
src/lib/types/ # TypeScript type definitions
src/routes/api/ # REST API endpoints
src/routes/ # Page routes (SvelteKit file-based routing)
```
+125
View File
@@ -0,0 +1,125 @@
# Deployment Guide
## Docker (Recommended)
### Quick Start
```bash
docker compose up -d
```
Open http://localhost:3000 and set your password.
### Custom Configuration
```yaml
# docker-compose.yml
services:
crumbs:
build: .
ports:
- "8080:3000" # Change external port
volumes:
- /path/to/data:/data # Custom data location
environment:
- ORIGIN=https://notes.example.com # Your domain
restart: unless-stopped
```
### Reverse Proxy (nginx)
```nginx
server {
listen 443 ssl;
server_name notes.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (for future live sync)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
Set `ORIGIN=https://notes.example.com` in your docker-compose.yml.
### Backup
The SQLite database and attachments are stored in `/data`:
```bash
# Backup
docker compose exec crumbs tar czf - /data > crumbs-backup.tar.gz
# Or copy the volume directly
docker cp crumbs:/data ./backup
```
### Update
```bash
docker compose pull
docker compose up -d
```
## Manual Deployment (Node.js)
### Prerequisites
- Node.js 22+
- pnpm
### Steps
```bash
# Clone and install
git clone <repo-url> crumbs
cd crumbs
pnpm install
# Build
pnpm build
# Set environment
export DATABASE_URL=./data/crumbs.db
export ORIGIN=http://localhost:3000
export NODE_ENV=production
# Run
node build
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
| `DATA_DIR` | `./data` | Directory for attachments |
| `ORIGIN` | `http://localhost:3000` | Server origin (required for CSRF) |
| `PORT` | `3000` | HTTP port |
| `NODE_ENV` | `development` | Set to `production` for deployment |
## Health Check
The Docker image includes a health check that pings `/login`:
```bash
docker inspect --format='{{.State.Health.Status}}' crumbs
```
## Security Notes
- Always use HTTPS in production (via reverse proxy)
- Set a strong password (min 8 characters recommended)
- The `ORIGIN` variable must match your actual domain for CSRF protection
- Sessions expire after 30 days
- Passwords are hashed with Argon2
+100
View File
@@ -0,0 +1,100 @@
# Features
## Core Features (MVP)
### Notes CRUD
- Create, read, update, and delete notes
- Notes have a title and content body
- Content supports full Markdown syntax
- Auto-save on editor close
### Markdown Support
- **Bold** (`**text**`)
- *Italic* (`*text*`)
- Headings (`# H1` through `###### H6`)
- Unordered lists (`- item`)
- Ordered lists (`1. item`)
- Code blocks (triple backtick)
- Inline code (single backtick)
- Tables
- Links (auto-linkified URLs)
- Line breaks
### Checklists / Task Lists
- Toggle between regular note and checklist mode
- Add, remove, reorder checklist items
- Check/uncheck items with checkboxes
- Checked items get strikethrough styling
- Enter key adds new item, Backspace removes empty items
### Image Attachments
- Upload images via file picker or drag-and-drop
- Supports JPEG, PNG, GIF, WebP, SVG
- Max file size: 10MB
- Thumbnail previews in note cards
- Remove attachments from editor
### Tag Organization
- Inline `#hashtag` extraction from note content
- Tags automatically parsed from title and content
- Filter notes by tag (sidebar + tag chips)
- Tags are case-insensitive and deduplicated
- Tags inside code blocks and URLs are ignored
### Color-Coded Notes
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
### Pin Notes
- Pin important notes to always appear at top
- Pinned section separated from unpinned notes
- Toggle pin from note card hover actions
### Archive
- Archive notes to declutter main view
- Archived notes accessible from sidebar
- Unarchive to restore to main view
### Trash
- Soft delete moves notes to trash
- Trash view accessible from sidebar
- Restore notes from trash
- Permanently delete from trash
### Full-Text Search
- Search across note titles, content, and tags
- Real-time search results as you type
- 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)
- Session-based auth with 30-day expiry
- All routes protected except /login and /setup
- Logout clears session
### 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
- LWW conflict resolution for multi-device use
- Sync status indicator (synced/syncing/offline/error)
### Docker Deployment
- Multi-stage Dockerfile (build + slim runtime)
- docker-compose.yml with persistent volume
- Health check endpoint
- Runs as non-root user
- Configurable via environment variables
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL || './data/crumbs.db'
}
});
+56
View File
@@ -0,0 +1,56 @@
{
"name": "crumbs",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "playwright test",
"test": "pnpm test:unit && pnpm test:e2e",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"lint": "eslint ."
},
"dependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.44.0",
"argon2": "^0.43.0",
"idb": "^8.0.0",
"uuid": "^11.0.0",
"markdown-it": "^14.0.0"
},
"devDependencies": {
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0",
"@vite-pwa/sveltekit": "^1.0.0",
"drizzle-kit": "^0.31.0",
"vitest": "^3.0.0",
"@playwright/test": "^1.50.0",
"@types/better-sqlite3": "^7.6.0",
"@types/markdown-it": "^14.0.0",
"@types/uuid": "^10.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"argon2",
"better-sqlite3",
"esbuild"
]
}
}
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: './tests/e2e/global-setup.ts',
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'setup',
testMatch: /auth\.spec\.ts/,
use: { ...devices['Desktop Chrome'] }
},
{
name: 'chromium',
testIgnore: /auth\.spec\.ts/,
dependencies: ['setup'],
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'pnpm build && pnpm preview --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
DATABASE_URL: './data/test-crumbs.db'
}
}
});
+6076
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#202124" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<title>Crumbs</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
import type { Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return resolve(event);
}
// Check if setup is complete
const setupDone = await isSetupComplete();
if (!setupDone) {
if (pathname !== '/setup') {
throw redirect(302, '/setup');
}
return resolve(event);
}
// Validate session
const sessionToken = event.cookies.get('session');
if (!sessionToken || !(await validateSession(sessionToken))) {
if (pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
throw redirect(302, '/login');
}
return resolve(event);
};
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
interface ChecklistItem {
text: string;
checked: boolean;
}
interface Props {
content: string;
onChange: (content: string) => void;
}
let { content, onChange }: Props = $props();
let items = $state<ChecklistItem[]>(parseChecklist(content));
function parseChecklist(text: string): ChecklistItem[] {
if (!text.trim()) return [{ 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 };
});
}
function serializeChecklist(list: ChecklistItem[]): string {
return list
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
}
function emitChange() {
onChange(serializeChecklist(items));
}
function toggleItem(index: number) {
items[index].checked = !items[index].checked;
emitChange();
}
function updateText(index: number, text: string) {
items[index].text = text;
emitChange();
}
function addItem(afterIndex: number) {
items.splice(afterIndex + 1, 0, { text: '', checked: false });
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) {
if (items.length <= 1) return;
items.splice(index, 1);
items = [...items];
emitChange();
}
function handleKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addItem(index);
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
e.preventDefault();
removeItem(index);
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
inputs[Math.max(0, index - 1)]?.focus();
}, 0);
}
}
</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-gray-300 text-amber-600 focus:ring-amber-500"
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-gray-400 line-through' : 'text-gray-800 dark:text-gray-200'}"
placeholder="List item"
data-testid="checklist-input"
/>
<button
onclick={() => removeItem(index)}
class="opacity-0 hover:opacity-100 focus:opacity-100"
aria-label="Remove item"
data-testid="checklist-remove"
>
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
<button
onclick={() => addItem(items.length - 1)}
class="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
data-testid="checklist-add"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add item
</button>
</div>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import type { NoteColor } from '$lib/types/index.js';
interface Props {
selected: NoteColor;
onSelect: (color: NoteColor) => void;
}
let { selected, onSelect }: Props = $props();
</script>
<div class="flex flex-wrap gap-1" data-testid="color-picker">
{#each COLOR_OPTIONS as option}
{@const colors = NOTE_COLORS[option.value]}
<button
onclick={() => onSelect(option.value)}
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-gray-800 dark:border-white' : 'border-transparent'}"
style="background-color: {$effectiveTheme === 'dark' ? colors.dark : colors.light}"
title={option.label}
data-testid="color-{option.value}"
></button>
{/each}
</div>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import type { Attachment } from '$lib/types/index.js';
interface Props {
noteId: string;
attachments: Attachment[];
onUpload: (attachment: Attachment) => void;
onRemove: (attachmentId: string) => void;
}
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
let uploading = $state(false);
let dragOver = $state(false);
async function handleFiles(files: FileList | null) {
if (!files || !noteId) return;
uploading = true;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: formData
});
if (res.ok) {
const attachment = await res.json();
onUpload(attachment);
}
} catch (err) {
console.error('Upload failed:', err);
}
}
uploading = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
handleFiles(e.dataTransfer?.files ?? null);
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragOver = true;
}
</script>
<div data-testid="image-upload">
<!-- Existing attachments -->
{#if attachments.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each attachments as attachment}
<div class="group relative">
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded object-cover"
data-testid="attachment-thumbnail"
/>
<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"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<!-- Upload area -->
<div
class="rounded-lg border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20' : 'border-gray-300 dark:border-gray-600'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={() => (dragOver = false)}
role="button"
tabindex="0"
data-testid="upload-dropzone"
>
{#if uploading}
<p class="text-sm text-gray-500">Uploading...</p>
{:else}
<label class="cursor-pointer">
<span class="text-sm text-gray-500">Drop images here or <span class="text-amber-600 underline">browse</span></span>
<input
type="file"
accept="image/*"
multiple
class="hidden"
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
data-testid="file-input"
/>
</label>
{/if}
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import SearchBar from '../SearchBar.svelte';
import ThemeToggle from '../ThemeToggle.svelte';
import SyncIndicator from '../SyncIndicator.svelte';
interface Props {
onMenuToggle: () => void;
}
let { onMenuToggle }: Props = $props();
</script>
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<button
onclick={onMenuToggle}
class="rounded-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Toggle sidebar"
>
<svg class="h-6 w-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="flex items-center gap-2">
<span class="text-2xl">🥨</span>
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Crumbs</h1>
</div>
<div class="mx-4 flex-1">
<SearchBar />
</div>
<div class="flex items-center gap-2">
<SyncIndicator />
<ThemeToggle />
</div>
</header>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
import type { NoteFilter } from '$lib/types/index.js';
interface Props {
open: boolean;
}
let { open }: Props = $props();
function setFilter(filter: NoteFilter) {
selectedTag.set(null);
loadNotes(filter);
}
function selectTag(tag: string | null) {
selectedTag.set(tag);
loadNotes('all');
}
</script>
<aside
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-gray-200 bg-white transition-transform duration-200 dark:border-gray-700 dark:bg-gray-900 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
>
<nav class="p-2">
<ul class="space-y-1">
<li>
<button
onclick={() => setFilter('all')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Notes
</button>
</li>
<li>
<button
onclick={() => setFilter('archived')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Archive
</button>
</li>
<li>
<button
onclick={() => setFilter('trashed')}
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Trash
</button>
</li>
</ul>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tags</h3>
<ul class="mt-2 space-y-1">
{#each $allTags as tag}
<li>
<button
onclick={() => selectTag($selectedTag === tag ? null : tag)}
class="flex w-full items-center gap-3 rounded-full px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
#{tag}
</button>
</li>
{/each}
</ul>
</div>
{/if}
</nav>
</aside>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import { writable } from 'svelte/store';
interface ToastMessage {
id: number;
text: string;
type: 'info' | 'success' | 'error';
action?: { label: string; handler: () => void };
}
let counter = 0;
export const toasts = writable<ToastMessage[]>([]);
export function showToast(
text: string,
type: 'info' | 'success' | 'error' = 'info',
action?: { label: string; handler: () => void },
duration = 4000
) {
const id = ++counter;
toasts.update((t) => [...t, { id, text, type, action }]);
if (!action) {
setTimeout(() => dismiss(id), duration);
}
}
export function dismiss(id: number) {
toasts.update((t) => t.filter((toast) => toast.id !== id));
}
</script>
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
{#each $toasts as toast (toast.id)}
<div
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900'}"
>
<span class="text-sm">{toast.text}</span>
{#if toast.action}
<button
onclick={() => { toast.action?.handler(); dismiss(toast.id); }}
class="text-sm font-semibold underline"
>
{toast.action.label}
</button>
{/if}
<button onclick={() => dismiss(toast.id)} class="ml-2 opacity-70 hover:opacity-100">&times;</button>
</div>
{/each}
</div>
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import { stripMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
interface Props {
note: Note;
onEdit: (note: Note) => void;
}
let { note, onEdit }: Props = $props();
$effect(() => {
const colors = NOTE_COLORS[note.color];
const bg = $effectiveTheme === 'dark' ? colors.dark : colors.light;
cardStyle = `background-color: ${bg}`;
});
let cardStyle = $state('');
const preview = $derived(stripMarkdown(note.content).slice(0, 200));
function stop(fn: () => void) {
return (e: Event) => {
e.stopPropagation();
fn();
};
}
</script>
<article
class="group relative cursor-pointer rounded-lg border border-gray-200 p-4 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
role="button"
tabindex="0"
data-testid="note-card"
data-note-id={note.id}
>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">{note.title}</h3>
{/if}
{#if preview}
<p class="line-clamp-6 text-sm text-gray-700 dark:text-gray-300">{preview}</p>
{/if}
{#if note.tags && note.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each note.tags as tag}
<span class="rounded-full bg-gray-200/60 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700/60 dark:text-gray-400">
#{tag}
</span>
{/each}
</div>
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Restore"
data-testid="restore-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Delete forever"
data-testid="delete-forever-btn"
>
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{:else}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title={note.pinned ? 'Unpin' : 'Pin'}
data-testid="pin-btn"
>
<svg class="h-4 w-4 {note.pinned ? 'text-amber-600' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Unarchive"
data-testid="unarchive-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Archive"
data-testid="archive-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
{/if}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
title="Delete"
data-testid="trash-btn"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/if}
</div>
<!-- Pin indicator -->
{#if note.pinned}
<div class="absolute right-2 top-2">
<svg class="h-4 w-4 text-amber-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
{/if}
</article>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { effectiveTheme } from '$lib/stores/theme.js';
import type { Note, NoteColor } from '$lib/types/index.js';
interface Props {
note: Note | null;
isNew?: boolean;
onClose: () => void;
}
let { note, isNew = false, onClose }: Props = $props();
let title = $state(note?.title || '');
let content = $state(note?.content || '');
let color = $state<NoteColor>(note?.color || 'default');
let checklistMode = $state(note?.checklistMode || false);
let showPreview = $state(false);
let showColorPicker = $state(false);
let bgStyle = $state('');
$effect(() => {
const colors = NOTE_COLORS[color];
bgStyle = `background-color: ${$effectiveTheme === 'dark' ? colors.dark : colors.light}`;
});
const renderedContent = $derived(renderMarkdown(content));
async function save() {
if (!title.trim() && !content.trim()) {
onClose();
return;
}
if (isNew) {
await createNote({ title, content, color, checklistMode });
} else if (note) {
await updateNote(note.id, { title, content, color, checklistMode });
}
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
save();
}
}
function handleColorSelect(c: NoteColor) {
color = c;
showColorPicker = false;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40 flex items-start justify-center bg-black/50 pt-20"
onclick={save}
onkeydown={handleKeydown}
data-testid="note-editor-overlay"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-xl rounded-lg border border-gray-200 shadow-xl dark:border-gray-700"
style={bgStyle}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
data-testid="note-editor"
>
<!-- Title -->
<input
type="text"
placeholder="Title"
bind:value={title}
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-gray-900 outline-none placeholder:text-gray-500 dark:text-gray-100"
data-testid="note-title-input"
/>
<!-- Content -->
{#if checklistMode}
<div class="px-4 py-2">
<Checklist {content} onChange={(c) => (content = c)} />
</div>
{:else if showPreview}
<div
class="prose prose-sm dark:prose-invert min-h-[100px] max-w-none px-4 py-2"
data-testid="note-preview"
>
{@html renderedContent}
</div>
{:else}
<textarea
placeholder="Take a note..."
bind:value={content}
class="min-h-[100px] w-full resize-none bg-transparent px-4 py-2 text-sm text-gray-800 outline-none placeholder:text-gray-500 dark:text-gray-200"
rows="6"
data-testid="note-content-input"
></textarea>
{/if}
<!-- Toolbar -->
<div class="flex items-center justify-between border-t border-gray-200/50 px-2 py-2 dark:border-gray-700/50">
<div class="flex items-center gap-1">
<!-- Color picker toggle -->
<div class="relative">
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10"
title="Background color"
data-testid="color-picker-toggle"
>
<svg class="h-5 w-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
{#if showColorPicker}
<div class="absolute left-0 top-full mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<ColorPicker selected={color} onSelect={handleColorSelect} />
</div>
{/if}
</div>
<!-- Preview toggle -->
<button
onclick={() => (showPreview = !showPreview)}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10"
title={showPreview ? 'Edit' : 'Preview'}
data-testid="preview-toggle"
>
<svg class="h-5 w-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if showPreview}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
{/if}
</svg>
</button>
<!-- Checklist mode toggle -->
<button
onclick={() => (checklistMode = !checklistMode)}
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10 {checklistMode ? 'text-amber-600' : ''}"
title="Checklist mode"
data-testid="checklist-toggle"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
</div>
<button
onclick={save}
class="rounded px-4 py-1 text-sm font-medium text-gray-700 hover:bg-black/10 dark:text-gray-300 dark:hover:bg-white/10"
data-testid="close-editor-btn"
>
Close
</button>
</div>
</div>
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import NoteCard from './NoteCard.svelte';
import type { Note } from '$lib/types/index.js';
interface Props {
notes: Note[];
label?: string;
onEdit: (note: Note) => void;
}
let { notes, label = '', onEdit }: Props = $props();
</script>
{#if notes.length > 0}
{#if label}
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{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}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
let query = $state('');
let originalNotes: Note[] = [];
let isSearching = $state(false);
async function handleSearch() {
if (!query.trim()) {
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
return;
}
if (!isSearching) {
notes.subscribe((n) => (originalNotes = n))();
isSearching = true;
}
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const results = await res.json();
notes.set(results);
}
}
function clearSearch() {
query = '';
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
}
</script>
<div class="relative max-w-2xl">
<div class="flex items-center rounded-lg bg-gray-100 px-4 py-2 dark:bg-gray-800">
<svg class="mr-3 h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search notes..."
bind:value={query}
oninput={handleSearch}
class="w-full bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-500 dark:text-gray-200"
data-testid="search-input"
/>
{#if query}
<button onclick={clearSearch} class="ml-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" aria-label="Clear search">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
// Placeholder for Phase 6 - will be connected to sync store
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
</script>
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
{#if status === 'synced'}
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{:else if status === 'syncing'}
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{:else if status === 'offline'}
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
{:else}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
{/if}
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { allTags, selectedTag } from '$lib/stores/notes.js';
function toggleTag(tag: string) {
selectedTag.update((current) => (current === tag ? null : tag));
}
</script>
{#if $allTags.length > 0}
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
{#each $allTags as tag}
<button
onclick={() => toggleTag(tag)}
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-amber-200 text-amber-900 dark:bg-amber-800 dark:text-amber-100' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}"
data-testid="tag-chip"
>
#{tag}
</button>
{/each}
</div>
{/if}
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { effectiveTheme, toggleTheme } from '$lib/stores/theme.js';
</script>
<button
onclick={toggleTheme}
class="rounded-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Toggle dark mode"
data-testid="theme-toggle"
>
{#if $effectiveTheme === 'dark'}
<svg class="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{:else}
<svg class="h-5 w-5 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{/if}
</button>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+63
View File
@@ -0,0 +1,63 @@
import { existsSync, mkdirSync } from 'fs';
import { writeFile, unlink } from 'fs/promises';
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';
const DATA_DIR = process.env.DATA_DIR || './data';
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
// Ensure attachments directory exists
if (!existsSync(ATTACHMENTS_DIR)) {
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
}
export async function saveAttachment(
noteId: string,
file: File
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
const ext = file.name.split('.').pop() || 'bin';
const filename = `${id}.${ext}`;
const filePath = join(ATTACHMENTS_DIR, filename);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
const [attachment] = await db
.insert(attachments)
.values({
id,
noteId,
filename: file.name,
mimeType: file.type,
size: file.size,
path: filePath,
createdAt: new Date()
})
.returning();
return attachment;
}
export async function getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
}
export async function getAttachmentsByNote(noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
}
export async function deleteAttachment(id: string) {
const attachment = await getAttachment(id);
if (attachment) {
try {
await unlink(attachment.path);
} catch {
// File may already be deleted
}
await db.delete(attachments).where(eq(attachments.id, id));
}
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './db/test-helpers.js';
import { users, sessions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './db/schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
});
describe('Auth - Password Hashing', () => {
it('should hash and verify a password correctly', async () => {
const password = 'mysecurepassword';
const hash = await argon2.hash(password);
expect(hash).not.toBe(password);
expect(await argon2.verify(hash, password)).toBe(true);
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
});
it('should produce different hashes for the same password', async () => {
const password = 'mysecurepassword';
const hash1 = await argon2.hash(password);
const hash2 = await argon2.hash(password);
expect(hash1).not.toBe(hash2);
expect(await argon2.verify(hash1, password)).toBe(true);
expect(await argon2.verify(hash2, password)).toBe(true);
});
});
describe('Auth - User Setup', () => {
it('should create a user with hashed password', async () => {
const password = 'testpassword123';
const hash = await argon2.hash(password);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
const user = await db.select().from(users).get();
expect(user).toBeDefined();
expect(user!.passwordHash).not.toBe(password);
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
});
it('should only allow one user', async () => {
await db.insert(users).values({
passwordHash: 'hash1',
createdAt: new Date()
});
const allUsers = await db.select().from(users);
expect(allUsers).toHaveLength(1);
});
});
describe('Auth - Sessions', () => {
it('should create and validate a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + 86400000)
});
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
expect(session).toBeDefined();
expect(session!.userId).toBe(user.id);
expect(session!.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it('should detect expired sessions', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() - 1000) // Expired
});
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
expect(session).toBeDefined();
expect(session!.expiresAt.getTime()).toBeLessThan(Date.now());
});
it('should delete a session', async () => {
const [user] = await db
.insert(users)
.values({ passwordHash: 'hash', createdAt: new Date() })
.returning();
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + 86400000)
});
await db.delete(sessions).where(eq(sessions.id, token));
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
expect(session).toBeUndefined();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { db } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export async function isSetupComplete(): Promise<boolean> {
const user = await db.select().from(users).get();
return !!user;
}
export async function setupUser(password: string): Promise<boolean> {
const existing = await db.select().from(users).get();
if (existing) return false; // Already set up
const hash = await argon2.hash(password);
await db.insert(users).values({
passwordHash: hash,
createdAt: new Date()
});
return true;
}
export async function verifyPassword(password: string): Promise<boolean> {
const user = await db.select().from(users).get();
if (!user) return false;
return argon2.verify(user.passwordHash, password);
}
export async function createSession(): Promise<string> {
const user = await db.select().from(users).get();
if (!user) throw new Error('No user exists');
const token = randomBytes(32).toString('hex');
await db.insert(sessions).values({
id: token,
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
});
return token;
}
export async function validateSession(token: string): Promise<boolean> {
if (!token) return false;
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
if (!session) return false;
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, token));
return false;
}
return true;
}
export async function deleteSession(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, token));
}
export async function cleanExpiredSessions(): Promise<void> {
const now = new Date();
// Delete all expired sessions by checking each one
const allSessions = await db.select().from(sessions);
for (const session of allSessions) {
if (session.expiresAt < now) {
await db.delete(sessions).where(eq(sessions.id, session.id));
}
}
}
+82
View File
@@ -0,0 +1,82 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema.js';
import { existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
const DATABASE_URL = process.env.DATABASE_URL || './data/crumbs.db';
// Ensure the data directory exists
const dir = dirname(DATABASE_URL);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const sqlite = new Database(DATABASE_URL);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Auto-create tables on first run
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
trashed INTEGER NOT NULL DEFAULT 0,
trashed_at INTEGER,
checklist_mode INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
`);
export const db = drizzle(sqlite, { schema });
export { sqlite };
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './test-helpers.js';
import { notes, noteTags, tags } from './schema.js';
import { eq, and } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => {
const testDb = createTestDb();
db = testDb.db;
});
describe('Notes CRUD', () => {
it('should create a note with default values', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Test Note',
content: 'Hello world',
createdAt: now,
updatedAt: now
});
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result).toBeDefined();
expect(result!.title).toBe('Test Note');
expect(result!.content).toBe('Hello world');
expect(result!.color).toBe('default');
expect(result!.pinned).toBe(false);
expect(result!.archived).toBe(false);
expect(result!.trashed).toBe(false);
expect(result!.checklistMode).toBe(false);
expect(result!.version).toBe(1);
});
it('should create a note with custom color and pinned state', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Colored Note',
content: '',
color: 'coral',
pinned: true,
createdAt: now,
updatedAt: now
});
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.color).toBe('coral');
expect(result!.pinned).toBe(true);
});
it('should read all active notes (not archived, not trashed)', async () => {
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
{
id: 'n2',
title: 'Archived',
content: '',
archived: true,
createdAt: now,
updatedAt: now
},
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
]);
const active = await db
.select()
.from(notes)
.where(and(eq(notes.archived, false), eq(notes.trashed, false)));
expect(active).toHaveLength(1);
expect(active[0].title).toBe('Active');
});
it('should update a note and increment version', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Original',
content: '',
createdAt: now,
updatedAt: now
});
const later = new Date(now.getTime() + 1000);
await db
.update(notes)
.set({ title: 'Updated', updatedAt: later, version: 2 })
.where(eq(notes.id, id));
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.title).toBe('Updated');
expect(result!.version).toBe(2);
});
it('should soft-delete a note (move to trash)', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'To Trash',
content: '',
createdAt: now,
updatedAt: now
});
await db.update(notes).set({ trashed: true, trashedAt: now }).where(eq(notes.id, id));
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.trashed).toBe(true);
expect(result!.trashedAt).toBeTruthy();
});
it('should permanently delete a note', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'To Delete',
content: '',
createdAt: now,
updatedAt: now
});
await db.delete(notes).where(eq(notes.id, id));
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result).toBeUndefined();
});
it('should archive and unarchive a note', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'To Archive',
content: '',
createdAt: now,
updatedAt: now
});
await db.update(notes).set({ archived: true }).where(eq(notes.id, id));
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.archived).toBe(true);
await db.update(notes).set({ archived: false }).where(eq(notes.id, id));
result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.archived).toBe(false);
});
it('should pin and unpin a note', async () => {
const id = uuidv4();
const now = new Date();
await db.insert(notes).values({
id,
title: 'Pin Me',
content: '',
createdAt: now,
updatedAt: now
});
await db.update(notes).set({ pinned: true }).where(eq(notes.id, id));
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.pinned).toBe(true);
await db.update(notes).set({ pinned: false }).where(eq(notes.id, id));
result = await db.select().from(notes).where(eq(notes.id, id)).get();
expect(result!.pinned).toBe(false);
});
});
describe('Notes with Tags', () => {
it('should associate tags with a note', async () => {
const noteId = uuidv4();
const now = new Date();
await db.insert(notes).values({
id: noteId,
title: 'Tagged Note',
content: '',
createdAt: now,
updatedAt: now
});
// Create tags
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
// Associate
await db.insert(noteTags).values([
{ noteId, tagId: tag1.id },
{ noteId, tagId: tag2.id }
]);
// Query
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, noteId));
expect(tagRows).toHaveLength(2);
expect(tagRows.map((t) => t.name).sort()).toEqual(['important', 'work']);
});
it('should cascade delete tags when note is deleted', async () => {
const noteId = uuidv4();
const now = new Date();
await db.insert(notes).values({
id: noteId,
title: '',
content: '',
createdAt: now,
updatedAt: now
});
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note
await db.delete(notes).where(eq(notes.id, noteId));
// note_tags should be gone
const remaining = await db
.select()
.from(noteTags)
.where(eq(noteTags.noteId, noteId));
expect(remaining).toHaveLength(0);
// Tag itself should still exist
const tagStillExists = await db.select().from(tags).where(eq(tags.id, tag.id)).get();
expect(tagStillExists).toBeDefined();
});
});
describe('Note ordering', () => {
it('should return pinned notes first', async () => {
const now = new Date();
await db.insert(notes).values([
{
id: 'unpinned',
title: 'Unpinned',
content: '',
pinned: false,
createdAt: now,
updatedAt: now
},
{
id: 'pinned',
title: 'Pinned',
content: '',
pinned: true,
createdAt: now,
updatedAt: now
}
]);
const result = await db
.select()
.from(notes)
.orderBy(({ pinned }) => [
// SQLite: true = 1, false = 0, DESC puts 1 first
eq(pinned, pinned) // just use raw desc
]);
// Manual approach: order by pinned desc
const { desc } = await import('drizzle-orm');
const ordered = await db
.select()
.from(notes)
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
expect(ordered[0].title).toBe('Pinned');
expect(ordered[1].title).toBe('Unpinned');
});
});
+71
View File
@@ -0,0 +1,71 @@
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
passwordHash: text('password_hash').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: integer('user_id')
.references(() => users.id)
.notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
export const notes = sqliteTable('notes', {
id: text('id').primaryKey(),
title: text('title').default('').notNull(),
content: text('content').default('').notNull(),
color: text('color').default('default').notNull(),
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
trashed: integer('trashed', { mode: 'boolean' }).default(false).notNull(),
trashedAt: integer('trashed_at', { mode: 'timestamp' }),
checklistMode: integer('checklist_mode', { mode: 'boolean' }).default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
version: integer('version').default(1).notNull()
});
export const tags = sqliteTable(
'tags',
{
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
);
export const noteTags = sqliteTable('note_tags', {
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
tagId: integer('tag_id')
.references(() => tags.id, { onDelete: 'cascade' })
.notNull()
});
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
filename: text('filename').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
path: text('path').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
export const syncLog = sqliteTable('sync_log', {
id: integer('id').primaryKey({ autoIncrement: true }),
noteId: text('note_id')
.references(() => notes.id)
.notNull(),
operation: text('operation').notNull(),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
clientId: text('client_id').notNull()
});
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from './test-helpers.js';
import { notes, tags, noteTags } from './schema.js';
import { and, eq, like, or } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>;
beforeEach(async () => {
const testDb = createTestDb();
db = testDb.db;
const now = new Date();
await db.insert(notes).values([
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
]);
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
});
describe('Search', () => {
it('should search by title', async () => {
const pattern = '%shopping%';
const results = await db
.select()
.from(notes)
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n1');
});
it('should search by content', async () => {
const pattern = '%tomato%';
const results = await db
.select()
.from(notes)
.where(and(eq(notes.trashed, false), like(notes.content, pattern)));
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n3');
});
it('should search across title and content', async () => {
const pattern = '%milk%';
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
expect(results).toHaveLength(1);
expect(results[0].id).toBe('n1');
});
it('should not include trashed notes', async () => {
const pattern = '%should not%';
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
expect(results).toHaveLength(0);
});
it('should return empty for no matches', async () => {
const pattern = '%nonexistent%';
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
expect(results).toHaveLength(0);
});
it('should be case-insensitive with LIKE', async () => {
const pattern = '%SHOPPING%';
const results = await db
.select()
.from(notes)
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
// SQLite LIKE is case-insensitive for ASCII by default
expect(results).toHaveLength(1);
});
it('should search by tag name', async () => {
const tagResults = await db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(like(tags.name, '%work%'));
expect(tagResults).toHaveLength(1);
expect(tagResults[0].noteId).toBe('n2');
});
});
+76
View File
@@ -0,0 +1,76 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema.js';
/**
* Create an in-memory SQLite database for testing.
* Each test gets a fresh database.
*/
export function createTestDb() {
const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
// Create tables
sqlite.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL
);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
trashed INTEGER NOT NULL DEFAULT 0,
trashed_at INTEGER,
checklist_mode INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
CREATE TABLE note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
path TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL,
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
`);
const db = drizzle(sqlite, { schema });
return { db, sqlite };
}
+104
View File
@@ -0,0 +1,104 @@
import { writable, derived } from 'svelte/store';
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
export const notes = writable<Note[]>([]);
export const currentFilter = writable<NoteFilter>('all');
export const selectedTag = writable<string | null>(null);
export const filteredNotes = derived(
[notes, selectedTag, currentFilter],
([$notes, $selectedTag, $filter]) => {
let result = $notes;
if ($filter === 'all') {
result = result.filter((n) => !n.trashed && !n.archived);
} else if ($filter === 'archived') {
result = result.filter((n) => n.archived && !n.trashed);
} else if ($filter === 'trashed') {
result = result.filter((n) => n.trashed);
}
if ($selectedTag) {
result = result.filter((n) => n.tags?.includes($selectedTag));
}
return result;
}
);
export const pinnedNotes = derived(filteredNotes, ($notes) =>
$notes.filter((n) => n.pinned)
);
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
$notes.filter((n) => !n.pinned)
);
export const allTags = derived(notes, ($notes) => {
const tagSet = new Set<string>();
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
return [...tagSet].sort();
});
export async function loadNotes(filter: NoteFilter = 'all') {
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data = await res.json();
notes.set(data);
currentFilter.set(filter);
}
}
export async function createNote(note: NoteCreate): Promise<Note | null> {
const res = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note)
});
if (res.ok) {
const created = await res.json();
notes.update((list) => [created, ...list]);
return created;
}
return null;
}
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
const res = await fetch(`/api/notes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (res.ok) {
const updated = await res.json();
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
return updated;
}
return null;
}
export async function deleteNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
return true;
}
return false;
}
export async function trashNote(id: string): Promise<Note | null> {
return updateNote(id, { trashed: true });
}
export async function restoreNote(id: string): Promise<Note | null> {
return updateNote(id, { trashed: false });
}
export async function archiveNote(id: string): Promise<Note | null> {
return updateNote(id, { archived: true });
}
export async function unarchiveNote(id: string): Promise<Note | null> {
return updateNote(id, { archived: false });
}
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
return updateNote(id, { pinned: !currentPinned });
}
+46
View File
@@ -0,0 +1,46 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
type Theme = 'light' | 'dark' | 'system';
function getInitialTheme(): Theme {
if (!browser) return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
}
function getEffectiveTheme(theme: Theme): 'light' | 'dark' {
if (theme !== 'system') return theme;
if (!browser) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export const themePreference = writable<Theme>(getInitialTheme());
export const effectiveTheme = writable<'light' | 'dark'>('light');
if (browser) {
themePreference.subscribe((theme) => {
localStorage.setItem('theme', theme);
const effective = getEffectiveTheme(theme);
effectiveTheme.set(effective);
document.documentElement.classList.toggle('dark', effective === 'dark');
});
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
themePreference.update((current) => {
if (current === 'system') {
const effective = getEffectiveTheme('system');
effectiveTheme.set(effective);
document.documentElement.classList.toggle('dark', effective === 'dark');
}
return current;
});
});
}
export function toggleTheme() {
themePreference.update((current) => {
const effective = getEffectiveTheme(current);
return effective === 'dark' ? 'light' : 'dark';
});
}
+128
View File
@@ -0,0 +1,128 @@
import { browser } from '$app/environment';
import type { Note } from '$lib/types/index.js';
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
import { mergeNotes } from './crdt.js';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
let syncStatus: SyncStatus = 'synced';
let syncInterval: ReturnType<typeof setInterval> | null = null;
const statusListeners: Set<(status: SyncStatus) => void> = new Set();
export function onSyncStatusChange(listener: (status: SyncStatus) => void) {
statusListeners.add(listener);
return () => statusListeners.delete(listener);
}
function setStatus(status: SyncStatus) {
syncStatus = status;
statusListeners.forEach((l) => l(status));
}
export function getSyncStatus(): SyncStatus {
return syncStatus;
}
/**
* Push local changes to server.
*/
async function pushChanges(): Promise<boolean> {
const queue = await getSyncQueue();
if (queue.length === 0) return true;
try {
const res = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes: queue })
});
if (res.ok) {
await clearSyncQueue();
return true;
}
return false;
} catch {
return false;
}
}
/**
* Pull remote changes and merge with local state.
*/
async function pullChanges(): Promise<Note[]> {
const lastSync = (await getMeta('lastSyncTimestamp')) || '0';
try {
const res = await fetch(`/api/sync?since=${lastSync}`);
if (!res.ok) return [];
const remoteNotes: Note[] = await res.json();
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const merged = mergeNotes(local, remote);
await putNote(merged);
} else {
await putNote(remote);
}
}
await setMeta('lastSyncTimestamp', String(Date.now()));
return remoteNotes;
} catch {
return [];
}
}
/**
* Full sync cycle: push then pull.
*/
export async function sync(): Promise<void> {
if (!browser || !navigator.onLine) {
setStatus('offline');
return;
}
setStatus('syncing');
try {
const pushed = await pushChanges();
if (!pushed) {
setStatus('error');
return;
}
await pullChanges();
setStatus('synced');
} catch {
setStatus('error');
}
}
/**
* Start background sync (every 30 seconds).
*/
export function startSync() {
if (!browser) return;
// Initial sync
sync();
// Periodic sync
syncInterval = setInterval(sync, 30_000);
// Sync on online/offline events
window.addEventListener('online', () => sync());
window.addEventListener('offline', () => setStatus('offline'));
}
export function stopSync() {
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { Note } from '$lib/types/index.js';
/**
* Last-Write-Wins (LWW) merge strategy per field.
* For a single-user app with multiple devices, this is sufficient.
* Each field is resolved by comparing updatedAt timestamps.
*/
export function mergeNotes(local: Note, remote: Note): Note {
// If remote is newer overall, take remote
const localTime = new Date(local.updatedAt).getTime();
const remoteTime = new Date(remote.updatedAt).getTime();
if (remoteTime > localTime) {
return { ...remote };
}
if (localTime > remoteTime) {
return { ...local };
}
// Same timestamp - prefer higher version number
if (remote.version > local.version) {
return { ...remote };
}
return { ...local };
}
/**
* Determine if a local note has changes compared to a remote note.
*/
export function hasChanges(local: Note, remote: Note): boolean {
return (
local.title !== remote.title ||
local.content !== remote.content ||
local.color !== remote.color ||
local.pinned !== remote.pinned ||
local.archived !== remote.archived ||
local.trashed !== remote.trashed ||
local.checklistMode !== remote.checklistMode ||
local.sortOrder !== remote.sortOrder
);
}
/**
* Generate a diff of changed fields between two notes.
*/
export function diffNotes(
oldNote: Note,
newNote: Note
): Partial<Note> {
const diff: Partial<Note> = {};
if (oldNote.title !== newNote.title) diff.title = newNote.title;
if (oldNote.content !== newNote.content) diff.content = newNote.content;
if (oldNote.color !== newNote.color) diff.color = newNote.color;
if (oldNote.pinned !== newNote.pinned) diff.pinned = newNote.pinned;
if (oldNote.archived !== newNote.archived) diff.archived = newNote.archived;
if (oldNote.trashed !== newNote.trashed) diff.trashed = newNote.trashed;
if (oldNote.checklistMode !== newNote.checklistMode) diff.checklistMode = newNote.checklistMode;
if (oldNote.sortOrder !== newNote.sortOrder) diff.sortOrder = newNote.sortOrder;
return diff;
}
+124
View File
@@ -0,0 +1,124 @@
import { openDB, type IDBPDatabase } from 'idb';
import type { Note } from '$lib/types/index.js';
const DB_NAME = 'crumbs';
const DB_VERSION = 1;
interface CrumbsDB {
notes: {
key: string;
value: Note;
indexes: {
'by-updated': Date;
};
};
syncQueue: {
key: number;
value: SyncQueueItem;
indexes: {
'by-timestamp': number;
};
};
meta: {
key: string;
value: { key: string; value: string };
};
}
export interface SyncQueueItem {
id?: number;
noteId: string;
operation: 'create' | 'update' | 'delete';
data?: Partial<Note>;
timestamp: number;
}
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
if (!dbPromise) {
dbPromise = openDB<CrumbsDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Notes store
if (!db.objectStoreNames.contains('notes')) {
const noteStore = db.createObjectStore('notes', { keyPath: 'id' });
noteStore.createIndex('by-updated', 'updatedAt');
}
// Sync queue
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', {
keyPath: 'id',
autoIncrement: true
});
syncStore.createIndex('by-timestamp', 'timestamp');
}
// Metadata (e.g., last sync timestamp, client ID)
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'key' });
}
}
});
}
return dbPromise;
}
// Notes operations
export async function getAllNotes(): Promise<Note[]> {
const db = await getDb();
return db.getAll('notes');
}
export async function getNote(id: string): Promise<Note | undefined> {
const db = await getDb();
return db.get('notes', id);
}
export async function putNote(note: Note): Promise<void> {
const db = await getDb();
await db.put('notes', note);
}
export async function deleteNoteFromIdb(id: string): Promise<void> {
const db = await getDb();
await db.delete('notes', id);
}
export async function clearNotes(): Promise<void> {
const db = await getDb();
await db.clear('notes');
}
// Sync queue operations
export async function addToSyncQueue(item: Omit<SyncQueueItem, 'id'>): Promise<void> {
const db = await getDb();
await db.add('syncQueue', item as SyncQueueItem);
}
export async function getSyncQueue(): Promise<SyncQueueItem[]> {
const db = await getDb();
return db.getAll('syncQueue');
}
export async function clearSyncQueue(): Promise<void> {
const db = await getDb();
await db.clear('syncQueue');
}
export async function removeSyncQueueItem(id: number): Promise<void> {
const db = await getDb();
await db.delete('syncQueue', id);
}
// Meta operations
export async function getMeta(key: string): Promise<string | undefined> {
const db = await getDb();
const result = await db.get('meta', key);
return result?.value;
}
export async function setMeta(key: string, value: string): Promise<void> {
const db = await getDb();
await db.put('meta', { key, value });
}
+71
View File
@@ -0,0 +1,71 @@
import { db } from '$lib/server/db/index.js';
import { notes, syncLog } from '$lib/server/db/schema.js';
import { eq, gt } from 'drizzle-orm';
import type { SyncQueueItem } from './idb.js';
/**
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
for (const change of changes) {
switch (change.operation) {
case 'create':
case 'update': {
if (!change.data) continue;
const existing = await db.select().from(notes).where(eq(notes.id, change.noteId)).get();
if (existing) {
// LWW: only update if incoming timestamp is newer
if (change.timestamp > existing.updatedAt.getTime()) {
await db
.update(notes)
.set({
...change.data,
updatedAt: new Date(change.timestamp),
version: existing.version + 1
})
.where(eq(notes.id, change.noteId));
}
} else if (change.operation === 'create' && change.data) {
await db.insert(notes).values({
id: change.noteId,
title: change.data.title || '',
content: change.data.content || '',
color: change.data.color || 'default',
pinned: change.data.pinned || false,
archived: change.data.archived || false,
trashed: change.data.trashed || false,
checklistMode: change.data.checklistMode || false,
sortOrder: change.data.sortOrder || 0,
createdAt: new Date(change.timestamp),
updatedAt: new Date(change.timestamp),
version: 1
});
}
break;
}
case 'delete': {
await db.delete(notes).where(eq(notes.id, change.noteId));
break;
}
}
// Log sync operation
await db.insert(syncLog).values({
noteId: change.noteId,
operation: change.operation,
timestamp: new Date(change.timestamp),
clientId: 'default'
});
}
}
/**
* Get all notes updated since a given timestamp.
*/
export async function getChangesSince(sinceTimestamp: number) {
const since = new Date(sinceTimestamp);
const allNotes = await db.select().from(notes);
// Filter notes updated after the timestamp
return allNotes.filter((n) => n.updatedAt > since);
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import { mergeNotes, hasChanges, diffNotes } from './crdt.js';
import type { Note } from '../types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
return {
id: 'test-id',
title: 'Test',
content: 'Content',
color: 'default',
pinned: false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: false,
sortOrder: 0,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
version: 1,
...overrides
};
}
describe('mergeNotes (LWW)', () => {
it('should prefer remote when remote is newer', () => {
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-01') });
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-02') });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Remote');
});
it('should prefer local when local is newer', () => {
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-02') });
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-01') });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
it('should prefer higher version when timestamps are equal', () => {
const ts = new Date('2024-01-01');
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 2 });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Remote');
});
it('should prefer local when timestamps and versions are equal', () => {
const ts = new Date('2024-01-01');
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 1 });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
});
describe('hasChanges', () => {
it('should detect title change', () => {
const a = makeNote({ title: 'A' });
const b = makeNote({ title: 'B' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect content change', () => {
const a = makeNote({ content: 'old' });
const b = makeNote({ content: 'new' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect color change', () => {
const a = makeNote({ color: 'default' });
const b = makeNote({ color: 'coral' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect pinned change', () => {
const a = makeNote({ pinned: false });
const b = makeNote({ pinned: true });
expect(hasChanges(a, b)).toBe(true);
});
it('should return false when notes are identical', () => {
const a = makeNote();
const b = makeNote();
expect(hasChanges(a, b)).toBe(false);
});
});
describe('diffNotes', () => {
it('should return only changed fields', () => {
const old = makeNote({ title: 'Old', content: 'Same' });
const updated = makeNote({ title: 'New', content: 'Same' });
const diff = diffNotes(old, updated);
expect(diff.title).toBe('New');
expect(diff.content).toBeUndefined();
});
it('should return empty object when no changes', () => {
const a = makeNote();
const b = makeNote();
const diff = diffNotes(a, b);
expect(Object.keys(diff)).toHaveLength(0);
});
it('should track multiple changed fields', () => {
const old = makeNote({ title: 'Old', color: 'default', pinned: false });
const updated = makeNote({ title: 'New', color: 'coral', pinned: true });
const diff = diffNotes(old, updated);
expect(diff.title).toBe('New');
expect(diff.color).toBe('coral');
expect(diff.pinned).toBe(true);
});
});
+77
View File
@@ -0,0 +1,77 @@
export interface Note {
id: string;
title: string;
content: string;
color: NoteColor;
pinned: boolean;
archived: boolean;
trashed: boolean;
trashedAt: Date | null;
checklistMode: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
version: number;
tags?: string[];
attachments?: Attachment[];
}
export interface NoteCreate {
title?: string;
content?: string;
color?: NoteColor;
pinned?: boolean;
checklistMode?: boolean;
}
export interface NoteUpdate {
title?: string;
content?: string;
color?: NoteColor;
pinned?: boolean;
archived?: boolean;
trashed?: boolean;
checklistMode?: boolean;
sortOrder?: number;
}
export interface Attachment {
id: string;
noteId: string;
filename: string;
mimeType: string;
size: number;
path: string;
createdAt: Date;
}
export interface Tag {
id: number;
name: string;
}
export interface SyncChange {
noteId: string;
operation: 'create' | 'update' | 'delete';
timestamp: Date;
clientId: string;
data?: Partial<Note>;
}
export type NoteColor =
| 'default'
| 'coral'
| 'peach'
| 'sand'
| 'mint'
| 'sage'
| 'fog'
| 'storm'
| 'dusk'
| 'blossom'
| 'clay'
| 'chalk';
export type NoteFilter = 'all' | 'archived' | 'trashed';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
+21
View File
@@ -0,0 +1,21 @@
import type { NoteColor } from '$lib/types/index.js';
export const NOTE_COLORS: Record<NoteColor, { light: string; dark: string; label: string }> = {
default: { light: '#ffffff', dark: '#202124', label: 'Default' },
coral: { light: '#faafa8', dark: '#77172e', label: 'Coral' },
peach: { light: '#f39f76', dark: '#692b17', label: 'Peach' },
sand: { light: '#fff8b8', dark: '#7c4a03', label: 'Sand' },
mint: { light: '#e2f6d3', dark: '#264d3b', label: 'Mint' },
sage: { light: '#b4ddd3', dark: '#0c625d', label: 'Sage' },
fog: { light: '#d4e4ed', dark: '#256377', label: 'Fog' },
storm: { light: '#aeccdc', dark: '#284255', label: 'Storm' },
dusk: { light: '#d3bfdb', dark: '#472e5b', label: 'Dusk' },
blossom: { light: '#f6e2dd', dark: '#6c394f', label: 'Blossom' },
clay: { light: '#e9e3d4', dark: '#4b443a', label: 'Clay' },
chalk: { light: '#efeff1', dark: '#232427', label: 'Chalk' }
};
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
value: value as NoteColor,
label
}));
+7
View File
@@ -0,0 +1,7 @@
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number): T {
let timeoutId: ReturnType<typeof setTimeout>;
return ((...args: unknown[]) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), ms);
}) as T;
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown, stripMarkdown } from './markdown.js';
describe('renderMarkdown', () => {
it('should render bold text', () => {
const html = renderMarkdown('**bold**');
expect(html).toContain('<strong>bold</strong>');
});
it('should render italic text', () => {
const html = renderMarkdown('*italic*');
expect(html).toContain('<em>italic</em>');
});
it('should render headings', () => {
const html = renderMarkdown('# Heading 1');
expect(html).toContain('<h1>Heading 1</h1>');
});
it('should render unordered lists', () => {
const html = renderMarkdown('- item 1\n- item 2');
expect(html).toContain('<li>item 1</li>');
expect(html).toContain('<li>item 2</li>');
});
it('should render ordered lists', () => {
const html = renderMarkdown('1. first\n2. second');
expect(html).toContain('<li>first</li>');
expect(html).toContain('<li>second</li>');
});
it('should render code blocks', () => {
const html = renderMarkdown('```\nconst x = 1;\n```');
expect(html).toContain('<code>');
expect(html).toContain('const x = 1;');
});
it('should render inline code', () => {
const html = renderMarkdown('Use `npm install`');
expect(html).toContain('<code>npm install</code>');
});
it('should render links', () => {
const html = renderMarkdown('[Google](https://google.com)');
expect(html).toContain('href="https://google.com"');
expect(html).toContain('Google');
});
it('should render tables', () => {
const md = '| A | B |\n|---|---|\n| 1 | 2 |';
const html = renderMarkdown(md);
expect(html).toContain('<table>');
expect(html).toContain('<td>1</td>');
});
it('should auto-linkify URLs', () => {
const html = renderMarkdown('Visit https://example.com');
expect(html).toContain('href="https://example.com"');
});
it('should convert line breaks', () => {
const html = renderMarkdown('line 1\nline 2');
expect(html).toContain('<br>');
});
});
describe('stripMarkdown', () => {
it('should remove markdown formatting', () => {
expect(stripMarkdown('**bold** and *italic*')).toBe('bold and italic');
});
it('should remove code blocks', () => {
expect(stripMarkdown('```\ncode\n```')).toBe('');
});
it('should remove inline code backticks', () => {
expect(stripMarkdown('Use `command`')).toBe('Use');
});
it('should extract link text', () => {
expect(stripMarkdown('[Click here](https://example.com)')).toBe('Click here');
});
it('should return empty for empty input', () => {
expect(stripMarkdown('')).toBe('');
});
});
+44
View File
@@ -0,0 +1,44 @@
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true
});
// Add task list support
md.core.ruler.after('inline', 'task-lists', (state) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type !== 'inline') continue;
const content = tokens[i].content;
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
const checked = content.startsWith('[x] ');
const checkbox = checked
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
: '<input type="checkbox" disabled class="task-checkbox" /> ';
tokens[i].content = content.slice(4);
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
// Prepend checkbox
const token = new state.Token('html_inline', '', 0);
token.content = checkbox;
tokens[i].children!.unshift(token);
}
}
});
export function renderMarkdown(content: string): string {
return md.render(content);
}
export function stripMarkdown(content: string): string {
// Simple strip for preview - remove markdown syntax
return content
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1')
.replace(/[#*_~\[\]]/g, '')
.trim();
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { extractTags } from './tags.js';
describe('extractTags', () => {
it('should extract simple hashtags', () => {
expect(extractTags('Hello #world')).toEqual(['world']);
});
it('should extract multiple hashtags', () => {
expect(extractTags('#work #personal #todo')).toEqual(['work', 'personal', 'todo']);
});
it('should deduplicate tags', () => {
expect(extractTags('#work some text #work')).toEqual(['work']);
});
it('should lowercase tags', () => {
expect(extractTags('#Work #IMPORTANT')).toEqual(['work', 'important']);
});
it('should handle tags with hyphens', () => {
expect(extractTags('#my-project')).toEqual(['my-project']);
});
it('should handle tags with underscores', () => {
expect(extractTags('#my_project')).toEqual(['my_project']);
});
it('should return empty array for no tags', () => {
expect(extractTags('No hashtags here')).toEqual([]);
});
it('should return empty array for empty string', () => {
expect(extractTags('')).toEqual([]);
});
it('should not match hashtags inside code blocks', () => {
expect(extractTags('```\n#not-a-tag\n```')).toEqual([]);
});
it('should not match hashtags inside inline code', () => {
expect(extractTags('Use `#channel` for that')).toEqual([]);
});
it('should not match hashtags in URLs', () => {
expect(extractTags('Visit https://example.com#section')).toEqual([]);
});
it('should handle tags at start of line', () => {
expect(extractTags('#first tag')).toEqual(['first']);
});
it('should handle mixed content with code and tags', () => {
const content = 'My #project uses `#define` and has a #deadline';
expect(extractTags(content)).toEqual(['project', 'deadline']);
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Extract #hashtags from text content.
* Matches hashtags that start with # followed by word characters.
* Does not match inside code blocks or URLs.
*/
export function extractTags(content: string): string[] {
if (!content) return [];
// Remove code blocks (``` ... ```) to avoid matching inside them
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
// Remove inline code (` ... `)
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
// Remove URLs
const withoutUrls = withoutInlineCode.replace(/https?:\/\/\S+/g, '');
const matches = withoutUrls.match(/(?:^|\s)#([\w-]+)/g);
if (!matches) return [];
const tags = matches.map((m) => m.trim().slice(1).toLowerCase());
return [...new Set(tags)];
}
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import '../app.css';
import Header from '$lib/components/Layout/Header.svelte';
import Sidebar from '$lib/components/Layout/Sidebar.svelte';
import Toast from '$lib/components/Layout/Toast.svelte';
import { loadNotes } from '$lib/stores/notes.js';
import '$lib/stores/theme.js';
import { onMount } from 'svelte';
let { children } = $props();
let sidebarOpen = $state(false);
onMount(() => {
loadNotes();
});
</script>
<div class="min-h-screen bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
<Sidebar open={sidebarOpen} />
<main class="pt-4 transition-all lg:ml-64">
<div class="mx-auto max-w-7xl px-4">
{@render children()}
</div>
</main>
<Toast />
</div>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import NoteGrid from '$lib/components/NoteGrid.svelte';
import NoteEditor from '$lib/components/NoteEditor.svelte';
import TagFilter from '$lib/components/TagFilter.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
let editingNote: Note | null = $state(null);
let showNewNote = $state(false);
function openEditor(note: Note) {
editingNote = note;
}
function closeEditor() {
editingNote = null;
showNewNote = false;
}
</script>
<svelte:head>
<title>Crumbs</title>
</svelte:head>
{#if $currentFilter === 'all'}
<div class="mx-auto mb-6 max-w-xl">
<button
onclick={() => (showNewNote = true)}
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-left text-sm text-gray-500 shadow-sm transition-shadow hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
data-testid="new-note-btn"
>
Take a note...
</button>
</div>
{/if}
<div class="mb-4">
<TagFilter />
</div>
{#if $currentFilter === 'archived'}
<h2 class="mb-4 text-lg font-medium text-gray-600 dark:text-gray-400">Archive</h2>
{:else if $currentFilter === 'trashed'}
<h2 class="mb-4 text-lg font-medium text-gray-600 dark:text-gray-400">Trash</h2>
{/if}
{#if $pinnedNotes.length > 0}
<div class="mb-6">
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} />
</div>
{/if}
<NoteGrid
notes={$unpinnedNotes}
label={$pinnedNotes.length > 0 ? 'Others' : ''}
onEdit={openEditor}
/>
{#if $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-gray-400 dark:text-gray-600">
<svg class="mb-4 h-24 w-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<p class="text-lg">
{#if $currentFilter === 'trashed'}
No notes in trash
{:else if $currentFilter === 'archived'}
No archived notes
{:else if $selectedTag}
No notes with tag #{$selectedTag}
{:else}
Notes you add appear here
{/if}
</p>
</div>
{/if}
{#if showNewNote}
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} />
{/if}
+26
View File
@@ -0,0 +1,26 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { verifyPassword, createSession } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ request, cookies }) => {
const { password } = await request.json();
if (!password) {
throw error(400, 'Password is required');
}
const valid = await verifyPassword(password);
if (!valid) {
throw error(401, 'Invalid password');
}
const token = await createSession();
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 // 30 days
});
return json({ success: true });
};
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { deleteSession } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ cookies }) => {
const token = cookies.get('session');
if (token) {
await deleteSession(token);
}
cookies.delete('session', { path: '/' });
return json({ success: true });
};
+31
View File
@@ -0,0 +1,31 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { setupUser, createSession, isSetupComplete } from '$lib/server/auth.js';
export const POST: RequestHandler = async ({ request, cookies }) => {
const alreadySetup = await isSetupComplete();
if (alreadySetup) {
throw error(400, 'Setup already completed');
}
const { password } = await request.json();
if (!password || password.length < 8) {
throw error(400, 'Password must be at least 8 characters');
}
const success = await setupUser(password);
if (!success) {
throw error(500, 'Failed to create user');
}
const token = await createSession();
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60
});
return json({ success: true }, { status: 201 });
};
+89
View File
@@ -0,0 +1,89 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
export const GET: RequestHandler = async ({ url }) => {
const filter = url.searchParams.get('filter') || 'all';
let conditions;
switch (filter) {
case 'archived':
conditions = and(eq(notes.archived, true), eq(notes.trashed, false));
break;
case 'trashed':
conditions = eq(notes.trashed, true);
break;
default:
conditions = and(eq(notes.archived, false), eq(notes.trashed, false));
}
const result = await db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
// Fetch tags for each note
const notesWithTags = await Promise.all(
result.map(async (note) => {
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return { ...note, tags: tagRows.map((t) => t.name) };
})
);
return json(notesWithTags);
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const now = new Date();
const id = body.id || uuidv4();
const newNote = {
id,
title: body.title || '',
content: body.content || '',
color: body.color || 'default',
pinned: body.pinned || false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: body.checklistMode || false,
sortOrder: body.sortOrder || 0,
createdAt: now,
updatedAt: now,
version: 1
};
await db.insert(notes).values(newNote);
// Extract and save tags
const content = `${newNote.title} ${newNote.content}`;
const extractedTags = extractTags(content);
await syncNoteTags(id, extractedTags);
return json({ ...newNote, tags: extractedTags }, { status: 201 });
};
async function syncNoteTags(noteId: string, tagNames: string[]) {
// Remove existing tag associations
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
for (const name of tagNames) {
// Upsert tag
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = await db.insert(tags).values({ name }).returning();
tagRow = result[0];
}
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
}
}
+92
View File
@@ -0,0 +1,92 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
export const GET: RequestHandler = async ({ params }) => {
const note = await db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!note) {
throw error(404, 'Note not found');
}
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return json({ ...note, tags: tagRows.map((t) => t.name) });
};
export const PATCH: RequestHandler = async ({ params, request }) => {
const body = await request.json();
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!existing) {
throw error(404, 'Note not found');
}
const now = new Date();
const updates: Record<string, unknown> = {
updatedAt: now,
version: existing.version + 1
};
if (body.title !== undefined) updates.title = body.title;
if (body.content !== undefined) updates.content = body.content;
if (body.color !== undefined) updates.color = body.color;
if (body.pinned !== undefined) updates.pinned = body.pinned;
if (body.archived !== undefined) updates.archived = body.archived;
if (body.trashed !== undefined) {
updates.trashed = body.trashed;
updates.trashedAt = body.trashed ? now : null;
}
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
await db.update(notes).set(updates).where(eq(notes.id, params.id));
const updated = await db.select().from(notes).where(eq(notes.id, params.id)).get();
// Re-sync tags if content or title changed
if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
await syncNoteTags(params.id, extractedTags);
}
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, params.id));
return json({ ...updated, tags: tagRows.map((t) => t.name) });
};
export const DELETE: RequestHandler = async ({ params }) => {
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!existing) {
throw error(404, 'Note not found');
}
await db.delete(notes).where(eq(notes.id, params.id));
return json({ success: true });
};
async function syncNoteTags(noteId: string, tagNames: string[]) {
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
for (const name of tagNames) {
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = await db.insert(tags).values({ name }).returning();
tagRow = result[0];
}
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
}
}
@@ -0,0 +1,53 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment } from '$lib/server/attachments.js';
import { readFile } from 'fs/promises';
export const GET: RequestHandler = async ({ params, url }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) {
const attachment = await getAttachment(attachmentId);
if (!attachment) throw error(404, 'Attachment not found');
const buffer = await readFile(attachment.path);
return new Response(buffer, {
headers: {
'Content-Type': attachment.mimeType,
'Content-Disposition': `inline; filename="${attachment.filename}"`
}
});
}
const list = await getAttachmentsByNote(params.id);
return json(list);
};
export const POST: RequestHandler = async ({ params, request }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
throw error(400, 'No file provided');
}
if (!file.type.startsWith('image/')) {
throw error(400, 'Only image files are allowed');
}
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw error(400, 'File too large (max 10MB)');
}
const attachment = await saveAttachment(params.id, file);
return json(attachment, { status: 201 });
};
export const DELETE: RequestHandler = async ({ url }) => {
const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required');
await deleteAttachment(attachmentId);
return json({ success: true });
};
+65
View File
@@ -0,0 +1,65 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { and, eq, like, or } from 'drizzle-orm';
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('q')?.trim();
if (!query) {
return json([]);
}
const pattern = `%${query}%`;
const results = await db
.select()
.from(notes)
.where(
and(
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
// Also search by tag name
const tagResults = await db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(like(tags.name, pattern));
const tagNoteIds = new Set(tagResults.map((r) => r.noteId));
const allNoteIds = new Set(results.map((n) => n.id));
// Fetch tag-matched notes not already in results
const extraNoteIds = [...tagNoteIds].filter((id) => !allNoteIds.has(id));
let extraNotes: typeof results = [];
if (extraNoteIds.length > 0) {
for (const id of extraNoteIds) {
const note = await db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.trashed, false)))
.get();
if (note) extraNotes.push(note);
}
}
const combined = [...results, ...extraNotes];
// Attach tags to each note
const withTags = await Promise.all(
combined.map(async (note) => {
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return { ...note, tags: tagRows.map((t) => t.name) };
})
);
return json(withTags);
};
+20
View File
@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
export const POST: RequestHandler = async ({ request }) => {
const { changes } = await request.json();
if (!Array.isArray(changes)) {
return json({ error: 'Invalid changes' }, { status: 400 });
}
await processSyncPush(changes);
return json({ success: true });
};
export const GET: RequestHandler = async ({ url }) => {
const since = parseInt(url.searchParams.get('since') || '0', 10);
const changes = await getChangesSince(since);
return json(changes);
};
+9
View File
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { tags } from '$lib/server/db/schema.js';
export const GET: RequestHandler = async () => {
const allTags = await db.select().from(tags);
return json(allTags);
};
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { goto } from '$app/navigation';
let password = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
errorMsg = '';
loading = true;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Invalid password';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Login - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md dark:bg-gray-800">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Crumbs</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Enter your password to continue</p>
</div>
<form onsubmit={handleLogin}>
{#if errorMsg}
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password"
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
data-testid="password-input"
required
/>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
data-testid="login-btn"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { goto } from '$app/navigation';
let password = $state('');
let confirmPassword = $state('');
let errorMsg = $state('');
let loading = $state(false);
async function handleSetup(e: Event) {
e.preventDefault();
errorMsg = '';
if (password.length < 8) {
errorMsg = 'Password must be at least 8 characters';
return;
}
if (password !== confirmPassword) {
errorMsg = 'Passwords do not match';
return;
}
loading = true;
try {
const res = await fetch('/api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) {
goto('/');
} else {
const data = await res.json();
errorMsg = data.message || 'Setup failed';
}
} catch {
errorMsg = 'Connection error';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Setup - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md dark:bg-gray-800">
<div class="mb-6 text-center">
<span class="text-5xl">🥨</span>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Welcome to Crumbs</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Set up your password to get started</p>
</div>
<form onsubmit={handleSetup}>
{#if errorMsg}
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400" data-testid="error-message">
{errorMsg}
</div>
{/if}
<input
type="password"
bind:value={password}
placeholder="Password (min 8 characters)"
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
data-testid="password-input"
required
/>
<input
type="password"
bind:value={confirmPassword}
placeholder="Confirm password"
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
data-testid="confirm-password-input"
required
/>
<button
type="submit"
disabled={loading}
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
data-testid="setup-btn"
>
{loading ? 'Setting up...' : 'Set password & start'}
</button>
</form>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "Crumbs by Bretzel",
"short_name": "Crumbs",
"description": "A self-hostable, offline-first note-taking app by Bretzel",
"start_url": "/",
"display": "standalone",
"background_color": "#202124",
"theme_color": "#f59e0b",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+12
View File
@@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
out: 'build'
})
}
};
export default config;
+11
View File
@@ -0,0 +1,11 @@
import { test, expect } from './helpers/fixtures.js';
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();
// Then the note editor is displayed
await expect(page.getByTestId('note-editor')).toBeVisible();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from '@playwright/test';
test.describe.serial('Authentication', () => {
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
// Given a user has not authenticated
// When they access the application
await page.goto('/');
// Then they are directed to the setup or login page
await expect(page).toHaveURL(/\/(setup|login)/);
});
test('Scenario: First-time user sees the account creation form', async ({ page }) => {
// Given no account has been created yet
// When the user visits the setup page
await page.goto('/setup');
// Then the password and confirmation fields and setup action are available
await expect(page.getByTestId('password-input')).toBeVisible();
await expect(page.getByTestId('confirm-password-input')).toBeVisible();
await expect(page.getByTestId('setup-btn')).toBeVisible();
});
test('Scenario: Account creation is rejected for a password below minimum length', async ({ page }) => {
// Given the user is setting up their account
await page.goto('/setup');
// When they submit a password shorter than 8 characters
await page.getByTestId('password-input').fill('short');
await page.getByTestId('confirm-password-input').fill('short');
await page.getByTestId('setup-btn').click();
// Then a validation error is shown
await expect(page.getByTestId('error-message')).toBeVisible();
});
test('Scenario: Account creation is rejected when passwords do not match', async ({ page }) => {
// Given the user is setting up their account
await page.goto('/setup');
// When they submit two different passwords
await page.getByTestId('password-input').fill('longpassword1');
await page.getByTestId('confirm-password-input').fill('longpassword2');
await page.getByTestId('setup-btn').click();
// Then a validation error is shown
await expect(page.getByTestId('error-message')).toBeVisible();
});
test('Scenario: User creates an account and gains access to the application', async ({ page }) => {
// Given the user accesses the application
await page.goto('/');
const url = page.url();
// When they authenticate with valid credentials
if (url.includes('/setup')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('confirm-password-input').fill('testpassword123');
await page.getByTestId('setup-btn').click();
} else if (url.includes('/login')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('login-btn').click();
}
// Then they have access to the main application
await page.waitForURL('/');
await expect(page.getByTestId('new-note-btn')).toBeVisible();
});
test('Scenario: Logged-out user loses access to the application', async ({ page }) => {
// Given the user is authenticated
await page.goto('/');
const url = page.url();
if (url.includes('/setup')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('confirm-password-input').fill('testpassword123');
await page.getByTestId('setup-btn').click();
await page.waitForURL('/');
} else if (url.includes('/login')) {
await page.getByTestId('password-input').fill('testpassword123');
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
}
// When they log out
await page.evaluate(() => fetch('/api/auth/logout', { method: 'POST' }));
// Then they are redirected to the login page
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Checklist', () => {
test('Scenario: Checklist UI replaces textarea when checklist mode is enabled', async ({ authenticatedPage: page }) => {
// Given the user is creating a new note
await page.getByTestId('new-note-btn').click();
// When the user enables checklist mode
await page.getByTestId('checklist-toggle').click();
// Then the checklist component is displayed
await expect(page.getByTestId('checklist')).toBeVisible();
// And the plain text area is hidden
await expect(page.getByTestId('note-content-input')).not.toBeVisible();
});
test('Scenario: Checklist item can be added and persisted', async ({ authenticatedPage: page }) => {
// When the user creates a checklist note with an item "Buy milk"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Shopping List');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Buy milk');
await page.getByTestId('close-editor-btn').click();
// Then the note appears in the list
await expect(page.getByText('Shopping List')).toBeVisible();
// When the user reopens the note
await page.getByTestId('note-card').click();
// Then the checklist is displayed with the saved item
await expect(page.getByTestId('checklist')).toBeVisible();
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
});
test('Scenario: Checking an item marks it as completed', async ({ authenticatedPage: page }) => {
// Given a checklist note with an item "Buy milk"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Tasks');
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Buy milk');
await page.getByTestId('close-editor-btn').click();
// When the user reopens the note and checks the item
await page.getByTestId('note-card').click();
await page.getByTestId('checklist-checkbox').first().check();
await page.getByTestId('close-editor-btn').click();
// Then reopening the note shows the item is still checked
await page.getByTestId('note-card').click();
await expect(page.getByTestId('checklist-checkbox').first()).toBeChecked();
});
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => {
// Given the user is editing a checklist note
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
// When the user presses Enter
await page.getByTestId('checklist-input').first().press('Enter');
// Then a second checklist item appears
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
});
test('Scenario: Empty checklist item is removed by pressing Backspace', async ({ authenticatedPage: page }) => {
// Given a checklist note with two items
await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item');
await page.getByTestId('checklist-input').first().press('Enter');
await expect(page.getByTestId('checklist-input')).toHaveCount(2);
// When the user presses Backspace on the empty second item
await page.getByTestId('checklist-input').nth(1).press('Backspace');
// Then only the first item remains
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
});
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();
await page.getByTestId('checklist-toggle').click();
await expect(page.getByTestId('checklist')).toBeVisible();
// When the user disables checklist mode
await page.getByTestId('checklist-toggle').click();
// Then the plain text area is displayed again
await expect(page.getByTestId('note-content-input')).toBeVisible();
await expect(page.getByTestId('checklist')).not.toBeVisible();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Dark Mode', () => {
test('Scenario: Dark mode can be toggled on and off', async ({ authenticatedPage: page }) => {
// When the user enables dark mode
await page.getByTestId('theme-toggle').click();
// Then the application is displayed in dark mode
const isDark = await page.evaluate(() =>
document.documentElement.classList.contains('dark')
);
expect(isDark).toBe(true);
// When the user disables dark mode
await page.getByTestId('theme-toggle').click();
// Then the application is displayed in light mode
const isLight = await page.evaluate(() =>
!document.documentElement.classList.contains('dark')
);
expect(isLight).toBe(true);
});
test('Scenario: Theme preference is remembered across page loads', async ({ authenticatedPage: page }) => {
// When the user enables dark mode
await page.getByTestId('theme-toggle').click();
// Then the preference is persisted
const theme = await page.evaluate(() => localStorage.getItem('theme'));
expect(theme).toBeTruthy();
});
});
+13
View File
@@ -0,0 +1,13 @@
import { existsSync, unlinkSync } from 'fs';
const TEST_DB = './data/test-crumbs.db';
export default function globalSetup() {
// Clean test database before E2E run so each run starts fresh
for (const ext of ['', '-journal', '-wal', '-shm']) {
const path = `${TEST_DB}${ext}`;
if (existsSync(path)) {
unlinkSync(path);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import { test as base, expect, type Page } from '@playwright/test';
const TEST_PASSWORD = 'testpassword123';
/**
* Extended test fixture that handles setup/auth.
*/
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
// Setup: create user and login
await setupAndLogin(page);
await use(page);
}
});
export async function setupAndLogin(page: Page) {
await page.goto('/');
const url = page.url();
if (url.includes('/setup')) {
// First-time setup - may race with other workers
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('confirm-password-input').fill(TEST_PASSWORD);
await page.getByTestId('setup-btn').click();
// Wait for either redirect to main page or an error (another worker completed setup first)
try {
await page.waitForURL('/', { timeout: 5000 });
} catch {
// Setup was completed by another worker - go to login instead
await page.goto('/login');
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
}
} else if (url.includes('/login')) {
// Login
await page.getByTestId('password-input').fill(TEST_PASSWORD);
await page.getByTestId('login-btn').click();
await page.waitForURL('/');
}
// else: already on main page (session still valid)
}
export { expect };
+64
View File
@@ -0,0 +1,64 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Notes CRUD', () => {
test('Scenario: Fresh account shows an empty notes list', async ({ authenticatedPage: page }) => {
// Given the user has no notes
// Then the empty state message is displayed
await expect(page.getByText('Notes you add appear here')).toBeVisible();
});
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => {
// When the user creates a note titled "My First Note" with content "Hello world!"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('My First Note');
await page.getByTestId('note-content-input').fill('Hello world!');
await page.getByTestId('close-editor-btn').click();
// Then the note is visible in the notes list
await expect(page.getByTestId('note-card')).toBeVisible();
await expect(page.getByText('My First Note')).toBeVisible();
});
test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => {
// Given a note titled "Original Title" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Original Title');
await page.getByTestId('note-content-input').fill('Original content');
await page.getByTestId('close-editor-btn').click();
// When the user changes the title to "Updated Title"
await page.getByTestId('note-card').click();
await page.getByTestId('note-title-input').clear();
await page.getByTestId('note-title-input').fill('Updated Title');
await page.getByTestId('close-editor-btn').click();
// Then the updated title is displayed
await expect(page.getByText('Updated Title')).toBeVisible();
});
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Delete Me" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Delete Me');
await page.getByTestId('close-editor-btn').click();
// When the user trashes the note
await page.getByTestId('note-card').hover();
await page.getByTestId('trash-btn').click();
// Then the note is no longer visible
await expect(page.getByText('Delete Me')).not.toBeVisible();
});
test('Scenario: Markdown content is rendered in the note preview', async ({ authenticatedPage: page }) => {
// Given a note with markdown content "**bold text**"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-content-input').fill('**bold text**');
// When the user switches to preview mode
await page.getByTestId('preview-toggle').click();
// Then the rendered preview contains the formatted text
await expect(page.getByTestId('note-preview')).toContainText('bold text');
});
});
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Offline Support', () => {
test('Scenario: Sync status is visible to the user', async ({ authenticatedPage: page }) => {
// Given the user is authenticated
// Then the sync status indicator is displayed
await expect(page.getByTestId('sync-indicator')).toBeVisible();
});
test('Scenario: Created note is immediately visible in the notes list', async ({ authenticatedPage: page }) => {
// When the user creates a note titled "Offline Test Note"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Offline Test Note');
await page.getByTestId('close-editor-btn').click();
// Then the note appears in the list
await expect(page.getByText('Offline Test Note')).toBeVisible();
});
});
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Organization Features', () => {
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
// Given a note titled "Pin Me" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Pin Me');
await page.getByTestId('close-editor-btn').click();
// When the user pins the note
await page.getByTestId('note-card').hover();
await page.getByTestId('pin-btn').click();
// Then the "Pinned" section is visible
await expect(page.getByText('Pinned')).toBeVisible();
});
test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Archive Me" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Archive Me');
await page.getByTestId('close-editor-btn').click();
// When the user archives the note
await page.getByTestId('note-card').hover();
await page.getByTestId('archive-btn').click();
// Then the note is no longer visible in the main view
await expect(page.getByText('Archive Me')).not.toBeVisible();
});
test('Scenario: Note color is changed via the color picker', async ({ authenticatedPage: page }) => {
// Given the user is creating a note titled "Colored Note"
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Colored Note');
// When the user sets the note color to "Coral"
await page.getByTestId('color-picker-toggle').click();
await page.getByTestId('color-coral').click();
await page.getByTestId('close-editor-btn').click();
// Then the note is saved and visible in the notes list
await expect(page.getByTestId('note-card')).toBeVisible();
});
test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => {
// Given a note tagged #important and an untagged note exist
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Tagged Note');
await page.getByTestId('note-content-input').fill('This is #important');
await page.getByTestId('close-editor-btn').click();
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Untagged Note');
await page.getByTestId('close-editor-btn').click();
// When the user filters by the #important tag
const tagChip = page.getByTestId('tag-chip').filter({ hasText: '#important' });
if (await tagChip.isVisible()) {
await tagChip.click();
// Then only the tagged note is visible
await expect(page.getByText('Tagged Note')).toBeVisible();
}
});
});
+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
test.describe('PWA', () => {
test('Scenario: Web manifest provides correct app metadata', async ({ page }) => {
// When the browser requests the web manifest
const response = await page.goto('/manifest.webmanifest');
// Then it returns successfully with the correct app name, display mode, and icons
expect(response?.status()).toBe(200);
const manifest = await response?.json();
expect(manifest?.name).toBe('Crumbs by Bretzel');
expect(manifest?.display).toBe('standalone');
expect(manifest?.icons?.length).toBeGreaterThan(0);
});
test('Scenario: Application declares a theme color for mobile browsers', async ({ page }) => {
// Given the application is loaded
await page.goto('/login');
// Then a theme-color meta tag is present
const themeColor = await page.getAttribute('meta[name="theme-color"]', 'content');
expect(themeColor).toBeTruthy();
});
});
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from './helpers/fixtures.js';
test.describe('Search', () => {
test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => {
// Given a note titled "Shopping List" and a note titled "Work Meeting" exist
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Shopping List');
await page.getByTestId('close-editor-btn').click();
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Work Meeting');
await page.getByTestId('close-editor-btn').click();
// When the user searches for "Shopping"
await page.getByTestId('search-input').fill('Shopping');
await page.waitForTimeout(500);
// Then "Shopping List" is visible in the results
await expect(page.getByText('Shopping List')).toBeVisible();
});
test('Scenario: Searching by content returns the matching note', async ({ authenticatedPage: page }) => {
// Given a note titled "Recipe" with content "Pasta with tomato sauce" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Recipe');
await page.getByTestId('note-content-input').fill('Pasta with tomato sauce');
await page.getByTestId('close-editor-btn').click();
// When the user searches for "tomato"
await page.getByTestId('search-input').fill('tomato');
await page.waitForTimeout(500);
// Then "Recipe" is visible in the results
await expect(page.getByText('Recipe')).toBeVisible();
});
test('Scenario: Searching for a nonexistent term yields no results', async ({ authenticatedPage: page }) => {
// When the user searches for a term that matches nothing
await page.getByTestId('search-input').fill('xyznonexistent');
await page.waitForTimeout(500);
// Then no notes are displayed
await expect(page.getByTestId('note-card')).not.toBeVisible();
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+7
View File
@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node'
}
});