mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
feat: Crumbs by Bretzel — self-hostable notes app MVP
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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 .
|
||||||
@@ -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
@@ -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
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+6076
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
Vendored
+13
@@ -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 {};
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -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
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()]
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
environment: 'node'
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user