mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-14 12:28:18 +02:00
Compare commits
@@ -22,3 +22,11 @@ NODE_ENV=development
|
|||||||
# AUTH_OIDC_CLIENT_ID=
|
# AUTH_OIDC_CLIENT_ID=
|
||||||
# AUTH_OIDC_CLIENT_SECRET=
|
# AUTH_OIDC_CLIENT_SECRET=
|
||||||
# AUTH_OIDC_DISPLAY_NAME=SSO
|
# AUTH_OIDC_DISPLAY_NAME=SSO
|
||||||
|
|
||||||
|
# --- Email Notifications (optional, auto-enabled when SMTP_HOST is set) ---
|
||||||
|
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=
|
||||||
|
# SMTP_PASS=
|
||||||
|
# SMTP_FROM=Crumbs <noreply@example.com>
|
||||||
|
|||||||
+76
-14
@@ -48,17 +48,83 @@ jobs:
|
|||||||
needs: bump
|
needs: bump
|
||||||
uses: ./.github/workflows/ci.yml
|
uses: ./.github/workflows/ci.yml
|
||||||
|
|
||||||
release:
|
build:
|
||||||
name: Build & Push Docker Image
|
name: Build Docker (${{ matrix.platform }})
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [bump, ci]
|
needs: [bump, ci]
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.bump.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
provenance: false
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||||
|
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.runner }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: Merge manifests & release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [bump, build]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.bump.outputs.tag }}
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
@@ -80,15 +146,11 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Create manifest list and push
|
||||||
uses: docker/build-push-action@v6
|
working-directory: ${{ runner.temp }}/digests
|
||||||
with:
|
run: |
|
||||||
context: .
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
push: true
|
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -99,7 +161,7 @@ jobs:
|
|||||||
pin-docker-compose:
|
pin-docker-compose:
|
||||||
name: Pin docker-compose to released version
|
name: Pin docker-compose to released version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [bump, release]
|
needs: [bump, merge]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+49
-17
@@ -46,11 +46,24 @@ jobs:
|
|||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm test:unit
|
- run: pnpm test:unit:coverage
|
||||||
|
|
||||||
|
- name: Coverage summary
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/coverage-summary.sh "Unit Test Coverage" coverage/unit/coverage-summary.json
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: unit-coverage
|
||||||
|
path: coverage/unit/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -65,24 +78,26 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Build (with coverage instrumentation)
|
||||||
id: playwright-cache
|
run: pnpm build
|
||||||
uses: actions/cache@v4
|
env:
|
||||||
with:
|
DATABASE_URL: ./data/test-crumbs.db
|
||||||
path: ~/.cache/ms-playwright
|
NODE_ENV: test
|
||||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
VITE_COVERAGE: 'true'
|
||||||
|
|
||||||
- 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
|
- run: pnpm test:e2e
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
VITE_COVERAGE: 'true'
|
||||||
|
HOME: /root
|
||||||
|
|
||||||
|
- name: Merge E2E coverage
|
||||||
|
if: always()
|
||||||
|
run: npx nyc report
|
||||||
|
|
||||||
|
- name: Coverage summary
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/coverage-summary.sh "E2E Test Coverage" coverage/e2e/coverage-summary.json
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
@@ -91,7 +106,14 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: e2e-coverage
|
||||||
|
path: coverage/e2e/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
name: Docker Build
|
name: Docker Build
|
||||||
@@ -99,5 +121,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker build -t crumbs:ci .
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
provenance: false
|
||||||
|
tags: crumbs:ci
|
||||||
|
cache-from: type=gha,scope=ci-docker
|
||||||
|
cache-to: type=gha,scope=ci-docker,mode=max
|
||||||
|
|||||||
@@ -13,13 +13,77 @@ jobs:
|
|||||||
ci:
|
ci:
|
||||||
uses: ./.github/workflows/ci.yml
|
uses: ./.github/workflows/ci.yml
|
||||||
|
|
||||||
release:
|
build:
|
||||||
name: Build & Push Docker Image
|
name: Build Docker (${{ matrix.platform }})
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: ci
|
needs: ci
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
provenance: false
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||||
|
outputs: type=image,"name=ghcr.io/${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.runner }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: Merge manifests & release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
@@ -40,15 +104,11 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Create manifest list and push
|
||||||
uses: docker/build-push-action@v6
|
working-directory: ${{ runner.temp }}/digests
|
||||||
with:
|
run: |
|
||||||
context: .
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
push: true
|
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -58,7 +118,7 @@ jobs:
|
|||||||
pin-docker-compose:
|
pin-docker-compose:
|
||||||
name: Pin docker-compose to released version
|
name: Pin docker-compose to released version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: release
|
needs: merge
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Screenshots
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
screenshots:
|
||||||
|
name: Capture Landing Page Screenshots
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install Playwright with system deps
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: pnpm build
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ./data/screenshots.db
|
||||||
|
|
||||||
|
- name: Capture screenshots
|
||||||
|
run: pnpm exec tsx scripts/screenshots.ts
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ./data/screenshots.db
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add website/assets/screenshot-*.png
|
||||||
|
git diff --cached --quiet && echo "No changes" && exit 0
|
||||||
|
git commit -m "chore: update landing page screenshots [skip ci]"
|
||||||
|
git push
|
||||||
@@ -32,6 +32,10 @@ vite.config.ts.timestamp-*
|
|||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
/coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
/.claude/
|
/.claude/
|
||||||
|
|
||||||
@@ -40,3 +44,7 @@ todo.md
|
|||||||
|
|
||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Superpowers brainstorm artifacts
|
||||||
|
.superpowers/
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"report-dir": "coverage/e2e",
|
||||||
|
"temp-dir": "coverage/e2e/raw",
|
||||||
|
"reporter": ["text", "html", "lcov", "json-summary"],
|
||||||
|
"include": ["src/**"],
|
||||||
|
"exclude": ["node_modules", "tests"]
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
|
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
|
|||||||
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
|
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
|
||||||
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
|
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
|
||||||
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at `src/lib/server/db/schema.ts`)
|
- **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)
|
- **Auth**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
|
||||||
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
|
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
|
||||||
- **Package manager**: pnpm
|
- **Package manager**: pnpm
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
|||||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||||
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
||||||
|
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
|
||||||
|
|
||||||
### Layout groups
|
### Layout groups
|
||||||
|
|
||||||
@@ -74,7 +75,12 @@ src/
|
|||||||
(app)/ # Layout group: main app (header, sidebar, footer)
|
(app)/ # Layout group: main app (header, sidebar, footer)
|
||||||
+layout.svelte # App shell with Header, Sidebar, Toast
|
+layout.svelte # App shell with Header, Sidebar, Toast
|
||||||
+page.svelte # Main notes view
|
+page.svelte # Main notes view
|
||||||
api/auth/ # Login, logout, setup endpoints
|
archive/ # Archived notes view
|
||||||
|
trash/ # Trashed notes view
|
||||||
|
tag/[name]/ # Notes filtered by tag
|
||||||
|
settings/ # Settings with tabbed layout (profile, MCP, users)
|
||||||
|
api/auth/ # Login, logout, setup, OAuth endpoints
|
||||||
|
api/admin/users/ # Admin user management (CRUD, role, sessions)
|
||||||
api/notes/ # CRUD + attachments
|
api/notes/ # CRUD + attachments
|
||||||
api/search/ # Full-text search
|
api/search/ # Full-text search
|
||||||
api/sync/ # Offline sync
|
api/sync/ # Offline sync
|
||||||
@@ -92,6 +98,10 @@ tests/
|
|||||||
unit/ # Vitest unit tests
|
unit/ # Vitest unit tests
|
||||||
e2e/ # Playwright e2e tests (Gherkin-style)
|
e2e/ # Playwright e2e tests (Gherkin-style)
|
||||||
helpers/fixtures.ts # Shared authenticatedPage fixture
|
helpers/fixtures.ts # Shared authenticatedPage fixture
|
||||||
|
website/ # Static landing page (crumbs.bretzel.app)
|
||||||
|
index.html # Landing page with feature cards, screenshots, deploy section
|
||||||
|
styles.css # Landing page styles (retro parchment theme)
|
||||||
|
assets/ # Favicon, screenshots
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -128,7 +138,9 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
|||||||
|
|
||||||
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
||||||
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
||||||
|
- **Main branch**: `main` — use as base for all PRs and feature branches
|
||||||
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
||||||
|
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
@@ -151,6 +163,13 @@ GitHub Actions with two workflows:
|
|||||||
|
|
||||||
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
|
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
|
||||||
|
|
||||||
|
## Database schema changes
|
||||||
|
|
||||||
|
When adding or modifying tables in `src/lib/server/db/schema.ts`:
|
||||||
|
|
||||||
|
- **If the table has a foreign key to `users`**: update `deleteUser()` in `src/lib/server/auth.ts` to delete from the new table before the user row is removed. Order matters — delete child rows before parent rows.
|
||||||
|
- **If the table has a foreign key to `notes`**: either add `onDelete: 'cascade'` in the schema definition, or add explicit cleanup in the note deletion logic.
|
||||||
|
|
||||||
## Testing philosophy: BDD/TDD
|
## Testing philosophy: BDD/TDD
|
||||||
|
|
||||||
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
|
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
|
||||||
@@ -168,6 +187,7 @@ Write tests first or alongside features. Tests serve as living documentation of
|
|||||||
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
|
- 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 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
|
- Auth tests use `test.describe.serial` because they depend on sequential database state
|
||||||
|
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
|
||||||
|
|
||||||
### Gherkin-style Given/When/Then
|
### Gherkin-style Given/When/Then
|
||||||
|
|
||||||
@@ -207,5 +227,6 @@ Keep docs in `docs/` up to date when making changes. If a feature, API endpoint,
|
|||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `docs/FEATURES.md` | User-facing feature list |
|
| `docs/FEATURES.md` | User-facing feature list |
|
||||||
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
|
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
|
||||||
|
| `docs/AUTH.md` | Authentication setup, OAuth/SSO provider guides (Authentik, Keycloak, etc.) |
|
||||||
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
|
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
|
||||||
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
|
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.PHONY: help dev build preview check test test-unit test-e2e lint \
|
.PHONY: help dev build preview check test test-unit test-e2e \
|
||||||
|
test-unit-coverage test-e2e-coverage lint \
|
||||||
db-push db-generate db-migrate db-studio docs-api \
|
db-push db-generate db-migrate db-studio docs-api \
|
||||||
install docker-build docker-up docker-down docker-logs clean \
|
install docker-build docker-up docker-down docker-logs clean \
|
||||||
release release-patch release-minor release-major
|
release release-patch release-minor release-major
|
||||||
@@ -27,6 +28,12 @@ test-unit: ## Run unit tests only
|
|||||||
test-e2e: ## Run e2e tests only
|
test-e2e: ## Run e2e tests only
|
||||||
pnpm test:e2e
|
pnpm test:e2e
|
||||||
|
|
||||||
|
test-unit-coverage: ## Run unit tests with coverage
|
||||||
|
pnpm test:unit:coverage
|
||||||
|
|
||||||
|
test-e2e-coverage: ## Run e2e tests with coverage
|
||||||
|
VITE_COVERAGE=true pnpm build && pnpm test:e2e && npx nyc report
|
||||||
|
|
||||||
lint: ## Run linter
|
lint: ## Run linter
|
||||||
pnpm lint
|
pnpm lint
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
|
|
||||||
# Crumbs by Bretzel
|
# Crumbs by Bretzel
|
||||||
|
|
||||||
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
|
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||||
|
|
||||||
## Quick Start
|
<p align="center">
|
||||||
|
<img src="website/assets/screenshot-grid.png" alt="Crumbs notes grid view" width="720">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (Recommended)
|
||||||
|
|
||||||
@@ -19,7 +23,56 @@ A self-hostable, offline-first note-taking app inspired by Google Keep. Part of
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:3000 and set your password on first visit.
|
Open http://localhost:3000 and create your admin account on first visit.
|
||||||
|
|
||||||
|
#### With SSO (optional)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
crumbs:
|
||||||
|
image: ghcr.io/bretzel-app/crumbs:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- crumbs-data:/data
|
||||||
|
environment:
|
||||||
|
- ORIGIN=https://notes.example.com
|
||||||
|
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||||
|
- AUTH_OIDC_CLIENT_ID=your-client-id
|
||||||
|
- AUTH_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
crumbs-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
|
||||||
|
|
||||||
|
#### With Email Notifications (optional)
|
||||||
|
|
||||||
|
Add SMTP settings to enable email notifications (share alerts, welcome emails, security alerts):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- SMTP_HOST=smtp.example.com
|
||||||
|
- SMTP_PORT=587
|
||||||
|
- SMTP_USER=your-username
|
||||||
|
- SMTP_PASS=your-password
|
||||||
|
- SMTP_FROM=Crumbs <noreply@example.com>
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-enabled when `SMTP_HOST` is set. Users can opt out in Settings > Preferences. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#email-notifications-optional) for details.
|
||||||
|
|
||||||
|
### Manual (Node.js)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> crumbs && cd crumbs
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
DATABASE_URL=./data/crumbs.db ORIGIN=http://localhost:3000 node build
|
||||||
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
@@ -41,8 +94,12 @@ pnpm build # Production build
|
|||||||
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
|
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
|
||||||
- Organize with #tags, pinning, archive, and trash
|
- Organize with #tags, pinning, archive, and trash
|
||||||
- Full-text search across titles, content, and tags
|
- Full-text search across titles, content, and tags
|
||||||
|
- Note version history — browse and restore previous versions
|
||||||
|
- Share notes with other users on the same instance
|
||||||
|
- Email notifications for shares, security alerts, and account events (optional SMTP)
|
||||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||||
- Single-user password auth (Argon2)
|
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
|
||||||
|
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
|
||||||
- Docker deployment with a single command
|
- Docker deployment with a single command
|
||||||
|
|
||||||
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||||
@@ -68,6 +125,7 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
|
| [Features](docs/FEATURES.md) | Detailed feature list and behavior |
|
||||||
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
|
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
|
||||||
|
| [Auth](docs/AUTH.md) | Password auth, OAuth/SSO setup (Authentik, Keycloak, etc.) |
|
||||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
|
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
|
||||||
| [API](docs/API.md) | REST API reference (auto-generated) |
|
| [API](docs/API.md) | REST API reference (auto-generated) |
|
||||||
| [Contributing](CONTRIBUTING.md) | How to contribute |
|
| [Contributing](CONTRIBUTING.md) | How to contribute |
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
crumbs:
|
crumbs:
|
||||||
image: ghcr.io/bretzel-app/crumbs:0.7.2
|
image: ghcr.io/bretzel-app/crumbs:0.26.0
|
||||||
container_name: crumbs
|
container_name: crumbs
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -11,6 +11,21 @@ services:
|
|||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
- DATABASE_URL=/data/crumbs.db
|
- DATABASE_URL=/data/crumbs.db
|
||||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||||
|
# OAuth / SSO (optional — providers auto-enable when configured)
|
||||||
|
- AUTH_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID:-}
|
||||||
|
- AUTH_GOOGLE_CLIENT_SECRET=${AUTH_GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- AUTH_GITHUB_CLIENT_ID=${AUTH_GITHUB_CLIENT_ID:-}
|
||||||
|
- AUTH_GITHUB_CLIENT_SECRET=${AUTH_GITHUB_CLIENT_SECRET:-}
|
||||||
|
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||||
|
- AUTH_OIDC_CLIENT_ID=${AUTH_OIDC_CLIENT_ID:-}
|
||||||
|
- AUTH_OIDC_CLIENT_SECRET=${AUTH_OIDC_CLIENT_SECRET:-}
|
||||||
|
- AUTH_OIDC_DISPLAY_NAME=${AUTH_OIDC_DISPLAY_NAME:-}
|
||||||
|
# Email notifications (optional — auto-enabled when SMTP_HOST is set)
|
||||||
|
- SMTP_HOST=${SMTP_HOST:-}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
- SMTP_USER=${SMTP_USER:-}
|
||||||
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
|
- SMTP_FROM=${SMTP_FROM:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+383
@@ -111,6 +111,17 @@ List notes
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"tags": [
|
"tags": [
|
||||||
"item"
|
"item"
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"noteId": "noteId",
|
||||||
|
"filename": "filename",
|
||||||
|
"mimeType": "mimeType",
|
||||||
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -157,6 +168,17 @@ Create a note
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"tags": [
|
"tags": [
|
||||||
"item"
|
"item"
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"noteId": "noteId",
|
||||||
|
"filename": "filename",
|
||||||
|
"mimeType": "mimeType",
|
||||||
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -192,6 +214,17 @@ Get a note
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"tags": [
|
"tags": [
|
||||||
"item"
|
"item"
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"noteId": "noteId",
|
||||||
|
"filename": "filename",
|
||||||
|
"mimeType": "mimeType",
|
||||||
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -247,6 +280,17 @@ Partial update — only include changed fields.
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"tags": [
|
"tags": [
|
||||||
"item"
|
"item"
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"noteId": "noteId",
|
||||||
|
"filename": "filename",
|
||||||
|
"mimeType": "mimeType",
|
||||||
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -277,6 +321,231 @@ Permanently delete a note
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Collaboration
|
||||||
|
|
||||||
|
### `GET /api/notes/{id}/collaborators`
|
||||||
|
|
||||||
|
List collaborators
|
||||||
|
|
||||||
|
Returns all collaborators for a note. Requires access (owner or collaborator).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `id` | path | string | yes | |
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"userId": 1,
|
||||||
|
"displayName": "displayName",
|
||||||
|
"email": "email",
|
||||||
|
"addedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/notes/{id}/collaborators`
|
||||||
|
|
||||||
|
Add a collaborator
|
||||||
|
|
||||||
|
Owner-only. Adds a user as collaborator on the note.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `id` | path | string | yes | |
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `201`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"userId": 1,
|
||||||
|
"displayName": "displayName",
|
||||||
|
"email": "email",
|
||||||
|
"addedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** `400` Invalid request (missing userId, owner as collaborator), `409` User is already a collaborator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE /api/notes/{id}/collaborators`
|
||||||
|
|
||||||
|
Remove a collaborator
|
||||||
|
|
||||||
|
Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `id` | path | string | yes | |
|
||||||
|
| `userId` | query | | yes | |
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sharing
|
||||||
|
|
||||||
|
### `POST /api/notes/{id}/share`
|
||||||
|
|
||||||
|
Create a public share link
|
||||||
|
|
||||||
|
Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `id` | path | string | yes | |
|
||||||
|
|
||||||
|
**Response:** `201`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "token",
|
||||||
|
"url": "url"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** `404` Note not found or not owner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE /api/notes/{id}/share`
|
||||||
|
|
||||||
|
Revoke a public share link
|
||||||
|
|
||||||
|
Owner-only. Removes the public share link for the note.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `id` | path | string | yes | |
|
||||||
|
|
||||||
|
**Response:** `204`
|
||||||
|
|
||||||
|
**Errors:** `404` Note not found or not owner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/shared/{token}`
|
||||||
|
|
||||||
|
Get a shared note (public)
|
||||||
|
|
||||||
|
No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
|
||||||
|
|
||||||
|
> No authentication required.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `token` | path | string | yes | |
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "title",
|
||||||
|
"content": "content",
|
||||||
|
"checklistMode": true,
|
||||||
|
"color": "default",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"filename": "filename",
|
||||||
|
"mimeType": "mimeType",
|
||||||
|
"size": 1,
|
||||||
|
"featured": true,
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** `404` Invalid token or note is trashed, `429` Rate limit exceeded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/shared/{token}/attachment/{attachmentId}`
|
||||||
|
|
||||||
|
Get an attachment from a shared note (public)
|
||||||
|
|
||||||
|
No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
|
||||||
|
|
||||||
|
> No authentication required.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `token` | path | string | yes | |
|
||||||
|
| `attachmentId` | path | string | yes | |
|
||||||
|
| `thumb` | query | string | no | Set to '1' to get the thumbnail version (1) |
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
**Errors:** `404` Invalid token, note trashed, or attachment not found, `429` Rate limit exceeded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
### `GET /api/users/search`
|
||||||
|
|
||||||
|
Search users
|
||||||
|
|
||||||
|
Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `q` | query | string | yes | Search query (matches email or displayName) |
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"displayName": "displayName",
|
||||||
|
"email": "email"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Attachments
|
## Attachments
|
||||||
|
|
||||||
### `GET /api/notes/{id}/attachments`
|
### `GET /api/notes/{id}/attachments`
|
||||||
@@ -291,6 +560,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|
|||||||
|------|----|------|----------|-------------|
|
|------|----|------|----------|-------------|
|
||||||
| `id` | path | string | yes | |
|
| `id` | path | string | yes | |
|
||||||
| `attachmentId` | query | string | no | |
|
| `attachmentId` | query | string | no | |
|
||||||
|
| `thumb` | query | string | no | Set to "1" to return the thumbnail instead of the full image (requires attachmentId) (1) |
|
||||||
|
|
||||||
**Response:** `200`
|
**Response:** `200`
|
||||||
|
|
||||||
@@ -302,6 +572,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|
|||||||
"filename": "filename",
|
"filename": "filename",
|
||||||
"mimeType": "mimeType",
|
"mimeType": "mimeType",
|
||||||
"size": 1,
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -330,6 +601,7 @@ Upload an image attachment
|
|||||||
"filename": "filename",
|
"filename": "filename",
|
||||||
"mimeType": "mimeType",
|
"mimeType": "mimeType",
|
||||||
"size": 1,
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -393,6 +665,17 @@ Searches across note titles, content, and tags. Excludes trashed notes.
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"tags": [
|
"tags": [
|
||||||
"item"
|
"item"
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"noteId": "noteId",
|
||||||
|
"filename": "filename",
|
||||||
|
"mimeType": "mimeType",
|
||||||
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -451,6 +734,17 @@ Pull changes since timestamp
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"tags": [
|
"tags": [
|
||||||
"item"
|
"item"
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"noteId": "noteId",
|
||||||
|
"filename": "filename",
|
||||||
|
"mimeType": "mimeType",
|
||||||
|
"size": 1,
|
||||||
|
"thumbnailPath": "thumbnailPath",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -498,3 +792,92 @@ Push local changes
|
|||||||
**Errors:** `400` Invalid changes payload
|
**Errors:** `400` Invalid changes payload
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
### `GET /api/settings/api-keys`
|
||||||
|
|
||||||
|
List API keys
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "name",
|
||||||
|
"keyPrefix": "keyPrefix",
|
||||||
|
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||||
|
"lastUsedAt": "2025-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/settings/api-keys`
|
||||||
|
|
||||||
|
Create API key
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `201`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"key": "key",
|
||||||
|
"name": "name",
|
||||||
|
"keyPrefix": "keyPrefix"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE /api/settings/api-keys/{id}`
|
||||||
|
|
||||||
|
Revoke API key
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Name | In | Type | Required | Description |
|
||||||
|
|------|----|------|----------|-------------|
|
||||||
|
| `id` | path | string | yes | |
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:** `404` Key not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP
|
||||||
|
|
||||||
|
### `POST /api/mcp`
|
||||||
|
|
||||||
|
MCP Streamable HTTP endpoint
|
||||||
|
|
||||||
|
Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200`
|
||||||
|
|
||||||
|
**Errors:** `400` Invalid session, `401` Invalid or missing API key
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+68
-6
@@ -83,32 +83,94 @@ For a single-user app with multiple devices:
|
|||||||
- New v4 uses Vite plugin (faster builds)
|
- New v4 uses Vite plugin (faster builds)
|
||||||
|
|
||||||
### Argon2
|
### Argon2
|
||||||
- Current best practice for password hashing
|
- Memory-hard password hashing (resists GPU attacks)
|
||||||
- Memory-hard (resists GPU attacks)
|
- See [AUTH.md](AUTH.md) for authentication details
|
||||||
- Winner of the Password Hashing Competition
|
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
|
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
|
||||||
|
|
||||||
Key tables:
|
Key tables:
|
||||||
- `users` - Single user (password hash)
|
- `users` - User accounts (password hash, OAuth provider link, role)
|
||||||
- `sessions` - Auth sessions (30-day TTL)
|
- `sessions` - Auth sessions (30-day TTL)
|
||||||
- `notes` - Core note data with version tracking
|
- `notes` - Core note data with version tracking
|
||||||
- `tags` - Unique tag names
|
- `tags` - Unique tag names
|
||||||
- `note_tags` - Many-to-many note ↔ tag
|
- `note_tags` - Many-to-many note ↔ tag
|
||||||
- `attachments` - Image file metadata (path + thumbnailPath)
|
- `attachments` - Image file metadata (path + thumbnailPath)
|
||||||
|
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
|
||||||
|
- `note_collaborators` - Sharing relationships (noteId + userId + addedBy)
|
||||||
|
- `note_user_state` - Per-user pin/archive/sortOrder for shared notes
|
||||||
|
- `shared_notes` - Public share links (noteId → token, 1:1 with cascade delete)
|
||||||
- `sync_log` - Sync operation history
|
- `sync_log` - Sync operation history
|
||||||
|
|
||||||
|
### Nested Checklist Serialization
|
||||||
|
|
||||||
|
Checklist items are stored as plain text in the note `content` field, one item per line. Child items use a 2-space indent prefix:
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Parent item
|
||||||
|
- [ ] Child item
|
||||||
|
- [x] Checked child item
|
||||||
|
- [x] Another parent
|
||||||
|
```
|
||||||
|
|
||||||
|
- A line starting with ` - [ ]` or ` - [x]` (2 spaces) is parsed as a child of the preceding top-level item.
|
||||||
|
- The `parentId` field on `ChecklistItem` is **runtime-only** — it is derived from position during parsing and never stored explicitly.
|
||||||
|
- This format is fully backward compatible: flat checklists (no indented lines) parse identically to before.
|
||||||
|
|
||||||
|
### Collaboration Access Control
|
||||||
|
|
||||||
|
Notes support sharing with other users on the same instance:
|
||||||
|
|
||||||
|
- **Owner**: Full control (edit, trash, delete, share/unshare)
|
||||||
|
- **Collaborator**: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via `note_user_state`
|
||||||
|
- Access checks use `canAccessNote()` / `requireNoteAccess()` / `requireNoteOwnership()` helpers
|
||||||
|
- `note_user_state` stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
|
||||||
|
- When owner trashes a note, it vanishes for all collaborators
|
||||||
|
- Collaborators can leave (remove themselves) from shared notes
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
|
||||||
|
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Endpoint**: `POST /api/mcp` (Streamable HTTP transport)
|
||||||
|
- **Auth**: Bearer token via API keys (generated in Settings)
|
||||||
|
- **Sessions**: Stateful per MCP spec — session ID in `mcp-session-id` header
|
||||||
|
- **Transport**: `WebStandardStreamableHTTPServerTransport` (works with SvelteKit's Web Standard Request/Response)
|
||||||
|
|
||||||
|
### Shared Logic
|
||||||
|
MCP tool handlers and REST API routes both call the same service layer (`src/lib/server/notes-service.ts`). This avoids duplicating business logic:
|
||||||
|
|
||||||
|
```
|
||||||
|
REST API routes ──┐
|
||||||
|
├──► notes-service.ts ──► Drizzle DB
|
||||||
|
MCP tool handlers ┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tools (14)
|
||||||
|
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Theme switching uses CSS variable overrides on the `<html>` element:
|
||||||
|
|
||||||
|
- **CSS**: `[data-theme="dark"]` attribute on `<html>` overrides all `--bg-*`, `--text-*`, `--border-*`, and `--primary-*` variables defined in `src/app.css`
|
||||||
|
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
|
||||||
|
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
|
||||||
|
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
|
||||||
|
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and returns the appropriate color variant per theme
|
||||||
|
|
||||||
## File Organization
|
## File Organization
|
||||||
|
|
||||||
```
|
```
|
||||||
src/lib/server/ # Server-only code (DB, auth, attachments)
|
src/lib/server/ # Server-only code (DB, auth, attachments, notes-service)
|
||||||
|
src/lib/server/mcp/ # MCP server + tool definitions
|
||||||
src/lib/sync/ # Sync engine (shared types, server + client implementations)
|
src/lib/sync/ # Sync engine (shared types, server + client implementations)
|
||||||
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
|
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
|
||||||
src/lib/components/ # Svelte UI components
|
src/lib/components/ # Svelte UI components
|
||||||
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
|
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
|
||||||
src/lib/types/ # TypeScript type definitions
|
src/lib/types/ # TypeScript type definitions
|
||||||
src/routes/api/ # REST API endpoints
|
src/routes/api/ # REST API + MCP endpoints
|
||||||
src/routes/ # Page routes (SvelteKit file-based routing)
|
src/routes/ # Page routes (SvelteKit file-based routing)
|
||||||
```
|
```
|
||||||
|
|||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
# Authentication
|
||||||
|
|
||||||
|
Crumbs supports password-based authentication and optional OAuth/SSO via Google, GitHub, or any OpenID Connect (OIDC) provider (e.g. Authentik, Keycloak, Okta, Auth0).
|
||||||
|
|
||||||
|
## First-run setup
|
||||||
|
|
||||||
|
On first launch, Crumbs presents a setup screen to create the initial admin account with email and password (minimum 8 characters). This account becomes the instance administrator.
|
||||||
|
|
||||||
|
## Password authentication
|
||||||
|
|
||||||
|
- Passwords are hashed with **Argon2** (argon2id variant)
|
||||||
|
- Sessions use httpOnly cookies with 30-day expiry
|
||||||
|
- Admins can reset passwords and revoke sessions for any user via Settings
|
||||||
|
|
||||||
|
## OAuth / SSO
|
||||||
|
|
||||||
|
OAuth providers are **auto-enabled** when their environment variables are set — no code changes needed. The login page dynamically shows buttons for each configured provider.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. User clicks an OAuth button on the login page
|
||||||
|
2. Crumbs redirects to the provider's authorization endpoint (with PKCE)
|
||||||
|
3. Provider authenticates the user and redirects back to Crumbs
|
||||||
|
4. Crumbs validates the response, extracts the user's email and name
|
||||||
|
5. If a matching user exists (by provider ID or email), a session is created
|
||||||
|
6. If no matching user exists, the login is rejected (invite-only model)
|
||||||
|
|
||||||
|
### Invite-only model
|
||||||
|
|
||||||
|
OAuth does **not** auto-create accounts. Users must be pre-created by an admin:
|
||||||
|
|
||||||
|
1. Go to **Settings > Users > Invite User**
|
||||||
|
2. Create the user with the **same email** they use on the OAuth provider
|
||||||
|
3. The user can now log in via OAuth — their account is automatically linked on first sign-in
|
||||||
|
|
||||||
|
After initial linking, the user is identified by their provider-specific ID (`sub` claim), so email changes on the provider side won't break login.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
#### Google OAuth
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `AUTH_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID |
|
||||||
|
| `AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret |
|
||||||
|
|
||||||
|
**Callback URL:** `https://<your-domain>/api/auth/oauth/google/callback`
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
|
||||||
|
2. Create an OAuth 2.0 Client ID (Web application)
|
||||||
|
3. Add the callback URL above as an authorized redirect URI
|
||||||
|
4. Copy the client ID and secret into your environment
|
||||||
|
|
||||||
|
#### GitHub OAuth
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `AUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
|
||||||
|
| `AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
|
||||||
|
|
||||||
|
**Callback URL:** `https://<your-domain>/api/auth/oauth/github/callback`
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||||
|
2. Create a new OAuth App
|
||||||
|
3. Set the authorization callback URL to the callback URL above
|
||||||
|
4. Copy the client ID and secret into your environment
|
||||||
|
|
||||||
|
#### Generic OIDC
|
||||||
|
|
||||||
|
Any OpenID Connect-compliant provider works via the generic OIDC integration. Crumbs automatically discovers endpoints via the provider's `/.well-known/openid-configuration` document.
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `AUTH_OIDC_ISSUER` | OIDC issuer URL (must serve `/.well-known/openid-configuration`) |
|
||||||
|
| `AUTH_OIDC_CLIENT_ID` | OIDC client ID |
|
||||||
|
| `AUTH_OIDC_CLIENT_SECRET` | OIDC client secret |
|
||||||
|
| `AUTH_OIDC_DISPLAY_NAME` | Button label on login page (default: `SSO`) |
|
||||||
|
|
||||||
|
**Callback URL:** `https://<your-domain>/api/auth/oauth/oidc/callback`
|
||||||
|
|
||||||
|
**Required scopes:** `openid`, `profile`, `email`
|
||||||
|
|
||||||
|
### Provider guides
|
||||||
|
|
||||||
|
#### Authentik
|
||||||
|
|
||||||
|
[Authentik](https://goauthentik.io/) is a self-hosted identity provider that works with Crumbs via the generic OIDC integration.
|
||||||
|
|
||||||
|
**1. Create a provider in Authentik:**
|
||||||
|
|
||||||
|
1. Open the Authentik admin panel
|
||||||
|
2. Go to **Applications > Providers > Create**
|
||||||
|
3. Select **OAuth2/OpenID Connect**
|
||||||
|
4. Configure:
|
||||||
|
- **Name:** `Crumbs`
|
||||||
|
- **Authorization flow:** `default-provider-authorization-implicit-consent` (or `explicit-consent` to prompt users)
|
||||||
|
- **Client type:** `Confidential`
|
||||||
|
- **Redirect URIs:** `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
|
||||||
|
- **Scopes:** `openid`, `profile`, `email`
|
||||||
|
5. Save and note the **Client ID** and **Client Secret**
|
||||||
|
|
||||||
|
**2. Create an application in Authentik:**
|
||||||
|
|
||||||
|
1. Go to **Applications > Applications > Create**
|
||||||
|
2. Configure:
|
||||||
|
- **Name:** `Crumbs`
|
||||||
|
- **Slug:** `crumbs`
|
||||||
|
- **Provider:** Select the `Crumbs` provider you just created
|
||||||
|
- **Launch URL:** `https://<your-crumbs-domain>` (optional, for Authentik's app dashboard)
|
||||||
|
3. Save
|
||||||
|
|
||||||
|
**3. Configure Crumbs:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
crumbs:
|
||||||
|
image: ghcr.io/bretzel-app/crumbs:latest
|
||||||
|
environment:
|
||||||
|
- ORIGIN=https://notes.example.com
|
||||||
|
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||||
|
- AUTH_OIDC_CLIENT_ID=<client-id>
|
||||||
|
- AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** The issuer URL for Authentik follows the pattern `https://<authentik-domain>/application/o/<app-slug>/`. This URL must serve a valid `/.well-known/openid-configuration` document — you can verify by visiting `https://<authentik-domain>/application/o/crumbs/.well-known/openid-configuration` in your browser.
|
||||||
|
|
||||||
|
**4. Pre-create users:**
|
||||||
|
|
||||||
|
In Crumbs, go to **Settings > Users > Invite User** and create accounts with emails matching your Authentik users. They can then log in via the "Authentik" button on the login page.
|
||||||
|
|
||||||
|
#### Keycloak
|
||||||
|
|
||||||
|
1. Create a new client in your Keycloak realm
|
||||||
|
2. Set **Client type** to `OpenID Connect`, **Client authentication** to `On`
|
||||||
|
3. Add the callback URL: `https://<your-crumbs-domain>/api/auth/oauth/oidc/callback`
|
||||||
|
4. Set the issuer URL to `https://<keycloak-domain>/realms/<realm-name>`
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
|
||||||
|
AUTH_OIDC_CLIENT_ID=crumbs
|
||||||
|
AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
AUTH_OIDC_DISPLAY_NAME=Keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Okta / Auth0
|
||||||
|
|
||||||
|
Use the issuer URL from your Okta org or Auth0 tenant:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Okta
|
||||||
|
AUTH_OIDC_ISSUER=https://your-org.okta.com
|
||||||
|
|
||||||
|
# Auth0
|
||||||
|
AUTH_OIDC_ISSUER=https://your-tenant.auth0.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker example with multiple providers
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
crumbs:
|
||||||
|
image: ghcr.io/bretzel-app/crumbs:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- crumbs-data:/data
|
||||||
|
environment:
|
||||||
|
- ORIGIN=https://notes.example.com
|
||||||
|
# Google
|
||||||
|
- AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
|
||||||
|
- AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||||
|
# GitHub
|
||||||
|
- AUTH_GITHUB_CLIENT_ID=Iv1.abc123
|
||||||
|
- AUTH_GITHUB_CLIENT_SECRET=abc123secret
|
||||||
|
# Authentik (OIDC)
|
||||||
|
- AUTH_OIDC_ISSUER=https://authentik.example.com/application/o/crumbs/
|
||||||
|
- AUTH_OIDC_CLIENT_ID=crumbs-client-id
|
||||||
|
- AUTH_OIDC_CLIENT_SECRET=crumbs-client-secret
|
||||||
|
- AUTH_OIDC_DISPLAY_NAME=Authentik
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
crumbs-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
All configured providers will appear as buttons on the login page.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- All OAuth flows use **PKCE** (Proof Key for Code Exchange) with S256 challenge method
|
||||||
|
- OAuth state and code verifier are stored as httpOnly cookies (10-minute expiry)
|
||||||
|
- Passwords are hashed with **Argon2** (never stored in plain text)
|
||||||
|
- Sessions expire after **30 days**
|
||||||
|
- The `ORIGIN` environment variable must match your actual domain for CSRF protection
|
||||||
|
- Always use **HTTPS** in production (via reverse proxy)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| OAuth button doesn't appear | Provider env vars not set or missing a required variable | Check all required env vars are set and restart |
|
||||||
|
| "No account found" after OAuth | User doesn't exist in Crumbs | Create the user via Settings with matching email |
|
||||||
|
| OIDC discovery fails | Issuer URL is wrong or unreachable | Verify `AUTH_OIDC_ISSUER` serves `/.well-known/openid-configuration` |
|
||||||
|
| Redirect URI mismatch | Callback URL in provider doesn't match `ORIGIN` | Ensure `ORIGIN` matches your domain and the callback URL uses the same origin |
|
||||||
|
| PKCE error | Provider doesn't support S256 PKCE | Most modern providers support it; check provider docs |
|
||||||
+47
-3
@@ -100,6 +100,8 @@ node build
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
|
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
|
||||||
@@ -108,6 +110,24 @@ node build
|
|||||||
| `PORT` | `3000` | HTTP port |
|
| `PORT` | `3000` | HTTP port |
|
||||||
| `NODE_ENV` | `development` | Set to `production` for deployment |
|
| `NODE_ENV` | `development` | Set to `production` for deployment |
|
||||||
|
|
||||||
|
### OAuth / SSO (optional)
|
||||||
|
|
||||||
|
Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported. Providers are auto-enabled when their env vars are set. See **[AUTH.md](AUTH.md)** for environment variables, callback URLs, provider-specific setup guides, and troubleshooting.
|
||||||
|
|
||||||
|
### Email Notifications (optional)
|
||||||
|
|
||||||
|
Email notifications are auto-enabled when `SMTP_HOST` is set. See [FEATURES.md](FEATURES.md#note-sharing--collaboration) for the full list of notification types.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SMTP_HOST` | — | SMTP server hostname (e.g. `smtp.example.com`) |
|
||||||
|
| `SMTP_PORT` | `587` | SMTP port (`587` for STARTTLS, `465` for SSL) |
|
||||||
|
| `SMTP_USER` | — | SMTP username (optional for unauthenticated relays) |
|
||||||
|
| `SMTP_PASS` | — | SMTP password |
|
||||||
|
| `SMTP_FROM` | `Crumbs <noreply@localhost>` | Sender address |
|
||||||
|
|
||||||
|
Users can opt out of share notifications in **Settings > Preferences**.
|
||||||
|
|
||||||
## Health Check
|
## Health Check
|
||||||
|
|
||||||
The Docker image includes a health check that pings `/login`:
|
The Docker image includes a health check that pings `/login`:
|
||||||
@@ -116,10 +136,34 @@ The Docker image includes a health check that pings `/login`:
|
|||||||
docker inspect --format='{{.State.Health.Status}}' crumbs
|
docker inspect --format='{{.State.Health.Status}}' crumbs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## MCP Server Configuration
|
||||||
|
|
||||||
|
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. See [ARCHITECTURE.md](ARCHITECTURE.md#mcp-server) for technical details and the full tool list.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Open **Settings** in the Crumbs UI
|
||||||
|
2. Create an API key (give it a name like "Claude Code")
|
||||||
|
3. Copy the key (shown only once)
|
||||||
|
4. Configure your MCP client:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"crumbs": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"url": "https://your-crumbs-instance/api/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- Always use HTTPS in production (via reverse proxy)
|
- 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
|
- The `ORIGIN` variable must match your actual domain for CSRF protection
|
||||||
- Sessions expire after 30 days
|
- API keys are SHA-256 hashed (never stored in plain text)
|
||||||
- Passwords are hashed with Argon2
|
- See [AUTH.md](AUTH.md#security-notes) for authentication security details (password hashing, session expiry, PKCE)
|
||||||
|
|||||||
+75
-8
@@ -25,8 +25,8 @@
|
|||||||
- Ordered lists (`1. item`)
|
- Ordered lists (`1. item`)
|
||||||
- Code blocks (triple backtick)
|
- Code blocks (triple backtick)
|
||||||
- Inline code (single backtick)
|
- Inline code (single backtick)
|
||||||
- Tables
|
|
||||||
- Links — insert, edit, and remove with URL input
|
- Links — insert, edit, and remove with URL input
|
||||||
|
- Tables (insert, add/delete rows and columns)
|
||||||
- Dividers (horizontal rules)
|
- Dividers (horizontal rules)
|
||||||
- Text alignment (left, center, right, justify)
|
- Text alignment (left, center, right, justify)
|
||||||
- Line breaks
|
- Line breaks
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
- Check/uncheck items with checkboxes
|
- Check/uncheck items with checkboxes
|
||||||
- Checked items get strikethrough styling
|
- Checked items get strikethrough styling
|
||||||
- Enter key adds new item, Backspace removes empty items
|
- Enter key adds new item, Backspace removes empty items
|
||||||
|
- **Nested checklists**: single-level nesting for sub-tasks
|
||||||
|
- Tab / Shift+Tab to indent or outdent an item (keyboard)
|
||||||
|
- Drag handle horizontal swipe for mobile indentation
|
||||||
|
- Checking a parent item checks all its children
|
||||||
|
- Done section groups checked children under their parent label
|
||||||
|
|
||||||
### Image Attachments
|
### Image Attachments
|
||||||
- Upload images via file picker or drag-and-drop
|
- Upload images via file picker or drag-and-drop
|
||||||
@@ -59,11 +64,17 @@
|
|||||||
- Tags inside code blocks and URLs are ignored
|
- Tags inside code blocks and URLs are ignored
|
||||||
|
|
||||||
### Color-Coded Notes
|
### Color-Coded Notes
|
||||||
12 colors matching Google Keep's palette:
|
12 note colors:
|
||||||
- Default, Coral, Peach, Sand, Mint, Sage
|
- Default, Coral, Peach, Sand, Mint, Sage
|
||||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||||
- Colors adapt to note theme
|
- Colors adapt to note theme
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
|
||||||
|
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
|
||||||
|
- Respects system `prefers-color-scheme` preference by default
|
||||||
|
- FOUC-free: theme applied before first paint via a blocking inline script
|
||||||
|
|
||||||
### Pin Notes
|
### Pin Notes
|
||||||
- Pin important notes to always appear at top
|
- Pin important notes to always appear at top
|
||||||
- Pinned section separated from unpinned notes
|
- Pinned section separated from unpinned notes
|
||||||
@@ -80,18 +91,59 @@
|
|||||||
- Restore notes from trash
|
- Restore notes from trash
|
||||||
- Permanently delete from trash
|
- Permanently delete from trash
|
||||||
|
|
||||||
|
### Note Sharing / Collaboration
|
||||||
|
- **Public link sharing**: share any note via a public URL — no login required for viewers
|
||||||
|
- Toggle public link on/off from the share dialog
|
||||||
|
- Copy shareable URL with one click
|
||||||
|
- Read-only public page with full markdown/checklist/image rendering
|
||||||
|
- Public links are automatically invalidated when a note is trashed
|
||||||
|
- Rate-limited public endpoints (60 req/min per IP)
|
||||||
|
- Globe icon on note cards indicates a public link is active
|
||||||
|
- Share notes with other users on the same instance
|
||||||
|
- User search autocomplete for adding collaborators
|
||||||
|
- Full collaboration: both owner and collaborators can edit content, color, and checklist mode
|
||||||
|
- Per-user state: pin, archive, and sort order are independent per user
|
||||||
|
- Owner-only actions: trash, delete, share/unshare
|
||||||
|
- Sharing indicator on note cards (Globe for public, Users for collaborators-only)
|
||||||
|
- Hover popover showing sharing status and collaborator names
|
||||||
|
- Share dialog accessible from editor toolbar
|
||||||
|
- Collaborators can leave shared notes
|
||||||
|
- Shared notes appear mixed in main list alongside owned notes
|
||||||
|
- Email notifications (requires SMTP config, opt-out in preferences):
|
||||||
|
- Share notifications when a note is shared with you
|
||||||
|
- Welcome email when admin creates your account
|
||||||
|
- Password reset notification
|
||||||
|
- Account deletion confirmation
|
||||||
|
- Email address change notification
|
||||||
|
- Role change notification (admin/user)
|
||||||
|
- Removed from shared note notification
|
||||||
|
- Shared note permanently deleted notification
|
||||||
|
- Account locked alert (after failed login attempts)
|
||||||
|
|
||||||
|
### Note Version History
|
||||||
|
- Browse previous versions of any note
|
||||||
|
- Versions are automatically saved when content changes
|
||||||
|
- Restore any previous version with one click
|
||||||
|
- Version snapshots are only created when content actually changes (not on metadata-only updates)
|
||||||
|
|
||||||
### Full-Text Search
|
### Full-Text Search
|
||||||
- Search across note titles, content, and tags
|
- Search across note titles, content, and tags
|
||||||
- Real-time search results as you type
|
- Real-time search results as you type
|
||||||
- Clear search to restore original view
|
- Clear search to restore original view
|
||||||
- Case-insensitive matching
|
- Case-insensitive matching
|
||||||
|
|
||||||
### Single-User Auth
|
### Authentication & OAuth / SSO
|
||||||
- First-run setup: create password (min 8 chars)
|
- Multi-user with role-based access (admin / user)
|
||||||
- Argon2 password hashing (industry standard)
|
- Password auth (Argon2) + optional OAuth/SSO (Google, GitHub, any OIDC provider)
|
||||||
- Session-based auth with 30-day expiry
|
- Session-based auth with active session management
|
||||||
- All routes protected except /login and /setup
|
- Invite-only OAuth model with PKCE-secured flows
|
||||||
- Logout clears session
|
- See [AUTH.md](AUTH.md) for full details and provider setup guides
|
||||||
|
|
||||||
|
### User Management (admin)
|
||||||
|
- Admin dashboard to create, list, and delete users
|
||||||
|
- Assign roles (admin / user)
|
||||||
|
- Reset passwords for any user
|
||||||
|
- Revoke all sessions (force logout) for any user
|
||||||
|
|
||||||
### PWA / Offline-First
|
### PWA / Offline-First
|
||||||
- Installable as standalone app
|
- Installable as standalone app
|
||||||
@@ -102,6 +154,21 @@
|
|||||||
- LWW conflict resolution for multi-device use
|
- LWW conflict resolution for multi-device use
|
||||||
- Sync status indicator (synced/syncing/offline/error)
|
- Sync status indicator (synced/syncing/offline/error)
|
||||||
|
|
||||||
|
### MCP Server (Model Context Protocol)
|
||||||
|
- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport)
|
||||||
|
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; pin, reorder notes; upload images
|
||||||
|
- API key authentication (Bearer token)
|
||||||
|
- Compatible with Claude Code, Claude Desktop, and other MCP clients
|
||||||
|
- Stateful sessions with automatic cleanup
|
||||||
|
|
||||||
|
### API Key Management
|
||||||
|
- Generate API keys from the Settings page
|
||||||
|
- Keys use `crumbs_` prefix for easy identification
|
||||||
|
- SHA-256 hashed storage (keys shown once at creation)
|
||||||
|
- Track last-used timestamps
|
||||||
|
- Revoke keys with confirmation dialog
|
||||||
|
- MCP client config example shown in Settings
|
||||||
|
|
||||||
### Docker Deployment
|
### Docker Deployment
|
||||||
- Multi-stage Dockerfile (build + slim runtime)
|
- Multi-stage Dockerfile (build + slim runtime)
|
||||||
- docker-compose.yml with persistent volume
|
- docker-compose.yml with persistent volume
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
# E2E BDD Testing Best Practices
|
||||||
|
|
||||||
|
This document defines the 10 best practices for writing E2E tests in Crumbs. All tests use Playwright with Gherkin-style BDD comments.
|
||||||
|
|
||||||
|
## 1. Declarative scenarios, not imperative scripts
|
||||||
|
|
||||||
|
Describe **what** the system does, not **how** the user clicks through the UI.
|
||||||
|
|
||||||
|
```
|
||||||
|
// Good: "When the user creates a note titled 'Shopping List'"
|
||||||
|
// Bad: "When the user clicks the + button, types 'Shopping List' in the title field, and clicks save"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the UI changes (button moves, field renames), only the Playwright code changes — the scenario comment stays stable.
|
||||||
|
|
||||||
|
## 2. Given describes state, not navigation
|
||||||
|
|
||||||
|
`Given` sets up preconditions. It should read like a database fixture, not a user journey.
|
||||||
|
|
||||||
|
```
|
||||||
|
// Good: "Given a note titled 'Old Note' exists in the trash"
|
||||||
|
// Bad: "Given the user creates a note, then clicks trash, then confirms deletion"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use API calls or helpers like `createNote()` in `Given` steps to avoid coupling tests to unrelated UI flows.
|
||||||
|
|
||||||
|
## 3. One behavior per scenario
|
||||||
|
|
||||||
|
Each `test()` should verify **one outcome**. If you need multiple `Then` assertions about different behaviors, split them.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good: Two focused scenarios
|
||||||
|
test('Trashed note disappears from main view', ...)
|
||||||
|
test('Trashed note appears in trash view', ...)
|
||||||
|
|
||||||
|
// Bad: One bloated scenario testing everything
|
||||||
|
test('Trash workflow', ...) // creates, trashes, checks main, checks trash, restores...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Scenario names describe outcomes, not actions
|
||||||
|
|
||||||
|
Name tests by the **observable result**, not the user action that triggers it.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good: test('Archived note no longer appears in the main view')
|
||||||
|
// Bad: test('User archives a note')
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes test reports read like a behavior specification.
|
||||||
|
|
||||||
|
## 5. Isolate test state completely
|
||||||
|
|
||||||
|
Each test should start from a **known, clean state**. Use API-based setup/teardown or the shared `authenticatedPage` fixture.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Use API to create/reset state before each test
|
||||||
|
test.beforeEach(async ({ authenticatedPage: page }) => {
|
||||||
|
await page.request.put('/api/preferences', { data: { ... } });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents the "test B fails because test A didn't clean up" problem — especially important since Playwright runs workers in parallel.
|
||||||
|
|
||||||
|
## 6. Use `test.describe.serial` only when truly sequential
|
||||||
|
|
||||||
|
Reserve `serial` for flows where state genuinely cascades (setup -> login -> session). For everything else, isolate with fixtures. Treat `serial` as a **code smell** — most tests should run in **any order**.
|
||||||
|
|
||||||
|
## 7. Abstract repeated UI actions into helpers
|
||||||
|
|
||||||
|
When multiple scenarios share the same UI interaction (e.g., "create a note"), extract it into a helper at the **intent level**.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// helpers/fixtures.ts
|
||||||
|
export async function createNote(page, title, content?) { ... }
|
||||||
|
|
||||||
|
// In tests — reads like BDD
|
||||||
|
await createNote(page, 'My Note', 'Hello');
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared `authenticatedPage` fixture and `noteCard()` locator are examples of this pattern. The `createNote()` helper abstracts the 4-step note creation flow used across most test files.
|
||||||
|
|
||||||
|
## 8. Apply the resilience test to every comment
|
||||||
|
|
||||||
|
Before committing a Gherkin comment, ask: *"Would this comment need to change if we redesigned the UI?"*
|
||||||
|
|
||||||
|
- If **yes** — rewrite to remove implementation details
|
||||||
|
- If **no** — it's at the right abstraction level
|
||||||
|
|
||||||
|
```
|
||||||
|
// Fails (mentions hover + button):
|
||||||
|
// "When the user hovers the card and clicks the 3-dot menu"
|
||||||
|
|
||||||
|
// Passes:
|
||||||
|
// "When the user opens the note actions menu"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Prefer `await expect(...).toBeVisible()` over arbitrary waits
|
||||||
|
|
||||||
|
Playwright's auto-waiting is powerful. Never use `page.waitForTimeout()` — it's flaky and slow. Assert on the **expected end state** instead.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good: Waits intelligently for the element
|
||||||
|
await expect(page.getByText('Note saved')).toBeVisible();
|
||||||
|
|
||||||
|
// Bad: Arbitrary delay
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
```
|
||||||
|
|
||||||
|
The exception is `waitForLoadState('networkidle')` after authentication redirects, where the full page reload makes auto-waiting insufficient.
|
||||||
|
|
||||||
|
## 10. Structure tests in the Given/When/Then rhythm
|
||||||
|
|
||||||
|
Use comments to maintain the three-act structure. This makes tests scannable and self-documenting.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
test('Pinned note appears at the top of the list', async ({ authenticatedPage: page }) => {
|
||||||
|
// Given a note titled "Important" exists
|
||||||
|
await createNote(page, 'Important');
|
||||||
|
|
||||||
|
// When the user pins the note
|
||||||
|
await pinNote(page, 'Important');
|
||||||
|
|
||||||
|
// Then it appears before unpinned notes
|
||||||
|
const firstCard = page.locator('[data-testid="note-card"]').first();
|
||||||
|
await expect(firstCard).toContainText('Important');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Practice | Category | Goal |
|
||||||
|
|----------|----------|------|
|
||||||
|
| 1. Declarative scenarios | Language | Stable comments despite UI changes |
|
||||||
|
| 2. Given = state | Language | Decouple setup from UI flow |
|
||||||
|
| 3. One behavior per test | Structure | Focused, debuggable tests |
|
||||||
|
| 4. Outcome-based names | Language | Test reports read as specs |
|
||||||
|
| 5. Isolate state | Reliability | No cross-test pollution |
|
||||||
|
| 6. Avoid serial | Reliability | Parallel-safe by default |
|
||||||
|
| 7. Extract helpers | Structure | DRY, intent-level code |
|
||||||
|
| 8. Resilience test | Language | UI-change-proof comments |
|
||||||
|
| 9. Auto-wait assertions | Reliability | No flaky timeouts |
|
||||||
|
| 10. Given/When/Then | Structure | Scannable three-act tests |
|
||||||
@@ -168,6 +168,202 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
description: Note not found
|
description: Note not found
|
||||||
|
|
||||||
|
/api/notes/{id}/collaborators:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
get:
|
||||||
|
summary: List collaborators
|
||||||
|
description: Returns all collaborators for a note. Requires access (owner or collaborator).
|
||||||
|
tags: [Collaboration]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Collaborator list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Collaborator'
|
||||||
|
post:
|
||||||
|
summary: Add a collaborator
|
||||||
|
description: Owner-only. Adds a user as collaborator on the note.
|
||||||
|
tags: [Collaboration]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [userId]
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Collaborator added, returns updated list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Collaborator'
|
||||||
|
'400':
|
||||||
|
description: Invalid request (missing userId, owner as collaborator)
|
||||||
|
'409':
|
||||||
|
description: User is already a collaborator
|
||||||
|
delete:
|
||||||
|
summary: Remove a collaborator
|
||||||
|
description: Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=self` to leave.
|
||||||
|
tags: [Collaboration]
|
||||||
|
parameters:
|
||||||
|
- name: userId
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
enum: [self]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Collaborator removed
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Success'
|
||||||
|
|
||||||
|
/api/notes/{id}/share:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
post:
|
||||||
|
summary: Create a public share link
|
||||||
|
description: Owner-only. Creates a public share URL for the note. Idempotent — returns existing token if already shared.
|
||||||
|
tags: [Sharing]
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Share link created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
'404':
|
||||||
|
description: Note not found or not owner
|
||||||
|
delete:
|
||||||
|
summary: Revoke a public share link
|
||||||
|
description: Owner-only. Removes the public share link for the note.
|
||||||
|
tags: [Sharing]
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Share link revoked
|
||||||
|
'404':
|
||||||
|
description: Note not found or not owner
|
||||||
|
|
||||||
|
/api/shared/{token}:
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
get:
|
||||||
|
summary: Get a shared note (public)
|
||||||
|
description: No authentication required. Returns the note content for a valid share token. Rate limited to 60 requests per minute per IP.
|
||||||
|
tags: [Sharing]
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Shared note data
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SharedNoteData'
|
||||||
|
'404':
|
||||||
|
description: Invalid token or note is trashed
|
||||||
|
'429':
|
||||||
|
description: Rate limit exceeded
|
||||||
|
|
||||||
|
/api/shared/{token}/attachment/{attachmentId}:
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: attachmentId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
get:
|
||||||
|
summary: Get an attachment from a shared note (public)
|
||||||
|
description: No authentication required. Serves image binary for an attachment belonging to the shared note. Rate limited.
|
||||||
|
tags: [Sharing]
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: thumb
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: ['1']
|
||||||
|
description: Set to '1' to get the thumbnail version
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Image binary
|
||||||
|
content:
|
||||||
|
image/*:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
'404':
|
||||||
|
description: Invalid token, note trashed, or attachment not found
|
||||||
|
'429':
|
||||||
|
description: Rate limit exceeded
|
||||||
|
|
||||||
|
/api/users/search:
|
||||||
|
get:
|
||||||
|
summary: Search users
|
||||||
|
description: Search registered users by email or display name. Excludes the current user. Any authenticated user can access.
|
||||||
|
tags: [Users]
|
||||||
|
parameters:
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Search query (matches email or displayName)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Matching users (max 10)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
|
||||||
/api/notes/{id}/attachments:
|
/api/notes/{id}/attachments:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -325,14 +521,144 @@ paths:
|
|||||||
'400':
|
'400':
|
||||||
description: Invalid changes payload
|
description: Invalid changes payload
|
||||||
|
|
||||||
|
/api/settings/api-keys:
|
||||||
|
get:
|
||||||
|
summary: List API keys
|
||||||
|
tags: [Settings]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: List of API keys (without hashes)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ApiKey'
|
||||||
|
post:
|
||||||
|
summary: Create API key
|
||||||
|
tags: [Settings]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: API key created (key shown only once)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
keyPrefix:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
/api/settings/api-keys/{id}:
|
||||||
|
delete:
|
||||||
|
summary: Revoke API key
|
||||||
|
tags: [Settings]
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Key revoked
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Success'
|
||||||
|
'404':
|
||||||
|
description: Key not found
|
||||||
|
|
||||||
|
/api/mcp:
|
||||||
|
post:
|
||||||
|
summary: MCP Streamable HTTP endpoint
|
||||||
|
description: Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
|
||||||
|
tags: [MCP]
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: JSON-RPC 2.0 message
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: JSON-RPC response (may be SSE stream)
|
||||||
|
'400':
|
||||||
|
description: Invalid session
|
||||||
|
'401':
|
||||||
|
description: Invalid or missing API key
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
sessionCookie:
|
sessionCookie:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
in: cookie
|
in: cookie
|
||||||
name: session
|
name: session
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
description: API key (crumbs_...)
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
PublicAttachment:
|
||||||
|
type: object
|
||||||
|
description: Sanitized attachment metadata for public sharing (no server paths)
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
mimeType:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
featured:
|
||||||
|
type: boolean
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
SharedNoteData:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
checklistMode:
|
||||||
|
type: boolean
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
enum: [default, coral, peach, sand, mint, sage, fog, storm, dusk, blossom, clay, chalk]
|
||||||
|
attachments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/PublicAttachment'
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
Success:
|
Success:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -452,3 +778,35 @@ components:
|
|||||||
description: Unix timestamp in milliseconds
|
description: Unix timestamp in milliseconds
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/NoteInput'
|
$ref: '#/components/schemas/NoteInput'
|
||||||
|
|
||||||
|
Collaborator:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
addedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
ApiKey:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
keyPrefix:
|
||||||
|
type: string
|
||||||
|
description: First 12 characters of the key for display
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
lastUsedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
|||||||
@@ -0,0 +1,781 @@
|
|||||||
|
# Dark Mode Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a dark parchment theme variant with system/light/dark toggle in Settings > Preferences.
|
||||||
|
|
||||||
|
**Architecture:** CSS `[data-theme="dark"]` attribute on `<html>` overrides CSS variables in `app.css`. A blocking inline script in `app.html` prevents FOUC. Theme preference uses the existing `UserPreferences` key-value system. A reactive `isDarkMode` rune in `theme.svelte.ts` drives note card color switching.
|
||||||
|
|
||||||
|
**Tech Stack:** SvelteKit, Svelte 5 runes, CSS custom properties, Tailwind CSS v4, Vitest, Playwright
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-20-dark-mode-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Modify | `src/app.css` | Add `--grid-dot` to `:root`, add `[data-theme="dark"]` block, update `body::before` |
|
||||||
|
| Modify | `src/app.html` | Add FOUC-prevention inline script |
|
||||||
|
| Modify | `src/lib/types/preferences.ts` | Add `theme` field to `UserPreferences` |
|
||||||
|
| Create | `src/lib/utils/theme.svelte.ts` | `applyTheme()`, reactive `isDarkMode` rune |
|
||||||
|
| Modify | `src/lib/utils/colors.ts` | Add `NOTE_COLORS_DARK`, export `getNoteColor()` |
|
||||||
|
| Modify | `src/routes/+layout.svelte` | Add theme `$effect` watcher |
|
||||||
|
| Modify | `src/routes/(app)/settings/preferences/+page.svelte` | Add theme segmented toggle |
|
||||||
|
| Modify | `src/lib/components/NoteCard.svelte` | Use `getNoteColor()` with `isDarkMode` |
|
||||||
|
| Modify | `src/lib/components/NoteEditor.svelte` | Use `getNoteColor()` with `isDarkMode` |
|
||||||
|
| Modify | `src/lib/components/ColorPicker.svelte` | Use dark color swatches when `isDarkMode` |
|
||||||
|
| Skip | `src/routes/(share)/s/[token]/+page.svelte` | Out of scope per spec (public share pages) |
|
||||||
|
| Modify | `.gitignore` | Add `.superpowers/` |
|
||||||
|
| Create | `tests/unit/theme.test.ts` | Unit tests for `applyTheme` and `isDarkMode` |
|
||||||
|
| Create | `tests/unit/colors.test.ts` | Unit tests for `getNoteColor` |
|
||||||
|
| Create | `tests/e2e/dark-mode.spec.ts` | E2E tests for theme toggle, persistence, system detection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: CSS Dark Theme Variables
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app.css:6-23` (`:root` block) and `src/app.css:36` (`body::before`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `--grid-dot` to `:root` and add `[data-theme="dark"]` block**
|
||||||
|
|
||||||
|
In `src/app.css`, add `--grid-dot: #1a1a2e;` to the end of the `:root` block, then add the dark theme block after `:root`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-base: #f0e6d3;
|
||||||
|
--bg-surface: #faf5eb;
|
||||||
|
--primary: #C8860A;
|
||||||
|
--primary-hover: #E8A020;
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-muted: #6b6272;
|
||||||
|
--border: #1a1a2e;
|
||||||
|
--border-subtle: #d4cabb;
|
||||||
|
--destructive: #a63d2f;
|
||||||
|
--error-bg: #f5e0db;
|
||||||
|
--error-border: #d4a69e;
|
||||||
|
--error-text: #8b2e1f;
|
||||||
|
--success-bg: #dce8d4;
|
||||||
|
--success-text: #3a5a2e;
|
||||||
|
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||||
|
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||||
|
--grid-dot: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-base: #1a1715;
|
||||||
|
--bg-surface: #2a2520;
|
||||||
|
--text: #e8dcc8;
|
||||||
|
--text-muted: #9a8e7e;
|
||||||
|
--border: #e8dcc8;
|
||||||
|
--border-subtle: #3a3530;
|
||||||
|
--destructive: #d4604e;
|
||||||
|
--error-bg: #3a2020;
|
||||||
|
--error-border: #5a3030;
|
||||||
|
--error-text: #e8a090;
|
||||||
|
--success-bg: #203020;
|
||||||
|
--success-text: #90c880;
|
||||||
|
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||||
|
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||||
|
--grid-dot: #e8dcc8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `body::before` to use `--grid-dot` variable**
|
||||||
|
|
||||||
|
Replace the hardcoded color in the `body::before` rule:
|
||||||
|
|
||||||
|
```css
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.03;
|
||||||
|
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
||||||
|
background-size: 4px 4px;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify by temporarily adding `data-theme="dark"` to `app.html`**
|
||||||
|
|
||||||
|
In `src/app.html`, temporarily change `<html lang="en">` to `<html lang="en" data-theme="dark">`. Run `make dev` and visually confirm dark background and light text. Then revert the change.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app.css
|
||||||
|
git commit -m "feat: add dark theme CSS variables and grid-dot extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Theme Preference Type
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/types/preferences.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `theme` field to `UserPreferences`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface UserPreferences {
|
||||||
|
theme: 'system' | 'light' | 'dark';
|
||||||
|
defaultNoteMode: 'richtext' | 'markdown';
|
||||||
|
defaultNoteColor: NoteColor;
|
||||||
|
hideFooter: boolean;
|
||||||
|
sidebarDefaultState: 'open' | 'collapsed';
|
||||||
|
notifyOnShare: boolean;
|
||||||
|
notifyOnCollabRemoved: boolean;
|
||||||
|
notifyOnNoteDeleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||||
|
theme: 'system',
|
||||||
|
defaultNoteMode: 'richtext',
|
||||||
|
defaultNoteColor: 'default',
|
||||||
|
hideFooter: false,
|
||||||
|
sidebarDefaultState: 'open',
|
||||||
|
notifyOnShare: true,
|
||||||
|
notifyOnCollabRemoved: true,
|
||||||
|
notifyOnNoteDeleted: true
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT add `theme` to `BOOLEAN_PREF_KEYS`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/types/preferences.ts
|
||||||
|
git commit -m "feat: add theme preference type (system/light/dark)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Theme Utility — `applyTheme` and `isDarkMode`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/utils/theme.svelte.ts`
|
||||||
|
- Test: `tests/unit/theme.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for theme utility**
|
||||||
|
|
||||||
|
Note: `theme.svelte.ts` uses module-scope `$state` which requires the Svelte compiler. The Vitest node environment does not process `.svelte.ts` runes correctly, so we test only the DOM side-effects of `applyTheme` (attribute and meta tag changes). The reactive `isDarkMode` / `getIsDarkMode()` behavior is covered by E2E tests instead.
|
||||||
|
|
||||||
|
Create `tests/unit/theme.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock $app/environment
|
||||||
|
vi.mock('$app/environment', () => ({ browser: true }));
|
||||||
|
|
||||||
|
describe('applyTheme', () => {
|
||||||
|
let applyTheme: (theme: 'system' | 'light' | 'dark') => void;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Reset DOM
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) meta.remove();
|
||||||
|
const metaEl = document.createElement('meta');
|
||||||
|
metaEl.setAttribute('name', 'theme-color');
|
||||||
|
metaEl.setAttribute('content', '#f0e6d3');
|
||||||
|
document.head.appendChild(metaEl);
|
||||||
|
|
||||||
|
// Reset module state
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('$lib/utils/theme.svelte.js');
|
||||||
|
applyTheme = mod.applyTheme;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets data-theme="dark" for dark mode', () => {
|
||||||
|
applyTheme('dark');
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes data-theme for light mode', () => {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
applyTheme('light');
|
||||||
|
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates meta theme-color for dark mode', () => {
|
||||||
|
applyTheme('dark');
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
expect(meta?.getAttribute('content')).toBe('#1a1715');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates meta theme-color for light mode', () => {
|
||||||
|
applyTheme('dark');
|
||||||
|
applyTheme('light');
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
expect(meta?.getAttribute('content')).toBe('#f0e6d3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to system for unknown values', () => {
|
||||||
|
// matchMedia returns false by default in jsdom → resolves to light
|
||||||
|
applyTheme('invalid' as 'system' | 'light' | 'dark');
|
||||||
|
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves system preference to dark when prefers-color-scheme is dark', () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query === '(prefers-color-scheme: dark)',
|
||||||
|
media: query,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
applyTheme('system');
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pnpm vitest run tests/unit/theme.test.ts`
|
||||||
|
Expected: FAIL — module not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `theme.svelte.ts`**
|
||||||
|
|
||||||
|
Create `src/lib/utils/theme.svelte.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
const LIGHT_THEME_COLOR = '#f0e6d3';
|
||||||
|
const DARK_THEME_COLOR = '#1a1715';
|
||||||
|
|
||||||
|
let darkMode = $state(false);
|
||||||
|
|
||||||
|
export function getIsDarkMode(): boolean {
|
||||||
|
return darkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: 'system' | 'light' | 'dark'): void {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (theme !== 'system' && theme !== 'light' && theme !== 'dark') {
|
||||||
|
theme = 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve system preference
|
||||||
|
let resolved = theme;
|
||||||
|
if (theme === 'system') {
|
||||||
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolved === 'dark';
|
||||||
|
darkMode = isDark;
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) {
|
||||||
|
meta.setAttribute('content', isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pnpm vitest run tests/unit/theme.test.ts`
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/utils/theme.svelte.ts tests/unit/theme.test.ts
|
||||||
|
git commit -m "feat: add theme utility with applyTheme and isDarkMode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Note Card Dark Colors
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/utils/colors.ts`
|
||||||
|
- Test: `tests/unit/colors.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for `getNoteColor`**
|
||||||
|
|
||||||
|
Create `tests/unit/colors.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getNoteColor, NOTE_COLORS, NOTE_COLORS_DARK } from '$lib/utils/colors.js';
|
||||||
|
|
||||||
|
describe('getNoteColor', () => {
|
||||||
|
it('returns light color when isDark is false', () => {
|
||||||
|
expect(getNoteColor('default', false)).toBe('#faf5eb');
|
||||||
|
expect(getNoteColor('coral', false)).toBe('#faafa8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns dark color when isDark is true', () => {
|
||||||
|
expect(getNoteColor('default', true)).toBe('#2a2520');
|
||||||
|
expect(getNoteColor('coral', true)).toBe('#4a2522');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default color for unknown color name', () => {
|
||||||
|
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
||||||
|
expect(getNoteColor('unknown' as any, true)).toBe('#2a2520');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has matching keys in light and dark color maps', () => {
|
||||||
|
const lightKeys = Object.keys(NOTE_COLORS).sort();
|
||||||
|
const darkKeys = Object.keys(NOTE_COLORS_DARK).sort();
|
||||||
|
expect(lightKeys).toEqual(darkKeys);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pnpm vitest run tests/unit/colors.test.ts`
|
||||||
|
Expected: FAIL — `getNoteColor` and `NOTE_COLORS_DARK` not found
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add dark colors and helper to `colors.ts`**
|
||||||
|
|
||||||
|
Update `src/lib/utils/colors.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { NoteColor } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
|
||||||
|
default: { bg: '#faf5eb', label: 'Default' },
|
||||||
|
coral: { bg: '#faafa8', label: 'Coral' },
|
||||||
|
peach: { bg: '#f39f76', label: 'Peach' },
|
||||||
|
sand: { bg: '#fff8b8', label: 'Sand' },
|
||||||
|
mint: { bg: '#e2f6d3', label: 'Mint' },
|
||||||
|
sage: { bg: '#b4ddd3', label: 'Sage' },
|
||||||
|
fog: { bg: '#d4e4ed', label: 'Fog' },
|
||||||
|
storm: { bg: '#aeccdc', label: 'Storm' },
|
||||||
|
dusk: { bg: '#d3bfdb', label: 'Dusk' },
|
||||||
|
blossom: { bg: '#f6e2dd', label: 'Blossom' },
|
||||||
|
clay: { bg: '#e9e3d4', label: 'Clay' },
|
||||||
|
chalk: { bg: '#efeff1', label: 'Chalk' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||||
|
default: { bg: '#2a2520', label: 'Default' },
|
||||||
|
coral: { bg: '#4a2522', label: 'Coral' },
|
||||||
|
peach: { bg: '#4a3020', label: 'Peach' },
|
||||||
|
sand: { bg: '#3a3520', label: 'Sand' },
|
||||||
|
mint: { bg: '#2a3a22', label: 'Mint' },
|
||||||
|
sage: { bg: '#223a32', label: 'Sage' },
|
||||||
|
fog: { bg: '#222e3a', label: 'Fog' },
|
||||||
|
storm: { bg: '#1e2a35', label: 'Storm' },
|
||||||
|
dusk: { bg: '#352540', label: 'Dusk' },
|
||||||
|
blossom: { bg: '#3a2830', label: 'Blossom' },
|
||||||
|
clay: { bg: '#302e28', label: 'Clay' },
|
||||||
|
chalk: { bg: '#2e2e30', label: 'Chalk' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getNoteColor(color: NoteColor, isDark: boolean): string {
|
||||||
|
const map = isDark ? NOTE_COLORS_DARK : NOTE_COLORS;
|
||||||
|
return (map[color] ?? map.default).bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
|
||||||
|
value: value as NoteColor,
|
||||||
|
label
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pnpm vitest run tests/unit/colors.test.ts`
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/utils/colors.ts tests/unit/colors.test.ts
|
||||||
|
git commit -m "feat: add dark note card colors and getNoteColor helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: FOUC Prevention Script
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app.html`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add inline script to `app.html`**
|
||||||
|
|
||||||
|
Insert after the `<meta name="theme-color">` tag (line 6) and before `%sveltekit.head%` (line 15):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||||
|
var t = p.theme || 'system';
|
||||||
|
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||||
|
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
if (t === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
var m = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (m) m.content = '#1a1715';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app.html
|
||||||
|
git commit -m "feat: add FOUC-prevention script for dark mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Reactive Theme in Root Layout
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/+layout.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add theme `$effect` to root layout**
|
||||||
|
|
||||||
|
The root layout currently imports CSS and registers the PWA service worker. Add the theme `$effect` here so both `(app)` and `(auth)` layouts get the theme applied. **Important:** Keep `initPreferences()` in `(app)/+layout.svelte` — do NOT move it here. The `(app)` layout reads `sidebarDefaultState` immediately after init, and moving init to the root layout would create a race condition. The root layout only watches the preference reactively via `$effect`, which fires after preferences are loaded.
|
||||||
|
|
||||||
|
On auth pages (login/setup), `initPreferences()` hasn't been called, so `prefs.theme` will be `'system'` (the default). The FOUC script already handles the initial theme from localStorage, and the `$effect` will apply the correct theme once it runs.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { pwaInfo } from 'virtual:pwa-info';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||||
|
import { applyTheme } from '$lib/utils/theme.svelte.js';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
let mediaQuery: MediaQueryList | undefined;
|
||||||
|
let systemListener: ((e: MediaQueryListEvent) => void) | undefined;
|
||||||
|
|
||||||
|
const prefs = $derived(getPreferences());
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (pwaInfo) {
|
||||||
|
const { registerSW } = await import('virtual:pwa-register');
|
||||||
|
registerSW({
|
||||||
|
immediate: true,
|
||||||
|
onRegistered(r: ServiceWorkerRegistration | undefined) {
|
||||||
|
console.log('SW Registered:', r);
|
||||||
|
},
|
||||||
|
onRegisterError(error: Error) {
|
||||||
|
console.log('SW registration error', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const theme = prefs.theme ?? 'system';
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
|
// Listen for system preference changes when in system mode
|
||||||
|
if (systemListener && mediaQuery) {
|
||||||
|
mediaQuery.removeEventListener('change', systemListener);
|
||||||
|
}
|
||||||
|
if (theme === 'system') {
|
||||||
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
systemListener = () => applyTheme('system');
|
||||||
|
mediaQuery.addEventListener('change', systemListener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (systemListener && mediaQuery) {
|
||||||
|
mediaQuery.removeEventListener('change', systemListener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
{@html webManifest}
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `initPreferences()` stays in `(app)/+layout.svelte`. The root layout only adds the theme `$effect` and does not call `initPreferences()`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify dev server works**
|
||||||
|
|
||||||
|
Run `make dev`, open the app, go to Settings > Preferences. The theme toggle doesn't exist yet, but the app should render without errors. Check browser console for any issues.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/+layout.svelte src/routes/\(app\)/+layout.svelte
|
||||||
|
git commit -m "feat: add reactive theme watcher in root layout"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Theme Toggle in Settings
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/(app)/settings/preferences/+page.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add theme segmented toggle**
|
||||||
|
|
||||||
|
Add as the first preference section (before "Default note mode"):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Theme -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
|
||||||
|
<div class="flex gap-1" role="group" aria-label="Theme">
|
||||||
|
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
|
||||||
|
<button
|
||||||
|
onclick={() => updatePreference('theme', value)}
|
||||||
|
class="rounded-sm border px-4 py-2 text-sm transition-colors {prefs.theme === value ? 'border-[var(--primary)] bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'border-[var(--border-subtle)] text-[var(--text)] hover:border-[var(--primary)]'}"
|
||||||
|
data-testid="pref-theme-{value}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manually test**
|
||||||
|
|
||||||
|
Run `make dev`. Go to Settings > Preferences. Click System / Light / Dark and confirm:
|
||||||
|
- Clicking "Dark" immediately applies dark theme
|
||||||
|
- Clicking "Light" reverts to light theme
|
||||||
|
- Clicking "System" follows OS preference
|
||||||
|
- Refreshing the page persists the choice (no FOUC)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/\(app\)/settings/preferences/+page.svelte
|
||||||
|
git commit -m "feat: add theme toggle to preferences settings"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update Components to Use Dark Note Colors
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/NoteCard.svelte:2,29-32`
|
||||||
|
- Modify: `src/lib/components/NoteEditor.svelte:12,194-197`
|
||||||
|
- Modify: `src/lib/components/ColorPicker.svelte`
|
||||||
|
|
||||||
|
**Important:** `getIsDarkMode()` must be called **inside** `$effect` or `$derived` blocks for reactivity. Calling it as a top-level `const` would capture the value once and never update when the theme changes. Each component already has an `$effect` block where it computes card/bg styles — add `getIsDarkMode()` inside those existing blocks.
|
||||||
|
|
||||||
|
Public share page (`src/routes/(share)/s/[token]/+page.svelte`) is out of scope per the spec.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `NoteCard.svelte`**
|
||||||
|
|
||||||
|
Replace `NOTE_COLORS` import with `getNoteColor` and `getIsDarkMode`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Change import at top of script -->
|
||||||
|
import { getNoteColor } from '$lib/utils/colors.js';
|
||||||
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||||
|
|
||||||
|
<!-- Inside the existing $effect block where cardStyle is computed (around lines 29-32): -->
|
||||||
|
$effect(() => {
|
||||||
|
// ... existing logic ...
|
||||||
|
const bg = getNoteColor(note.color, getIsDarkMode());
|
||||||
|
cardStyle = `background-color: ${bg}`;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the `NOTE_COLORS` import. `getIsDarkMode()` is called inside the `$effect` so it re-runs when the theme changes.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `NoteEditor.svelte`**
|
||||||
|
|
||||||
|
Same pattern — inside the existing `$effect` where `bgStyle` is computed:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
import { getNoteColor } from '$lib/utils/colors.js';
|
||||||
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||||
|
|
||||||
|
<!-- Inside the existing $effect (around lines 194-197): -->
|
||||||
|
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the `NOTE_COLORS` import.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `ColorPicker.svelte`**
|
||||||
|
|
||||||
|
The ColorPicker shows circular swatches. Use `$derived` for the dark mode check so swatches update reactively:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
|
||||||
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||||
|
|
||||||
|
<!-- Use getIsDarkMode() in the template directly (it's reactive in Svelte 5 template expressions): -->
|
||||||
|
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `NOTE_COLORS` import if no longer needed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify all components render correctly in both themes**
|
||||||
|
|
||||||
|
Run `make dev`. Toggle between light and dark in Settings > Preferences. Check:
|
||||||
|
- Note cards show correct dark/light background colors
|
||||||
|
- Color picker shows dark/light swatches
|
||||||
|
- Note editor uses correct background color
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/NoteCard.svelte src/lib/components/NoteEditor.svelte src/lib/components/ColorPicker.svelte
|
||||||
|
git commit -m "feat: use dark note card colors in all components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: E2E Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/e2e/dark-mode.spec.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write E2E tests**
|
||||||
|
|
||||||
|
Create `tests/e2e/dark-mode.spec.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from './helpers/fixtures.js';
|
||||||
|
|
||||||
|
test.describe('Dark mode', () => {
|
||||||
|
test('Scenario: Theme toggle persists selection across page reload', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// Given the user is on the preferences page
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
|
||||||
|
// When the user selects dark theme
|
||||||
|
await page.getByTestId('pref-theme-dark').click();
|
||||||
|
|
||||||
|
// Then the page applies dark theme
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
// When the page is reloaded
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Then the dark theme persists without flash
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
await expect(page.getByTestId('pref-theme-dark')).toHaveClass(/font-medium/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Light mode removes dark theme attribute', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// Given dark theme is active
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
await page.getByTestId('pref-theme-dark').click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
// When the user selects light theme
|
||||||
|
await page.getByTestId('pref-theme-light').click();
|
||||||
|
|
||||||
|
// Then the dark theme attribute is removed
|
||||||
|
await expect(page.locator('html')).not.toHaveAttribute('data-theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: System mode follows OS dark preference', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// Given the OS prefers dark mode
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
|
||||||
|
// When the user selects system theme
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
await page.getByTestId('pref-theme-system').click();
|
||||||
|
|
||||||
|
// Then the page applies dark theme from system preference
|
||||||
|
// Use toHaveAttribute with timeout to handle async $effect execution
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark', { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Note cards use dark colors in dark mode', async ({
|
||||||
|
authenticatedPage: page
|
||||||
|
}) => {
|
||||||
|
// Given dark theme is active
|
||||||
|
await page.goto('/settings/preferences');
|
||||||
|
await page.getByTestId('pref-theme-dark').click();
|
||||||
|
|
||||||
|
// When viewing the notes page
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Then note cards should have dark background colors
|
||||||
|
const noteCard = page.getByTestId('note-card').first();
|
||||||
|
if (await noteCard.isVisible()) {
|
||||||
|
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
|
// Dark default color is #2a2520 = rgb(42, 37, 32)
|
||||||
|
expect(bg).not.toBe('rgb(250, 245, 235)'); // Not the light default
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run E2E tests**
|
||||||
|
|
||||||
|
Run: `pnpm test:e2e tests/e2e/dark-mode.spec.ts`
|
||||||
|
Expected: All PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/e2e/dark-mode.spec.ts
|
||||||
|
git commit -m "test: add E2E tests for dark mode toggle and persistence"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Housekeeping and Type Check
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `.superpowers/` to `.gitignore`**
|
||||||
|
|
||||||
|
Append to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Superpowers brainstorm artifacts
|
||||||
|
.superpowers/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run type check**
|
||||||
|
|
||||||
|
Run: `pnpm check`
|
||||||
|
Expected: No errors. If there are type errors, fix them.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full test suite**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: All unit and E2E tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitignore
|
||||||
|
git commit -m "chore: add .superpowers/ to gitignore"
|
||||||
|
```
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Dark Mode / Theme Toggle — Design Spec
|
||||||
|
|
||||||
|
**Issue:** #26
|
||||||
|
**Date:** 2026-03-20
|
||||||
|
**Status:** Draft
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a dark variant of the retro parchment theme with a three-way toggle (system / light / dark) in Settings > Preferences. The dark theme preserves the retro 8-bit character — warm dark tones, not cold grays.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Toggle location:** Settings > Preferences only (no header icon)
|
||||||
|
- **UI pattern:** Segmented buttons (system / light / dark), matching existing `defaultNoteMode` toggle style
|
||||||
|
- **Implementation:** CSS `[data-theme="dark"]` attribute on `<html>`, variable overrides in `app.css`
|
||||||
|
- **Pixel grid texture:** Inverted to light dots (`#e8dcc8`) at 3% opacity in dark mode
|
||||||
|
- **Default:** `system` (follows `prefers-color-scheme`)
|
||||||
|
- **Light mode:** No `data-theme` attribute set (`:root` defaults apply). `data-theme="dark"` is only set for dark mode. This means no redundant `[data-theme="light"]` block is needed.
|
||||||
|
|
||||||
|
## 1. CSS Variable Overrides
|
||||||
|
|
||||||
|
Add `--grid-dot` to the existing `:root` block and add a `[data-theme="dark"]` block after it in `app.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* ...existing vars... */
|
||||||
|
--grid-dot: #1a1a2e; /* new — extracted from body::before */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-base: #1a1715;
|
||||||
|
--bg-surface: #2a2520;
|
||||||
|
--text: #e8dcc8;
|
||||||
|
--text-muted: #9a8e7e;
|
||||||
|
--border: #e8dcc8;
|
||||||
|
--border-subtle: #3a3530;
|
||||||
|
--destructive: #d4604e;
|
||||||
|
--error-bg: #3a2020;
|
||||||
|
--error-border: #5a3030;
|
||||||
|
--error-text: #e8a090;
|
||||||
|
--success-bg: #203020;
|
||||||
|
--success-text: #90c880;
|
||||||
|
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||||
|
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||||
|
--grid-dot: #e8dcc8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`--primary` and `--primary-hover` remain unchanged — gold works on both backgrounds.
|
||||||
|
|
||||||
|
`--card-shadow` uses `var(--border-subtle)` in both modes for consistency — the dark `--border-subtle` (#3a3530) produces the right effect.
|
||||||
|
|
||||||
|
Update `body::before` to use the new variable:
|
||||||
|
```css
|
||||||
|
body::before {
|
||||||
|
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Theme Preference Storage
|
||||||
|
|
||||||
|
Extend the existing `UserPreferences` system:
|
||||||
|
|
||||||
|
- **Type change:** Add `theme: 'system' | 'light' | 'dark'` to `UserPreferences` interface
|
||||||
|
- **Default:** `'system'`
|
||||||
|
- **Storage:** Same key-value `userPreferences` table + localStorage cache — no schema migration needed
|
||||||
|
- **Do NOT add `theme` to `BOOLEAN_PREF_KEYS`** — it's a string enum, not a boolean
|
||||||
|
|
||||||
|
Files to modify:
|
||||||
|
- `src/lib/types/preferences.ts` — add `theme` field and default
|
||||||
|
|
||||||
|
The existing `fromServerRecord` in `preferences.svelte.ts` does not validate string values. The `applyTheme` function (Section 4) must guard against unexpected values and fall back to `'system'` if the value is not one of the three valid options.
|
||||||
|
|
||||||
|
## 3. FOUC Prevention
|
||||||
|
|
||||||
|
Add a blocking `<script>` in `app.html` `<head>`, **after** the `<meta name="theme-color">` tag and **before** `%sveltekit.head%`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||||
|
var t = p.theme || 'system';
|
||||||
|
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||||
|
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
if (t === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
var m = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (m) m.content = '#1a1715';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs synchronously before first paint. The script validates the theme value and null-checks the meta element.
|
||||||
|
|
||||||
|
## 4. Reactive Theme Application
|
||||||
|
|
||||||
|
Create `src/lib/utils/theme.svelte.ts` (`.svelte.ts` to enable Svelte 5 runes) with:
|
||||||
|
|
||||||
|
- `applyTheme(theme: 'system' | 'light' | 'dark')` — resolves system preference, sets/removes `data-theme="dark"` on `document.documentElement`, updates `<meta name="theme-color">`. Guards against unexpected values (falls back to `'system'`). All DOM/`matchMedia` access guarded with `browser` check from `$app/environment`.
|
||||||
|
- `isDarkMode` — a reactive `$state` boolean, updated by `applyTheme` and the `matchMedia` listener. Components import this to conditionally use dark color variants.
|
||||||
|
|
||||||
|
Place the theme `$effect` in the **root `+layout.svelte`** (not just `(app)` layout). This ensures both `(app)` and `(auth)` layout groups get the theme applied. The root layout currently only imports CSS — this adds a small `$effect` that watches `preferences.theme` and calls `applyTheme`. It also registers a `matchMedia('(prefers-color-scheme: dark)')` change listener when in system mode, cleaned up on destroy.
|
||||||
|
|
||||||
|
## 5. Note Card Dark Colors
|
||||||
|
|
||||||
|
Add a parallel `NOTE_COLORS_DARK` map in `src/lib/utils/colors.ts`:
|
||||||
|
|
||||||
|
| Color | Light | Dark |
|
||||||
|
|-------|-------|------|
|
||||||
|
| default | `#faf5eb` | `#2a2520` |
|
||||||
|
| coral | `#faafa8` | `#4a2522` |
|
||||||
|
| peach | `#f39f76` | `#4a3020` |
|
||||||
|
| sand | `#fff8b8` | `#3a3520` |
|
||||||
|
| mint | `#e2f6d3` | `#2a3a22` |
|
||||||
|
| sage | `#b4ddd3` | `#223a32` |
|
||||||
|
| fog | `#d4e4ed` | `#222e3a` |
|
||||||
|
| storm | `#aeccdc` | `#1e2a35` |
|
||||||
|
| dusk | `#d3bfdb` | `#352540` |
|
||||||
|
| blossom | `#f6e2dd` | `#3a2830` |
|
||||||
|
| clay | `#e9e3d4` | `#302e28` |
|
||||||
|
| chalk | `#efeff1` | `#2e2e30` |
|
||||||
|
|
||||||
|
Export a helper `getNoteColor(color: NoteColor, isDark: boolean): string` that returns the correct bg hex.
|
||||||
|
|
||||||
|
Components (`NoteCard.svelte`, `NoteEditor.svelte`, `ColorPicker.svelte`) import `isDarkMode` from `theme.svelte.ts` and pass it to `getNoteColor`. This is the single approach — do not use direct DOM inspection for dark mode detection.
|
||||||
|
|
||||||
|
## 6. Settings UI
|
||||||
|
|
||||||
|
Add a "Theme" section to `src/routes/(app)/settings/preferences/+page.svelte` as the **first** preference (before "Default note mode"):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<!-- Theme -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
|
||||||
|
<div class="flex gap-1" role="group" aria-label="Theme">
|
||||||
|
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
|
||||||
|
<button
|
||||||
|
onclick={() => updatePreference('theme', value)}
|
||||||
|
class="rounded-sm border px-4 py-2 text-sm transition-colors ..."
|
||||||
|
data-testid="pref-theme-{value}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Test IDs: `pref-theme-system`, `pref-theme-light`, `pref-theme-dark`.
|
||||||
|
|
||||||
|
## 7. Landing Page
|
||||||
|
|
||||||
|
Out of scope for this spec. The landing page (`website/`) uses its own color system separate from `app.css`. Dark mode support for the landing page will be tracked separately.
|
||||||
|
|
||||||
|
## 8. Prose / Typography Overrides
|
||||||
|
|
||||||
|
The `.prose` overrides in `app.css` already use CSS variables, so they'll automatically adapt. No changes needed.
|
||||||
|
|
||||||
|
The TipTap editor styles (table borders, selected cells, task lists) also use CSS variables. No changes needed.
|
||||||
|
|
||||||
|
## 9. Color Picker in Dark Mode
|
||||||
|
|
||||||
|
The `ColorPicker` component shows circular swatches. In dark mode, these should display the dark color variants. The component imports `isDarkMode` from `theme.svelte.ts` and uses `getNoteColor(color, isDarkMode)` to get the correct swatch background.
|
||||||
|
|
||||||
|
## 10. Testing
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
- `theme.svelte.ts` — test `applyTheme` sets correct attribute and meta tag, validates unknown values
|
||||||
|
- `colors.ts` — test `getNoteColor` returns correct color for light/dark
|
||||||
|
|
||||||
|
### E2E tests
|
||||||
|
- Theme toggle in preferences persists and applies (use `data-testid` selectors)
|
||||||
|
- System preference detection (use Playwright's `page.emulateMedia({ colorScheme: 'dark' })`)
|
||||||
|
- No FOUC on page reload in dark mode
|
||||||
|
- Note cards render with correct dark colors
|
||||||
|
|
||||||
|
## 11. PWA Manifest
|
||||||
|
|
||||||
|
The PWA manifest (`@vite-pwa/sveltekit`) contains a static `theme_color`. This cannot be dynamically changed. The manifest will keep the light theme color (`#f0e6d3`). The `<meta name="theme-color">` is updated dynamically by the FOUC script and `applyTheme`, which handles the browser chrome color. This is a known limitation of PWA manifests and is acceptable.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Header quick-toggle icon
|
||||||
|
- Per-note theme override
|
||||||
|
- Custom color theme builder
|
||||||
|
- Dark mode for public shared note pages (can be added later)
|
||||||
|
- Dark mode for landing page (separate task)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Public Note Sharing — Design Spec
|
||||||
|
|
||||||
|
**Issue**: [#7 — Public links and shared notes](https://github.com/bretzel-app/crumbs/issues/7)
|
||||||
|
**Date**: 2026-03-20
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Allow sharing individual notes via a public URL that anyone can view without authentication. Read-only sharing via unguessable tokens. Integrates into the existing ShareDialog alongside collaborator management.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Share UI location | Inside existing ShareDialog | One "Share" entry point for all sharing (public + collaborators) |
|
||||||
|
| Expiry | Deferred to future version | Simpler v1; active/revoked toggle only |
|
||||||
|
| Rate limiting | In-memory per-IP | Lightweight, resets on restart; token entropy handles enumeration |
|
||||||
|
| Architecture | Separated shares-service | Follows collaborators-service pattern; clean module boundary |
|
||||||
|
| Sharing icon | Globe (public) or Users (collaborators) | Single icon slot; tooltip shows combined sharing state |
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
New `sharedNotes` table in `src/lib/server/db/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const sharedNotes = sqliteTable('shared_notes', {
|
||||||
|
noteId: text('note_id').primaryKey().references(() => notes.id, { onDelete: 'cascade' }),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
expiresAt: integer('expires_at', { mode: 'timestamp' }), // NULL = no expiry, reserved for future use
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- `noteId` as primary key — one share per note, no separate `id` needed
|
||||||
|
- `integer` timestamps with `mode: 'timestamp'` — matches all other tables in the schema
|
||||||
|
- `createdAt` set in service layer (`new Date()`), not via SQL default
|
||||||
|
- CASCADE delete — deleting the note removes the share
|
||||||
|
- Token: `nanoid(21)` — ~128 bits of entropy
|
||||||
|
- No update to `deleteUser()` needed — shares cascade through notes (foreign keys enabled via `PRAGMA foreign_keys = ON` in db connection)
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
Add to `src/lib/types/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface NoteShare {
|
||||||
|
noteId: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: Date;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend the `Note` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Note {
|
||||||
|
// ...existing fields
|
||||||
|
shareToken?: string; // populated during hydration if a public share exists
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Layer
|
||||||
|
|
||||||
|
New file: `src/lib/server/shares-service.ts`
|
||||||
|
|
||||||
|
| Function | Params | Returns | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `createShare` | `db, noteId, userId` | `{ token }` | Verify ownership, generate nanoid(21), insert row, return token. If share already exists, return existing token. |
|
||||||
|
| `revokeShare` | `db, noteId, userId` | `void` | Verify ownership, delete share row |
|
||||||
|
| `getShareByNoteId` | `db, noteId` | `NoteShare \| null` | Return share if exists (for ShareDialog) |
|
||||||
|
| `getSharedNote` | `db, token` | `SharedNoteData \| null` | Public — fetch note by token, return null if trashed or not found |
|
||||||
|
| `fetchSharesForNotes` | `db, noteIds` | `Map<string, string>` | Batch fetch — returns Map of noteId → token for hydration (only token needed for NoteCard indicator) |
|
||||||
|
|
||||||
|
`SharedNoteData` type (returned to public viewers):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SharedNoteData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
checklistMode: boolean;
|
||||||
|
color: NoteColor;
|
||||||
|
attachments: Attachment[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `POST /api/notes/[id]/share`
|
||||||
|
|
||||||
|
- **Auth**: Required (owner only)
|
||||||
|
- **Body**: None
|
||||||
|
- **Response**: `{ token: string, url: string }`
|
||||||
|
- **Behavior**: Creates share or returns existing one. URL constructed from request origin + `/s/${token}`
|
||||||
|
|
||||||
|
### `DELETE /api/notes/[id]/share`
|
||||||
|
|
||||||
|
- **Auth**: Required (owner only)
|
||||||
|
- **Response**: `204 No Content`
|
||||||
|
- **Behavior**: Deletes share row. Link immediately returns 404.
|
||||||
|
|
||||||
|
### `GET /api/shared/[token]`
|
||||||
|
|
||||||
|
- **Auth**: None (public)
|
||||||
|
- **Rate limit**: 60 requests/min per IP (in-memory)
|
||||||
|
- **Response**: `SharedNoteData` JSON
|
||||||
|
- **Error cases**:
|
||||||
|
- Invalid/unknown token → 404
|
||||||
|
- Note is trashed → 404
|
||||||
|
- Rate limited → 429
|
||||||
|
|
||||||
|
### `GET /api/shared/[token]/attachment/[attachmentId]`
|
||||||
|
|
||||||
|
- **Auth**: None (public)
|
||||||
|
- **Rate limit**: Same as above
|
||||||
|
- **Response**: Image binary (proxied from attachment storage)
|
||||||
|
- **Behavior**: Verify attachment belongs to the shared note, then serve
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
src/routes/
|
||||||
|
(share)/
|
||||||
|
+layout.svelte # Minimal layout: parchment bg, centered, no app chrome
|
||||||
|
s/[token]/
|
||||||
|
+page.server.ts # Load shared note by token via shares-service
|
||||||
|
+page.svelte # Read-only note renderer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Middleware
|
||||||
|
|
||||||
|
Add `/s/` and `/api/shared` to `PUBLIC_PATHS` in `src/hooks.server.ts`. Both are needed — `/s/` for the public page route, `/api/shared` for the public API endpoints.
|
||||||
|
|
||||||
|
### Public Page Design
|
||||||
|
|
||||||
|
- Parchment background (`--bg-base`)
|
||||||
|
- Centered content card (`--bg-surface`, `--border-subtle`, `--card-shadow`)
|
||||||
|
- Note title in JetBrains Mono
|
||||||
|
- Rendered markdown content or checklist (read-only)
|
||||||
|
- Inline images from attachments
|
||||||
|
- No tags, no edit controls, no sidebar, no navigation
|
||||||
|
- Footer: "POWERED BY CRUMBS" in Press Start 2P pixel font, links to project
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### ShareDialog Changes
|
||||||
|
|
||||||
|
Add a "Public link" section **above** the existing collaborators section:
|
||||||
|
|
||||||
|
**Inactive state:**
|
||||||
|
- Globe icon + "Public link" label + toggle switch (off)
|
||||||
|
- Subtitle: "Anyone with the link can view this note (read-only)"
|
||||||
|
|
||||||
|
**Active state:**
|
||||||
|
- Globe icon + "Public link" label + toggle switch (on, gold)
|
||||||
|
- URL input (readonly) + "Copy" button
|
||||||
|
- Subtitle: "Toggle off to revoke access. The link will return a 404."
|
||||||
|
- Border highlight in `--primary` gold
|
||||||
|
|
||||||
|
Toggle on → calls `POST /api/notes/[id]/share`, displays URL
|
||||||
|
Toggle off → calls `DELETE /api/notes/[id]/share`, hides URL
|
||||||
|
Copy button → clipboard API, brief "Copied!" feedback
|
||||||
|
|
||||||
|
### NoteCard Share Indicator
|
||||||
|
|
||||||
|
Single icon slot in top-right of NoteCard (replaces showing both icons):
|
||||||
|
|
||||||
|
| State | Icon | Color |
|
||||||
|
|---|---|---|
|
||||||
|
| Public link active | `Globe` (Lucide) | `--primary` gold |
|
||||||
|
| Collaborators only | `Users` (Lucide) | `--text-muted` |
|
||||||
|
| Both | `Globe` (Lucide) | `--primary` gold |
|
||||||
|
| Neither | No icon | — |
|
||||||
|
|
||||||
|
**Hover tooltip** shows combined sharing info regardless of which icon is displayed:
|
||||||
|
- "Shared publicly via link" (if public share exists)
|
||||||
|
- "Shared with N collaborator(s)" (if collaborators exist)
|
||||||
|
- Both lines if both exist
|
||||||
|
|
||||||
|
### NoteEditor Share Button
|
||||||
|
|
||||||
|
Existing Share button behavior unchanged. The Globe/Users icon logic from NoteCard also applies to the editor toolbar button to indicate current sharing state.
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
New file: `src/lib/server/ip-rate-limit.ts` — separate from the existing DB-backed `rate-limit.ts` (which is login-specific).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In-memory store: Map<IP, { count: number, windowStart: number }>
|
||||||
|
// 60 requests per minute per IP
|
||||||
|
// Applied to GET /api/shared/[token] and GET /api/shared/[token]/attachment/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Resets on server restart. No database table needed.
|
||||||
|
|
||||||
|
## Hydration
|
||||||
|
|
||||||
|
`hydrateNotes()` in `notes-service.ts` must be updated to call `fetchSharesForNotes()` and populate `shareToken` on each note. This follows the existing pattern where `hydrateNotes` already batch-fetches tags, attachments, and collaborators.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Tokens are unguessable: nanoid(21) = ~128 bits of entropy
|
||||||
|
- `/s/` and `/api/shared` routes bypass auth middleware — only these prefixes
|
||||||
|
- Trashed notes return 404 even if share token is valid
|
||||||
|
- Only note owners can create/revoke shares — collaborators cannot
|
||||||
|
- No user information exposed on public page (no username, no email, no tags)
|
||||||
|
- Rate limiting prevents brute-force enumeration
|
||||||
|
- Attachments served through share-specific route (validates attachment belongs to shared note)
|
||||||
|
|
||||||
|
## Out of Scope (v1)
|
||||||
|
|
||||||
|
- Expiry dates / auto-expire
|
||||||
|
- Password-protected shares
|
||||||
|
- Edit access through share links
|
||||||
|
- Share analytics / view counts
|
||||||
|
- Email notifications for share events
|
||||||
|
- E2EE interaction (no E2EE exists)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Share toggle in ShareDialog creates/revokes public link
|
||||||
|
- [ ] Unique nanoid(21) token generated per shared note
|
||||||
|
- [ ] Public `/s/[token]` route renders note without auth
|
||||||
|
- [ ] Copy link to clipboard with feedback
|
||||||
|
- [ ] Revoke share → link returns 404
|
||||||
|
- [ ] Globe indicator on NoteCard for publicly shared notes
|
||||||
|
- [ ] Combined tooltip showing all sharing state
|
||||||
|
- [ ] Trashed notes return 404 even if shared
|
||||||
|
- [ ] Minimal, parchment-styled public page with "Powered by Crumbs"
|
||||||
|
- [ ] In-memory rate limiting on public endpoints
|
||||||
|
- [ ] Attachments accessible on public page
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# Screenshot Automation for Landing Page
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A Playwright-based script that seeds the Crumbs app with bretzel-themed demo data, captures 10 screenshots (7 desktop + 3 mobile) for the landing page, and commits them. Triggered manually via a GitHub Actions workflow.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
screenshots.ts # Main orchestrator: seed + capture
|
||||||
|
fixtures/
|
||||||
|
bretzel-pixel.ts # Generates a pixel-art bretzel PNG programmatically
|
||||||
|
```
|
||||||
|
|
||||||
|
Single script (`scripts/screenshots.ts`) that:
|
||||||
|
1. Spawns the preview server as a child process (`pnpm preview --port 4173`), same approach as `playwright.config.ts` uses for e2e tests. The app uses `@sveltejs/adapter-node`, so `vite preview` import won't work — must spawn via CLI.
|
||||||
|
2. Deletes any existing screenshot DB to start fresh
|
||||||
|
3. Seeds data via API calls (setup admin, login, create notes/users/keys)
|
||||||
|
4. Navigates to each view and captures screenshots at specific viewports
|
||||||
|
5. Saves PNGs directly to `website/assets/`
|
||||||
|
6. Kills the preview server in `finally` block
|
||||||
|
|
||||||
|
## Seed Data — Bretzel Universe
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
| User | Email | Role | Purpose |
|
||||||
|
|------|-------|------|---------|
|
||||||
|
| Bretzel Admin | admin@bretzel.app | admin | Main user, owns all notes |
|
||||||
|
| Alice Chen | alice@bretzel.app | user | Collaborator for sharing screenshot |
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Passed explicitly in the `tags` array of each `POST /api/notes` request body: `baking`, `bretzel`, `dev`, `devops`, `homelab`, `personal`, `reading`, `weekend`. Tags are NOT auto-parsed from hashtags in content — they must be provided as a separate field.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
| # | Title | Color | Mode | Tags | Pinned | Special |
|
||||||
|
|---|-------|-------|------|------|--------|---------|
|
||||||
|
| 1 | Bretzel Ingredients | sand | checklist | #baking, #bretzel | yes | Checklist items (see below) |
|
||||||
|
| 2 | Self-Hosting Stack | fog | rich text | #homelab, #devops | no | Homelab list (see below) |
|
||||||
|
| 3 | Back to Retro Gaming | peach | rich text | #reading, #personal | no | Retro gaming note |
|
||||||
|
| 4 | The Best Software Is the Software You Own | coral | rich text | #personal, #bretzel | no | Self-hosting manifesto |
|
||||||
|
| 5 | Weekend Plans | mint | rich text | #personal, #weekend | no | Casual weekend plans |
|
||||||
|
| 6 | Ancient Flute Harmonica Discovered | chalk | rich text | #reading | no | Quirky news note |
|
||||||
|
| 7 | Baking code for the... | clay | checklist | #dev, #bretzel | no | Dev/baking crossover |
|
||||||
|
|
||||||
|
### Note Content Details
|
||||||
|
|
||||||
|
**Bretzel Ingredients** (checklist):
|
||||||
|
- [x] 500g bread flour
|
||||||
|
- [x] 300ml warm water
|
||||||
|
- [x] 10g salt
|
||||||
|
- [ ] 7g dry yeast
|
||||||
|
- [ ] 30g butter (softened)
|
||||||
|
- [ ] Baking soda for the bath
|
||||||
|
- [ ] Coarse salt for topping
|
||||||
|
|
||||||
|
**Self-Hosting Stack** (rich text — used for editor screenshot):
|
||||||
|
```
|
||||||
|
Current homelab setup:
|
||||||
|
|
||||||
|
**Crumbs** — notes (obviously)
|
||||||
|
**42** — holiday budget tracking
|
||||||
|
**Dokploy** — deployment platform
|
||||||
|
**Outline** — team wiki
|
||||||
|
**Navidrome** — music streaming
|
||||||
|
**Vaultwarden** — passwords
|
||||||
|
**Nextcloud** — files & calendar
|
||||||
|
**Plausible** — analytics
|
||||||
|
|
||||||
|
Everything below daily reverse proxy.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The Best Software Is the Software You Own** (rich text):
|
||||||
|
```
|
||||||
|
Self-host everything. Trust no cloud.
|
||||||
|
|
||||||
|
The best software is the software you own.
|
||||||
|
Your data. Your server. Your rules.
|
||||||
|
|
||||||
|
If it can't run on your hardware, it's not really yours.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Back to Retro Gaming** (rich text):
|
||||||
|
```
|
||||||
|
Games to replay this summer:
|
||||||
|
|
||||||
|
Chrono Trigger (SNES) — best RPG ever made
|
||||||
|
The Legend of Zelda: Link's Awakening (GB)
|
||||||
|
Castlevania: Symphony of the Night (PS1)
|
||||||
|
Advance Wars (GBA)
|
||||||
|
Final Fantasy Tactics (PS1)
|
||||||
|
|
||||||
|
The pixel art era was peak game design.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Weekend Plans** (rich text):
|
||||||
|
```
|
||||||
|
Saturday:
|
||||||
|
- Farmer's market (get sourdough starter)
|
||||||
|
- Fix the Dokploy SSL cert renewal
|
||||||
|
- Bake a batch of bretzels
|
||||||
|
|
||||||
|
Sunday:
|
||||||
|
- Retro gaming marathon
|
||||||
|
- Update the self-hosting stack
|
||||||
|
- Plan next trip on 42
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ancient Flute Harmonica Discovered** (rich text):
|
||||||
|
```
|
||||||
|
Archaeologists found a 40,000-year-old flute carved from vulture bone.
|
||||||
|
|
||||||
|
The oldest known musical instrument.
|
||||||
|
Imagine the first song ever played.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Baking code for the...** (checklist):
|
||||||
|
```
|
||||||
|
- [x] Set up CI/CD pipeline
|
||||||
|
- [x] Write the CRDT sync logic
|
||||||
|
- [ ] Bake the docker image
|
||||||
|
- [ ] Deploy to the homelab
|
||||||
|
- [ ] Celebrate with a bretzel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Attachments
|
||||||
|
|
||||||
|
**Pixel-art bretzel image** (`scripts/fixtures/bretzel-pixel.ts`):
|
||||||
|
- Generated programmatically as a PNG using raw pixel data (no external dependencies)
|
||||||
|
- ~64x64 pixel grid, bretzel shape in warm browns (#C8860A gold, #8B6914 dark, #F0E6D3 parchment bg)
|
||||||
|
- Attached as featured image to "Bretzel Ingredients" note
|
||||||
|
- Also create a second smaller image attached to the same note (e.g. a pixel-art salt shaker or oven) for visual variety
|
||||||
|
|
||||||
|
### Version History
|
||||||
|
|
||||||
|
Send 4-5 `PATCH /api/notes/:id` calls to "Self-Hosting Stack" with varying content. Version history entries are created as a side-effect of each PATCH — there is no explicit "create version" endpoint. Each version adds or changes a tool in the stack, so the diff is meaningful when viewed in the history panel.
|
||||||
|
|
||||||
|
### API Keys
|
||||||
|
|
||||||
|
Create via API after seeding:
|
||||||
|
| Name | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| Claude Code | For MCP integration screenshot |
|
||||||
|
| N8N | For automation screenshot |
|
||||||
|
|
||||||
|
## Screenshot Capture Plan
|
||||||
|
|
||||||
|
### Desktop (1280x800)
|
||||||
|
|
||||||
|
| # | Filename | View | Capture Steps |
|
||||||
|
|---|----------|------|---------------|
|
||||||
|
| 1 | screenshot-grid.png | Home grid | Navigate to `/`, wait for notes to render, ensure sidebar is expanded with tags visible |
|
||||||
|
| 2 | screenshot-editor.png | Note editor | Click "Self-Hosting Stack" note, wait for editor to open with content and toolbar visible |
|
||||||
|
| 3 | screenshot-checklist.png | Checklist mode | Click "Bretzel Ingredients" note, wait for checklist items with checkboxes visible |
|
||||||
|
| 4 | screenshot-sharing.png | Share dialog | Open a note, click share button, wait for ShareDialog with "Alice Chen" listed as collaborator |
|
||||||
|
| 5 | screenshot-history.png | Version history | Open "Self-Hosting Stack", click history button, select a middle version in the list, wait for preview panel |
|
||||||
|
| 6 | screenshot-attachments.png | Attachments | Open "Bretzel Ingredients" note (has featured bretzel image), ensure image thumbnails and featured strip visible |
|
||||||
|
| 7 | screenshot-api.png | Settings > API/MCP | Navigate to `/settings/mcp`, wait for API keys list ("Claude Code", "N8N") and MCP config JSON block |
|
||||||
|
|
||||||
|
### Mobile (390x844)
|
||||||
|
|
||||||
|
| # | Filename | View | Capture Steps |
|
||||||
|
|---|----------|------|---------------|
|
||||||
|
| 1 | screenshot-mobile-grid.png | Home grid | Navigate to `/`, wait for notes in mobile layout (no sidebar, tags as horizontal pills) |
|
||||||
|
| 2 | screenshot-mobile-editor.png | Note editor | Open "Self-Hosting Stack" note, wait for mobile editor |
|
||||||
|
| 3 | screenshot-mobile-checklist.png | Checklist | Open "Bretzel Ingredients" note, wait for mobile checklist view |
|
||||||
|
|
||||||
|
### Capture Settings
|
||||||
|
- Format: PNG
|
||||||
|
- Full page: false (viewport only)
|
||||||
|
- Animations disabled via `page.emulateMedia({ reducedMotion: 'reduce' })` to avoid capturing mid-animation
|
||||||
|
- Wait for `networkidle` after each navigation before capturing
|
||||||
|
|
||||||
|
## GitHub Actions Workflow
|
||||||
|
|
||||||
|
**File**: `.github/workflows/screenshots.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Screenshots
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
screenshots:
|
||||||
|
name: Capture Landing Page Screenshots
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install Playwright browsers and system deps
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: pnpm build
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ./data/screenshots.db
|
||||||
|
|
||||||
|
- name: Capture screenshots
|
||||||
|
run: pnpm exec tsx scripts/screenshots.ts
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ./data/screenshots.db
|
||||||
|
|
||||||
|
- name: Commit screenshots
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add website/assets/screenshot-*.png
|
||||||
|
git diff --cached --quiet && echo "No screenshot changes" && exit 0
|
||||||
|
git commit -m "chore: update landing page screenshots [skip ci]"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/screenshots.ts
|
||||||
|
|
||||||
|
import { chromium, type Page } from 'playwright';
|
||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import { generateBretzelPixelArt } from './fixtures/bretzel-pixel';
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:4173';
|
||||||
|
const DESKTOP = { width: 1280, height: 800 };
|
||||||
|
const MOBILE = { width: 390, height: 844 };
|
||||||
|
const OUTPUT = 'website/assets';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 1. Start preview server (spawn child process, wait for port 4173)
|
||||||
|
const server = await startPreviewServer();
|
||||||
|
|
||||||
|
// 2. Launch browser
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. Seed data (desktop context — API calls work at any viewport)
|
||||||
|
const seedContext = await browser.newContext({ viewport: DESKTOP });
|
||||||
|
const seedPage = await seedContext.newPage();
|
||||||
|
await seed(seedPage);
|
||||||
|
|
||||||
|
// 4. Desktop screenshots
|
||||||
|
await captureDesktop(seedPage);
|
||||||
|
await seedContext.close();
|
||||||
|
|
||||||
|
// 5. Mobile screenshots
|
||||||
|
const mobileContext = await browser.newContext({ viewport: MOBILE });
|
||||||
|
const mobilePage = await mobileContext.newPage();
|
||||||
|
await login(mobilePage);
|
||||||
|
await captureMobile(mobilePage);
|
||||||
|
await mobileContext.close();
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed(page: Page) {
|
||||||
|
// Setup admin via POST /api/auth/setup
|
||||||
|
// Login via POST /api/auth/login (sets session cookie on page context)
|
||||||
|
// Create notes via POST /api/notes (with content, color, tags:string[], pinned, checklistMode)
|
||||||
|
// → capture note IDs from responses for later use
|
||||||
|
// Create collaborator user via POST /api/admin/users
|
||||||
|
// → capture alice.id (numeric) from response
|
||||||
|
// Share a note via POST /api/notes/:id/collaborators { userId: alice.id }
|
||||||
|
// Create version history: PATCH /api/notes/:id × 4-5 with varying content
|
||||||
|
// Upload pixel-art bretzel image via POST /api/notes/:id/attachments (multipart form)
|
||||||
|
// Create API keys via POST /api/settings/api-keys { name: "Claude Code" } and { name: "N8N" }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureDesktop(page: Page) {
|
||||||
|
// D1: Grid view
|
||||||
|
// D2: Editor (Self-Hosting Stack)
|
||||||
|
// D3: Checklist (Bretzel Ingredients)
|
||||||
|
// D4: Sharing dialog
|
||||||
|
// D5: Version history
|
||||||
|
// D6: Attachments
|
||||||
|
// D7: API/MCP settings
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureMobile(page: Page) {
|
||||||
|
// M1: Grid view
|
||||||
|
// M2: Editor
|
||||||
|
// M3: Checklist
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Landing Page Updates
|
||||||
|
|
||||||
|
After the screenshots are generated, `website/index.html` needs updating:
|
||||||
|
- Add new screenshot entries for: checklist, sharing, history, attachments, API
|
||||||
|
- Update alt text and labels
|
||||||
|
- Hero image stays as `screenshot-grid.png`
|
||||||
|
- Mobile section adds checklist screenshot, replaces sidebar screenshot
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
No new dependencies. Uses:
|
||||||
|
- `playwright` (already in devDependencies for e2e tests)
|
||||||
|
- `tsx` (already in devDependencies for other scripts)
|
||||||
|
- Node.js built-in `fs`, `path`, `Buffer` for pixel-art PNG generation
|
||||||
|
- SvelteKit's preview server (spawned as child process via `pnpm preview`)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — all decisions resolved during brainstorming.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE `login_attempts` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`ip` text NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`success` integer NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint
|
||||||
|
DROP INDEX `tags_name_unique`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_users` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`email` text DEFAULT '' NOT NULL,
|
||||||
|
`display_name` text DEFAULT '' NOT NULL,
|
||||||
|
`role` text DEFAULT 'user' NOT NULL,
|
||||||
|
`password_hash` text,
|
||||||
|
`auth_provider` text DEFAULT 'password' NOT NULL,
|
||||||
|
`provider_id` text,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint
|
||||||
|
DROP TABLE `users`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||||
|
ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||||
|
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id);
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
CREATE TABLE `api_keys` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`key_hash` text NOT NULL,
|
||||||
|
`key_prefix` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`last_used_at` integer,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `note_collaborators` (
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`added_by` integer NOT NULL,
|
||||||
|
`added_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `note_user_state` (
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`pinned` integer DEFAULT false NOT NULL,
|
||||||
|
`archived` integer DEFAULT false NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_preferences` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`key` text NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_attachments` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`mime_type` text NOT NULL,
|
||||||
|
`size` integer NOT NULL,
|
||||||
|
`path` text NOT NULL,
|
||||||
|
`thumbnail_path` text,
|
||||||
|
`featured` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint
|
||||||
|
DROP TABLE `attachments`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_notes` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`title` text DEFAULT '' NOT NULL,
|
||||||
|
`content` text DEFAULT '' NOT NULL,
|
||||||
|
`color` text DEFAULT 'default' NOT NULL,
|
||||||
|
`pinned` integer DEFAULT false NOT NULL,
|
||||||
|
`archived` integer DEFAULT false NOT NULL,
|
||||||
|
`trashed` integer DEFAULT false NOT NULL,
|
||||||
|
`trashed_at` integer,
|
||||||
|
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`version` integer DEFAULT 1 NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint
|
||||||
|
DROP TABLE `notes`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint
|
||||||
|
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_sync_log` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`operation` text NOT NULL,
|
||||||
|
`timestamp` integer NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint
|
||||||
|
DROP TABLE `sync_log`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint
|
||||||
|
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_tags` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer DEFAULT 0 NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint
|
||||||
|
DROP TABLE `tags`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE `note_versions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`note_id` text NOT NULL,
|
||||||
|
`version` integer NOT NULL,
|
||||||
|
`title` text DEFAULT '' NOT NULL,
|
||||||
|
`content` text DEFAULT '' NOT NULL,
|
||||||
|
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||||
|
`color` text DEFAULT 'default' NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `note_versions_note_id_idx` ON `note_versions` (`note_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `note_versions_note_id_version_unique` ON `note_versions` (`note_id`,`version`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `shared_notes` (
|
||||||
|
`note_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`expires_at` integer,
|
||||||
|
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `shared_notes_token_unique` ON `shared_notes` (`token`);
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3",
|
||||||
|
"prevId": "682b02aa-a28d-4904-8996-cbc4d740f626",
|
||||||
|
"tables": {
|
||||||
|
"attachments": {
|
||||||
|
"name": "attachments",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"note_id": {
|
||||||
|
"name": "note_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mime_type": {
|
||||||
|
"name": "mime_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"name": "size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"thumbnail_path": {
|
||||||
|
"name": "thumbnail_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"featured": {
|
||||||
|
"name": "featured",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"attachments_user_id_users_id_fk": {
|
||||||
|
"name": "attachments_user_id_users_id_fk",
|
||||||
|
"tableFrom": "attachments",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"attachments_note_id_notes_id_fk": {
|
||||||
|
"name": "attachments_note_id_notes_id_fk",
|
||||||
|
"tableFrom": "attachments",
|
||||||
|
"tableTo": "notes",
|
||||||
|
"columnsFrom": [
|
||||||
|
"note_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"login_attempts": {
|
||||||
|
"name": "login_attempts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"ip": {
|
||||||
|
"name": "ip",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"name": "success",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"login_attempts_ip_timestamp_idx": {
|
||||||
|
"name": "login_attempts_ip_timestamp_idx",
|
||||||
|
"columns": [
|
||||||
|
"ip",
|
||||||
|
"timestamp"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"note_tags": {
|
||||||
|
"name": "note_tags",
|
||||||
|
"columns": {
|
||||||
|
"note_id": {
|
||||||
|
"name": "note_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag_id": {
|
||||||
|
"name": "tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"note_tags_note_id_idx": {
|
||||||
|
"name": "note_tags_note_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"note_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"note_tags_tag_id_idx": {
|
||||||
|
"name": "note_tags_tag_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"note_tags_note_id_notes_id_fk": {
|
||||||
|
"name": "note_tags_note_id_notes_id_fk",
|
||||||
|
"tableFrom": "note_tags",
|
||||||
|
"tableTo": "notes",
|
||||||
|
"columnsFrom": [
|
||||||
|
"note_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"note_tags_tag_id_tags_id_fk": {
|
||||||
|
"name": "note_tags_tag_id_tags_id_fk",
|
||||||
|
"tableFrom": "note_tags",
|
||||||
|
"tableTo": "tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'default'"
|
||||||
|
},
|
||||||
|
"pinned": {
|
||||||
|
"name": "pinned",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"archived": {
|
||||||
|
"name": "archived",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"trashed": {
|
||||||
|
"name": "trashed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"trashed_at": {
|
||||||
|
"name": "trashed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"checklist_mode": {
|
||||||
|
"name": "checklist_mode",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"name": "version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"notes_user_id_idx": {
|
||||||
|
"name": "notes_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"notes_trashed_archived_idx": {
|
||||||
|
"name": "notes_trashed_archived_idx",
|
||||||
|
"columns": [
|
||||||
|
"trashed",
|
||||||
|
"archived"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"notes_updated_at_idx": {
|
||||||
|
"name": "notes_updated_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"notes_user_id_users_id_fk": {
|
||||||
|
"name": "notes_user_id_users_id_fk",
|
||||||
|
"tableFrom": "notes",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ip": {
|
||||||
|
"name": "ip",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sync_log": {
|
||||||
|
"name": "sync_log",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"note_id": {
|
||||||
|
"name": "note_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"name": "operation",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"name": "timestamp",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"name": "client_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sync_log_timestamp_idx": {
|
||||||
|
"name": "sync_log_timestamp_idx",
|
||||||
|
"columns": [
|
||||||
|
"timestamp"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sync_log_user_id_users_id_fk": {
|
||||||
|
"name": "sync_log_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sync_log",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"sync_log_note_id_notes_id_fk": {
|
||||||
|
"name": "sync_log_note_id_notes_id_fk",
|
||||||
|
"tableFrom": "sync_log",
|
||||||
|
"tableTo": "notes",
|
||||||
|
"columnsFrom": [
|
||||||
|
"note_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tags_name_user_unique": {
|
||||||
|
"name": "tags_name_user_unique",
|
||||||
|
"columns": [
|
||||||
|
"name",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tags_user_id_users_id_fk": {
|
||||||
|
"name": "tags_user_id_users_id_fk",
|
||||||
|
"tableFrom": "tags",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'password'"
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,27 @@
|
|||||||
"when": 1773000424830,
|
"when": 1773000424830,
|
||||||
"tag": "0000_wooden_vargas",
|
"tag": "0000_wooden_vargas",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773016788899,
|
||||||
|
"tag": "0001_strange_lord_hawal",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773150888301,
|
||||||
|
"tag": "0002_deep_black_widow",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1774037379518,
|
||||||
|
"tag": "0003_uneven_tomorrow_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+24
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "crumbs",
|
"name": "crumbs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.27.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bretzel (https://bretzel.app)",
|
"author": "Bretzel (https://bretzel.app)",
|
||||||
@@ -12,14 +12,18 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "npx tsx scripts/generate-openapi.ts && vite build",
|
||||||
|
"openapi": "npx tsx scripts/generate-openapi.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:unit:watch": "vitest",
|
"test:unit:watch": "vitest",
|
||||||
|
"test:unit:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:coverage": "VITE_COVERAGE=true pnpm build && pnpm test:e2e && nyc report",
|
||||||
|
"coverage:merge-e2e": "nyc merge coverage/e2e/raw coverage/e2e/merged.json && nyc report",
|
||||||
"test": "pnpm test:unit && pnpm test:e2e",
|
"test": "pnpm test:unit && pnpm test:e2e",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
@@ -29,10 +33,15 @@
|
|||||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@tiptap/core": "^3.20.1",
|
"@tiptap/core": "^3.20.1",
|
||||||
"@tiptap/extension-link": "^3.20.1",
|
"@tiptap/extension-link": "^3.20.1",
|
||||||
"@tiptap/extension-placeholder": "^3.20.1",
|
"@tiptap/extension-placeholder": "^3.20.1",
|
||||||
|
"@tiptap/extension-table": "^3.20.1",
|
||||||
|
"@tiptap/extension-table-cell": "^3.20.1",
|
||||||
|
"@tiptap/extension-table-header": "^3.20.1",
|
||||||
|
"@tiptap/extension-table-row": "^3.20.1",
|
||||||
"@tiptap/extension-task-item": "^3.20.1",
|
"@tiptap/extension-task-item": "^3.20.1",
|
||||||
"@tiptap/extension-task-list": "^3.20.1",
|
"@tiptap/extension-task-list": "^3.20.1",
|
||||||
"@tiptap/extension-text-align": "^3.20.1",
|
"@tiptap/extension-text-align": "^3.20.1",
|
||||||
@@ -46,9 +55,13 @@
|
|||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"lucide-svelte": "^0.577.0",
|
"lucide-svelte": "^0.577.0",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
|
"nanoid": "^5.1.7",
|
||||||
|
"node-diff3": "^3.2.0",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
"svelte-dnd-action": "^0.9.69",
|
"svelte-dnd-action": "^0.9.69",
|
||||||
"tiptap-markdown": "^0.9.0",
|
"tiptap-markdown": "^0.9.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zxcvbn": "^4.4.2"
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -59,17 +72,24 @@
|
|||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"@vite-pwa/sveltekit": "^1.1.0",
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"nyc": "^18.0.0",
|
||||||
"svelte": "^5.53.7",
|
"svelte": "^5.53.7",
|
||||||
"svelte-check": "^4.4.4",
|
"svelte-check": "^4.4.4",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-istanbul": "^7.2.1",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
|
"workbox-window": "^7.4.0",
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
@@ -81,7 +101,8 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"serialize-javascript": ">=7.0.3",
|
"serialize-javascript": ">=7.0.3",
|
||||||
"esbuild@<0.25.0": "0.25.12",
|
"esbuild@<0.25.0": "0.25.12",
|
||||||
"cookie": ">=0.7.0"
|
"cookie": ">=0.7.0",
|
||||||
|
"hono": ">=4.12.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ export default defineConfig({
|
|||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: 0,
|
||||||
|
workers: process.env.CI ? 4 : undefined,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
globalSetup: './tests/e2e/global-setup.ts',
|
globalSetup: './tests/e2e/global-setup.ts',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:4173',
|
baseURL: 'http://localhost:4173',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure'
|
screenshot: 'only-on-failure',
|
||||||
|
serviceWorkers: 'block'
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
@@ -27,7 +29,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'pnpm build && pnpm preview --port 4173',
|
command: 'pnpm preview --port 4173',
|
||||||
port: 4173,
|
port: 4173,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
|
|||||||
Generated
+1823
-2
File diff suppressed because it is too large
Load Diff
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Writes a coverage summary table to $GITHUB_STEP_SUMMARY (or stdout if not in CI).
|
||||||
|
# Usage: scripts/coverage-summary.sh <title> <json-summary-path>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TITLE="${1:?Usage: coverage-summary.sh <title> <json-summary-path>}"
|
||||||
|
JSON="${2:?Usage: coverage-summary.sh <title> <json-summary-path>}"
|
||||||
|
|
||||||
|
if [ ! -f "$JSON" ]; then
|
||||||
|
echo "Coverage summary not found: $JSON" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve to absolute path for require()
|
||||||
|
ABS_JSON="$(cd "$(dirname "$JSON")" && pwd)/$(basename "$JSON")"
|
||||||
|
|
||||||
|
# Extract totals with node (available in CI, no jq dependency)
|
||||||
|
read -r stmts branches funcs lines < <(node -e "
|
||||||
|
const s = require('$ABS_JSON').total;
|
||||||
|
console.log(s.statements.pct, s.branches.pct, s.functions.pct, s.lines.pct);
|
||||||
|
")
|
||||||
|
|
||||||
|
TABLE="## $TITLE
|
||||||
|
|
||||||
|
| Metric | Coverage |
|
||||||
|
|--------|----------|
|
||||||
|
| Statements | ${stmts}% |
|
||||||
|
| Branches | ${branches}% |
|
||||||
|
| Functions | ${funcs}% |
|
||||||
|
| Lines | ${lines}% |
|
||||||
|
"
|
||||||
|
|
||||||
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
|
echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
else
|
||||||
|
echo "$TABLE"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Auto-generates an OpenAPI spec from the SvelteKit route tree.
|
||||||
|
*
|
||||||
|
* Scans all +server.ts files under src/routes/api/ for exported HTTP methods and
|
||||||
|
* builds a minimal OpenAPI 3.1 spec at static/.well-known/openapi.json.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/generate-openapi.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
||||||
|
import { resolve, relative, dirname, join } from 'path';
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const ROUTES_DIR = resolve(ROOT, 'src/routes/api');
|
||||||
|
const OUTPUT = resolve(ROOT, 'static/api/openapi.json');
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'));
|
||||||
|
|
||||||
|
function walkDir(dir: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
results.push(...walkDir(full));
|
||||||
|
} else if (entry === '+server.ts') {
|
||||||
|
results.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
||||||
|
const METHOD_RE = new RegExp(`export\\s+const\\s+(${HTTP_METHODS.join('|')})`, 'g');
|
||||||
|
|
||||||
|
function svelteKitPathToOpenAPI(filePath: string): string {
|
||||||
|
const rel = relative(ROUTES_DIR, dirname(filePath));
|
||||||
|
const segments = rel === '' ? [] : rel.split('/');
|
||||||
|
const apiPath =
|
||||||
|
'/api/' +
|
||||||
|
segments
|
||||||
|
.map((s) => {
|
||||||
|
const match = s.match(/^\[(.+)\]$/);
|
||||||
|
return match ? `{${match[1]}}` : s;
|
||||||
|
})
|
||||||
|
.join('/');
|
||||||
|
return apiPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMethods(filePath: string): string[] {
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const methods: string[] = [];
|
||||||
|
let match;
|
||||||
|
while ((match = METHOD_RE.exec(content)) !== null) {
|
||||||
|
methods.push(match[1].toLowerCase());
|
||||||
|
}
|
||||||
|
METHOD_RE.lastIndex = 0;
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferTag(path: string): string {
|
||||||
|
const first = path.replace('/api/', '').split('/')[0];
|
||||||
|
return first.charAt(0).toUpperCase() + first.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPathParams(path: string) {
|
||||||
|
const params: { name: string; in: string; required: boolean; schema: { type: string } }[] = [];
|
||||||
|
const re = /\{(\w+)\}/g;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(path)) !== null) {
|
||||||
|
params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } });
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = walkDir(ROUTES_DIR);
|
||||||
|
const paths: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
for (const fullPath of files) {
|
||||||
|
const apiPath = svelteKitPathToOpenAPI(fullPath);
|
||||||
|
const methods = extractMethods(fullPath);
|
||||||
|
|
||||||
|
if (methods.length === 0) continue;
|
||||||
|
|
||||||
|
const pathParams = extractPathParams(apiPath);
|
||||||
|
const pathEntry: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (pathParams.length > 0) {
|
||||||
|
pathEntry.parameters = pathParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const method of methods) {
|
||||||
|
const operation: Record<string, unknown> = {
|
||||||
|
tags: [inferTag(apiPath)],
|
||||||
|
responses: {
|
||||||
|
'200': { description: 'Success' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (['post', 'put', 'patch'].includes(method)) {
|
||||||
|
operation.requestBody = {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { type: 'object' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pathEntry[method] = operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
paths[apiPath] = pathEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedPaths: Record<string, Record<string, unknown>> = {};
|
||||||
|
for (const key of Object.keys(paths).sort()) {
|
||||||
|
sortedPaths[key] = paths[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = {
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: {
|
||||||
|
title: `${pkg.name.charAt(0).toUpperCase() + pkg.name.slice(1)} API`,
|
||||||
|
description: `Auto-generated API spec for ${pkg.name}`,
|
||||||
|
version: pkg.version,
|
||||||
|
contact: {
|
||||||
|
name: 'Bretzel',
|
||||||
|
url: 'https://bretzel.app'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
servers: [{ url: '/', description: 'Current instance' }],
|
||||||
|
security: [{ sessionCookie: [] }],
|
||||||
|
paths: sortedPaths,
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
sessionCookie: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'cookie',
|
||||||
|
name: 'session'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(dirname(OUTPUT), { recursive: true });
|
||||||
|
writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + '\n');
|
||||||
|
console.log(`Generated ${relative(ROOT, OUTPUT)} with ${Object.keys(paths).length} paths`);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { chromium } from '@playwright/test';
|
||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import { existsSync, unlinkSync, mkdirSync } from 'fs';
|
||||||
|
import { seed, login } from './screenshots/seed';
|
||||||
|
import { captureDesktop, captureMobile } from './screenshots/capture';
|
||||||
|
import {
|
||||||
|
BASE_URL,
|
||||||
|
SCREENSHOT_DB,
|
||||||
|
DESKTOP_VIEWPORT,
|
||||||
|
MOBILE_VIEWPORT,
|
||||||
|
OUTPUT_DIR
|
||||||
|
} from './screenshots/constants';
|
||||||
|
|
||||||
|
function cleanDatabase(): void {
|
||||||
|
for (const ext of ['', '-journal', '-wal', '-shm']) {
|
||||||
|
const path = `${SCREENSHOT_DB}${ext}`;
|
||||||
|
if (existsSync(path)) {
|
||||||
|
unlinkSync(path);
|
||||||
|
console.log(` deleted ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok || res.status === 302) return;
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Server not reachable at ${url} after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(): Promise<ChildProcess> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const dir = SCREENSHOT_DB.substring(0, SCREENSHOT_DB.lastIndexOf('/'));
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const server = spawn('pnpm', ['preview', '--port', '4173'], {
|
||||||
|
env: { ...process.env, DATABASE_URL: SCREENSHOT_DB, NODE_ENV: 'production' },
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', reject);
|
||||||
|
server.on('exit', (code) => {
|
||||||
|
if (code !== null && code !== 0) reject(new Error(`Server exited with code ${code}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll until server responds (stdout detection is unreliable in CI)
|
||||||
|
waitForServer(BASE_URL, 60_000)
|
||||||
|
.then(() => resolve(server))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('Screenshot automation starting...\n');
|
||||||
|
|
||||||
|
// 1. Clean DB
|
||||||
|
console.log('Cleaning database...');
|
||||||
|
cleanDatabase();
|
||||||
|
|
||||||
|
// 2. Ensure output dir exists
|
||||||
|
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// 3. Start preview server
|
||||||
|
console.log('Starting preview server...');
|
||||||
|
const server = await startServer();
|
||||||
|
console.log('Server ready.\n');
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. Seed data (in desktop context)
|
||||||
|
console.log('Seeding data...');
|
||||||
|
const desktopContext = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
|
||||||
|
const desktopPage = await desktopContext.newPage();
|
||||||
|
await seed(desktopPage);
|
||||||
|
console.log('Seed complete.\n');
|
||||||
|
|
||||||
|
// 5. Desktop screenshots
|
||||||
|
await captureDesktop(desktopPage);
|
||||||
|
await desktopContext.close();
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 6. Mobile screenshots (fresh context with mobile viewport)
|
||||||
|
const mobileContext = await browser.newContext({ viewport: MOBILE_VIEWPORT });
|
||||||
|
const mobilePage = await mobileContext.newPage();
|
||||||
|
await login(mobilePage);
|
||||||
|
await captureMobile(mobilePage);
|
||||||
|
await mobileContext.close();
|
||||||
|
|
||||||
|
console.log('\nAll screenshots captured successfully!');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.kill('SIGTERM');
|
||||||
|
// Force exit — pnpm spawns vite as a grandchild process that
|
||||||
|
// server.kill() doesn't reach, leaving the CI job hanging
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Screenshot automation failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Page, Locator } from '@playwright/test';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { BASE_URL, OUTPUT_DIR } from './constants';
|
||||||
|
|
||||||
|
async function screenshot(page: Page, name: string): Promise<void> {
|
||||||
|
await page.screenshot({ path: join(OUTPUT_DIR, name), type: 'png' });
|
||||||
|
console.log(` captured ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForApp(page: Page): Promise<void> {
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click a note card by its title (targets the h3 inside note-card to avoid content substring matches) */
|
||||||
|
function noteCard(page: Page, title: string): Locator {
|
||||||
|
return page.locator('[data-testid="note-card"]', { has: page.locator('h3', { hasText: title }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureDesktop(page: Page): Promise<void> {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
console.log('Capturing desktop screenshots...');
|
||||||
|
|
||||||
|
// D1: Grid view (also used as hero)
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-grid.png');
|
||||||
|
|
||||||
|
// D2: Rich text editor — open "Self-Hosting Stack"
|
||||||
|
await noteCard(page, 'Self-Hosting Stack').click();
|
||||||
|
await page.waitForSelector('[data-testid="note-editor"]');
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-editor.png');
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// D3: Checklist — open "Bretzel Ingredients"
|
||||||
|
await noteCard(page, 'Bretzel Ingredients').click();
|
||||||
|
await page.waitForSelector('[data-testid="note-editor"]');
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-checklist.png');
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// D4: Sharing dialog — open "Weekend Plans", click share
|
||||||
|
await noteCard(page, 'Weekend Plans').click();
|
||||||
|
await page.waitForSelector('[data-testid="note-editor"]');
|
||||||
|
await page.getByTestId('share-toggle').click();
|
||||||
|
await page.waitForSelector('[data-testid="share-dialog"]');
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-sharing.png');
|
||||||
|
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// D5: Version history — open "Self-Hosting Stack", click history
|
||||||
|
await noteCard(page, 'Self-Hosting Stack').click();
|
||||||
|
await page.waitForSelector('[data-testid="note-editor"]');
|
||||||
|
await page.getByTestId('history-toggle').click();
|
||||||
|
await page.waitForSelector('[data-testid="history-panel"]');
|
||||||
|
// Wait for version items to load from API
|
||||||
|
await page.waitForSelector('[data-testid="version-item"]', { timeout: 10_000 });
|
||||||
|
const versionItems = page.getByTestId('version-item');
|
||||||
|
if ((await versionItems.count()) > 1) {
|
||||||
|
await versionItems.nth(1).click();
|
||||||
|
}
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-history.png');
|
||||||
|
// Close history panel first (overlay blocks close-editor-btn)
|
||||||
|
await page.getByTestId('close-history-btn').click();
|
||||||
|
await waitForApp(page);
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// D6: API/MCP settings
|
||||||
|
await page.goto(`${BASE_URL}/settings/mcp`);
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-api.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureMobile(page: Page): Promise<void> {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
console.log('Capturing mobile screenshots...');
|
||||||
|
|
||||||
|
// M1: Mobile grid view
|
||||||
|
await page.goto(BASE_URL);
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-mobile-grid.png');
|
||||||
|
|
||||||
|
// M2: Mobile editor — open "Self-Hosting Stack"
|
||||||
|
await noteCard(page, 'Self-Hosting Stack').click();
|
||||||
|
await page.waitForSelector('[data-testid="note-editor"]');
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-mobile-editor.png');
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// M3: Mobile checklist — open "Bretzel Ingredients"
|
||||||
|
await noteCard(page, 'Bretzel Ingredients').click();
|
||||||
|
await page.waitForSelector('[data-testid="note-editor"]');
|
||||||
|
await waitForApp(page);
|
||||||
|
await screenshot(page, 'screenshot-mobile-checklist.png');
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const BASE_URL = 'http://localhost:4173';
|
||||||
|
export const SCREENSHOT_DB = './data/screenshots.db';
|
||||||
|
export const OUTPUT_DIR = 'website/assets';
|
||||||
|
|
||||||
|
export const DESKTOP_VIEWPORT = { width: 1280, height: 800 };
|
||||||
|
export const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||||
|
|
||||||
|
export const ADMIN = {
|
||||||
|
email: 'admin@bretzel.app',
|
||||||
|
displayName: 'Bretzel Admin',
|
||||||
|
password: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLLABORATOR = {
|
||||||
|
email: 'salzig@bretzel.app',
|
||||||
|
displayName: 'Salzig Bretzel',
|
||||||
|
password: crypto.randomUUID()
|
||||||
|
};
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { BASE_URL, ADMIN, COLLABORATOR } from './constants';
|
||||||
|
|
||||||
|
type HttpMethod = 'post' | 'patch' | 'get' | 'delete';
|
||||||
|
|
||||||
|
async function api(page: Page, method: HttpMethod, path: string, data?: unknown) {
|
||||||
|
const res = await page.request[method](
|
||||||
|
`${BASE_URL}${path}`,
|
||||||
|
data !== undefined ? { data } : undefined
|
||||||
|
);
|
||||||
|
if (!res.ok()) {
|
||||||
|
throw new Error(`${method.toUpperCase()} ${path} failed: ${res.status()} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoteData {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
color: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
checklistMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotesData(): NoteData[] {
|
||||||
|
// Created in reverse display order so newest (first created) appears last
|
||||||
|
// and the grid reads naturally top-left to bottom-right
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'bakingcode',
|
||||||
|
title: 'Crumbs v0.23 Release',
|
||||||
|
content: `- [x] CRDT sync logic
|
||||||
|
- [x] MCP server integration
|
||||||
|
- [ ] Bake the Docker image
|
||||||
|
- [ ] Deploy to the homelab
|
||||||
|
- [ ] Celebrate with a bretzel`,
|
||||||
|
color: 'clay',
|
||||||
|
checklistMode: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'coding-music',
|
||||||
|
title: 'Coding Playlists',
|
||||||
|
content: `Curated on **Navidrome** for maximum focus.
|
||||||
|
|
||||||
|
### Late Night Focus
|
||||||
|
|
||||||
|
1. *Tycho* — Dive (ambient electronic)
|
||||||
|
2. *Boards of Canada* — Music Has the Right to Children
|
||||||
|
3. *Nujabes* — Modal Soul
|
||||||
|
4. *C418* — Minecraft Volume Alpha
|
||||||
|
|
||||||
|
### Saturday Baking
|
||||||
|
|
||||||
|
For slow mornings in the kitchen:
|
||||||
|
|
||||||
|
1. *Khruangbin* — Con Todo El Mundo
|
||||||
|
2. *Tatsuro Yamashita* — For You
|
||||||
|
|
||||||
|
#music #personal`,
|
||||||
|
color: 'chalk'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'weekend',
|
||||||
|
title: 'Weekend Plans',
|
||||||
|
content: `### Saturday
|
||||||
|
|
||||||
|
- Farmer's market — get sourdough starter
|
||||||
|
- Fix the Dokploy SSL cert renewal
|
||||||
|
- Bake a batch of bretzels
|
||||||
|
|
||||||
|
### Sunday
|
||||||
|
|
||||||
|
- Retro gaming marathon on *RetroAssembly*
|
||||||
|
- Update the self-hosting stack
|
||||||
|
- Plan next trip on **42**
|
||||||
|
|
||||||
|
> A weekend well spent brings a week of content.
|
||||||
|
|
||||||
|
#personal #weekend`,
|
||||||
|
color: 'mint'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manifesto',
|
||||||
|
title: '',
|
||||||
|
content: `The best software is the software you **own**. Self-host everything. Trust no cloud. 🥨
|
||||||
|
|
||||||
|
#personal #selfhosting`,
|
||||||
|
color: 'coral'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'retro',
|
||||||
|
title: 'Back to Retro Gaming',
|
||||||
|
content: `Games running on **RetroAssembly** this summer:
|
||||||
|
|
||||||
|
- **Chrono Trigger** (SNES) — best RPG ever made
|
||||||
|
- **Link's Awakening** (GB) — pure portable magic
|
||||||
|
- **Symphony of the Night** (PS1) — the *definitive* Metroidvania
|
||||||
|
- **Advance Wars** (GBA) — turn-based perfection
|
||||||
|
- **Final Fantasy Tactics** (PS1) — political intrigue + job system
|
||||||
|
|
||||||
|
> The pixel art era was peak game design.
|
||||||
|
|
||||||
|
#gaming #retro`,
|
||||||
|
color: 'peach'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'selfhosting',
|
||||||
|
title: 'Self-Hosting Stack',
|
||||||
|
content: `All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||||
|
|
||||||
|
### Core Services
|
||||||
|
|
||||||
|
- **Crumbs** — notes *(obviously)*
|
||||||
|
- **Dokploy** — deployment platform
|
||||||
|
- **Outline** — team wiki
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||||
|
|
||||||
|
Just getting started. More to come.
|
||||||
|
|
||||||
|
#homelab #devops`,
|
||||||
|
color: 'fog'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bretzel',
|
||||||
|
title: 'Bretzel Ingredients',
|
||||||
|
content: `- [x] 500g bread flour
|
||||||
|
- [x] 300ml warm water
|
||||||
|
- [x] 10g salt
|
||||||
|
- [ ] 7g dry yeast
|
||||||
|
- [ ] 30g butter (softened)
|
||||||
|
- [ ] Baking soda for the bath
|
||||||
|
- [ ] Coarse salt for topping`,
|
||||||
|
color: 'sand',
|
||||||
|
checklistMode: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionHistoryContents(): string[] {
|
||||||
|
return [
|
||||||
|
// Version 2: Add Navidrome + RetroAssembly
|
||||||
|
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||||
|
|
||||||
|
### Core Services
|
||||||
|
|
||||||
|
- **Crumbs** — notes *(obviously)*
|
||||||
|
- **Dokploy** — deployment platform
|
||||||
|
- **Outline** — team wiki
|
||||||
|
- **Navidrome** — music streaming
|
||||||
|
- **RetroAssembly** — retro game emulation
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||||
|
|
||||||
|
Growing the stack.
|
||||||
|
|
||||||
|
#homelab #devops`,
|
||||||
|
|
||||||
|
// Version 3: Add 42 + Umami
|
||||||
|
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||||
|
|
||||||
|
### Core Services
|
||||||
|
|
||||||
|
- **Crumbs** — notes *(obviously)*
|
||||||
|
- **42** — holiday budget tracking
|
||||||
|
- **Dokploy** — deployment platform
|
||||||
|
- **Outline** — team wiki
|
||||||
|
- **Navidrome** — music streaming
|
||||||
|
- **RetroAssembly** — retro game emulation
|
||||||
|
- **Umami** — privacy-first analytics
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||||
|
|
||||||
|
The stack is getting serious.
|
||||||
|
|
||||||
|
#homelab #devops`,
|
||||||
|
|
||||||
|
// Version 4: Final version
|
||||||
|
`All services running behind **Caddy** reverse proxy on a single Hetzner VPS.
|
||||||
|
|
||||||
|
### Core Services
|
||||||
|
|
||||||
|
- **Crumbs** — notes *(obviously)*
|
||||||
|
- **42** — holiday budget tracking
|
||||||
|
- **Dokploy** — deployment platform
|
||||||
|
- **Outline** — team wiki
|
||||||
|
- **Navidrome** — music streaming
|
||||||
|
- **RetroAssembly** — retro game emulation
|
||||||
|
- **Umami** — privacy-first analytics
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
> Everything that can break *will* break at 3 AM on a Sunday.
|
||||||
|
|
||||||
|
Everything below daily reverse proxy. The dream stack is *almost* complete.
|
||||||
|
|
||||||
|
#homelab #devops`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seed(page: Page): Promise<void> {
|
||||||
|
// 1. Setup admin (sets session cookie)
|
||||||
|
await api(page, 'post', '/api/auth/setup', {
|
||||||
|
email: ADMIN.email,
|
||||||
|
displayName: ADMIN.displayName,
|
||||||
|
password: ADMIN.password
|
||||||
|
});
|
||||||
|
console.log(' admin created');
|
||||||
|
|
||||||
|
// 2. Create collaborator user
|
||||||
|
const alice = await api(page, 'post', '/api/admin/users', {
|
||||||
|
email: COLLABORATOR.email,
|
||||||
|
displayName: COLLABORATOR.displayName,
|
||||||
|
password: COLLABORATOR.password,
|
||||||
|
role: 'user'
|
||||||
|
});
|
||||||
|
const aliceId: number = alice.id;
|
||||||
|
console.log(' collaborator created (id:', aliceId, ')');
|
||||||
|
|
||||||
|
// 3. Create notes (order matters — newest appear first in grid)
|
||||||
|
const noteData = getNotesData();
|
||||||
|
const noteIds: Record<string, string> = {};
|
||||||
|
for (const note of noteData) {
|
||||||
|
const created = await api(page, 'post', '/api/notes', {
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
color: note.color,
|
||||||
|
pinned: note.pinned ?? false,
|
||||||
|
checklistMode: note.checklistMode ?? false
|
||||||
|
});
|
||||||
|
noteIds[note.key] = created.id;
|
||||||
|
console.log(' note created:', note.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Share "Weekend Plans" with Alice
|
||||||
|
await api(page, 'post', `/api/notes/${noteIds['weekend']}/collaborators`, {
|
||||||
|
userId: aliceId
|
||||||
|
});
|
||||||
|
console.log(' shared "Weekend Plans" with Alice');
|
||||||
|
|
||||||
|
// 5. Create version history for "Self-Hosting Stack"
|
||||||
|
const versionContents = getVersionHistoryContents();
|
||||||
|
for (let i = 0; i < versionContents.length; i++) {
|
||||||
|
await api(page, 'patch', `/api/notes/${noteIds['selfhosting']}`, {
|
||||||
|
content: versionContents[i]
|
||||||
|
});
|
||||||
|
console.log(` version ${i + 2} created for "Self-Hosting Stack"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Create API keys
|
||||||
|
await api(page, 'post', '/api/settings/api-keys', { name: 'Claude Code' });
|
||||||
|
await api(page, 'post', '/api/settings/api-keys', { name: 'N8N' });
|
||||||
|
console.log(' API keys created');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(page: Page): Promise<void> {
|
||||||
|
await page.goto(`${BASE_URL}/login`);
|
||||||
|
await page.getByTestId('email-input').fill(ADMIN.email);
|
||||||
|
await page.getByTestId('password-input').fill(ADMIN.password);
|
||||||
|
await page.getByTestId('login-btn').click();
|
||||||
|
await page.waitForURL('**/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
+155
-1
@@ -20,6 +20,25 @@
|
|||||||
--success-text: #3a5a2e;
|
--success-text: #3a5a2e;
|
||||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||||
|
--grid-dot: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-base: #1a1715;
|
||||||
|
--bg-surface: #2a2520;
|
||||||
|
--text: #e8dcc8;
|
||||||
|
--text-muted: #9a8e7e;
|
||||||
|
--border: #e8dcc8;
|
||||||
|
--border-subtle: #3a3530;
|
||||||
|
--destructive: #d4604e;
|
||||||
|
--error-bg: #3a2020;
|
||||||
|
--error-border: #5a3030;
|
||||||
|
--error-text: #e8a090;
|
||||||
|
--success-bg: #203020;
|
||||||
|
--success-text: #90c880;
|
||||||
|
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||||
|
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||||
|
--grid-dot: #e8dcc8;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -33,7 +52,7 @@ body::before {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.03;
|
opacity: 0.03;
|
||||||
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
|
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
||||||
background-size: 4px 4px;
|
background-size: 4px 4px;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -72,3 +91,138 @@ body::before {
|
|||||||
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
|
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
|
||||||
color: var(--primary-hover);
|
color: var(--primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table styles for TipTap editor */
|
||||||
|
.tiptap table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap th,
|
||||||
|
.tiptap td {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
min-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap th {
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: color-mix(in srgb, var(--bg-base) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap td p,
|
||||||
|
.tiptap th p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .selectedCell {
|
||||||
|
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task list styles — shared between TipTap editor and markdown-it preview */
|
||||||
|
|
||||||
|
/* TipTap: ul[data-type="taskList"] | markdown-it: ul.task-list */
|
||||||
|
.tiptap ul[data-type="taskList"],
|
||||||
|
.prose ul.task-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] li,
|
||||||
|
.prose ul.task-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested task lists take full width below the parent item */
|
||||||
|
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
|
||||||
|
.prose ul.task-list li > ul.task-list {
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] li::marker,
|
||||||
|
.prose ul.task-list li::marker {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TipTap: checkbox is inside a label */
|
||||||
|
.tiptap ul[data-type="taskList"] li label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared checkbox styles */
|
||||||
|
.tiptap ul[data-type="taskList"] li label input[type="checkbox"],
|
||||||
|
.prose ul.task-list .task-checkbox {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TipTap: text content is in a div>p */
|
||||||
|
.tiptap ul[data-type="taskList"] li > div {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type="taskList"] li > div p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checked item styling */
|
||||||
|
.tiptap ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override prose spacing for task lists */
|
||||||
|
.prose ul[data-type="taskList"],
|
||||||
|
.prose ul.task-list {
|
||||||
|
padding-left: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul[data-type="taskList"] li,
|
||||||
|
.prose ul.task-list li {
|
||||||
|
padding-left: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul.task-list li p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom tooltips */
|
||||||
|
.tooltip-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--bg-surface);
|
||||||
|
background: var(--text);
|
||||||
|
border-radius: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 100ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -1,3 +1,6 @@
|
|||||||
|
/// <reference types="vite-plugin-pwa/info" />
|
||||||
|
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||||
|
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
+15
-1
@@ -4,6 +4,21 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#f0e6d3" />
|
<meta name="theme-color" content="#f0e6d3" />
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||||
|
var t = p.theme || 'system';
|
||||||
|
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||||
|
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
if (t === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
var m = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (m) m.content = '#1a1715';
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
@@ -11,7 +26,6 @@
|
|||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Crumbs" />
|
<meta name="apple-mobile-web-app-title" content="Crumbs" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
|
||||||
<title>Crumbs</title>
|
<title>Crumbs</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+31
-1
@@ -1,13 +1,16 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
|
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
|
||||||
|
import { validateApiKey, getUserForApiKey } from '$lib/server/api-keys.js';
|
||||||
|
|
||||||
const PUBLIC_PATHS = [
|
const PUBLIC_PATHS = [
|
||||||
'/login',
|
'/login',
|
||||||
'/setup',
|
'/setup',
|
||||||
'/api/auth/login',
|
'/api/auth/login',
|
||||||
'/api/auth/setup',
|
'/api/auth/setup',
|
||||||
'/api/auth/oauth'
|
'/api/auth/oauth',
|
||||||
|
'/s/',
|
||||||
|
'/api/shared'
|
||||||
];
|
];
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
@@ -21,6 +24,33 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API routes: authenticate via Bearer token if present
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
const authHeader = event.request.headers.get('authorization');
|
||||||
|
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const { valid, userId } = validateApiKey(token);
|
||||||
|
if (!valid || !userId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = getUserForApiKey(userId);
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.locals.user = user;
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if setup is complete
|
// Check if setup is complete
|
||||||
const setupDone = await isSetupComplete();
|
const setupDone = await isSetupComplete();
|
||||||
if (!setupDone) {
|
if (!setupDone) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Mock for $app/environment used in Vitest unit tests
|
||||||
|
export const browser = true;
|
||||||
|
export const dev = false;
|
||||||
|
export const building = false;
|
||||||
|
export const version = 'test';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Polyfill Svelte 5 runes for Vitest.
|
||||||
|
* The Svelte compiler transforms these in .svelte and .svelte.ts files,
|
||||||
|
* but Vitest doesn't run the Svelte compiler, so we provide plain-JS stubs.
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
(globalThis as any).$state = <T>(initial: T): T => initial;
|
||||||
|
(globalThis as any).$derived = <T>(value: T): T => value;
|
||||||
|
(globalThis as any).$effect = (_fn: () => void): void => {};
|
||||||
|
(globalThis as any).$props = (): any => ({});
|
||||||
@@ -3,14 +3,10 @@
|
|||||||
import Plus from 'lucide-svelte/icons/plus';
|
import Plus from 'lucide-svelte/icons/plus';
|
||||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
|
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem } from '$lib/utils/checklist.js';
|
||||||
interface ChecklistItem {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
checked: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -19,45 +15,75 @@
|
|||||||
|
|
||||||
let { content, onChange }: Props = $props();
|
let { content, onChange }: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||||
let doneExpanded = $state(true);
|
let doneExpanded = $state(true);
|
||||||
const flipDurationMs = 150;
|
const flipDurationMs = 150;
|
||||||
|
|
||||||
|
// Track the last content we emitted so we can distinguish self-originated
|
||||||
|
// changes from external ones (sync, history restore, etc.)
|
||||||
|
let lastEmitted = content;
|
||||||
|
|
||||||
|
// Re-parse items when content changes externally (not from our own emitChange)
|
||||||
|
$effect(() => {
|
||||||
|
if (content !== lastEmitted) {
|
||||||
|
items = parseChecklist(content);
|
||||||
|
lastEmitted = content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let activeItems = $derived(items.filter((i) => !i.checked));
|
let activeItems = $derived(items.filter((i) => !i.checked));
|
||||||
let doneItems = $derived(items.filter((i) => i.checked));
|
let doneItems = $derived(items.filter((i) => i.checked));
|
||||||
let doneCount = $derived(doneItems.length);
|
let doneCount = $derived(doneItems.length);
|
||||||
|
|
||||||
function generateId(): string {
|
interface DoneSectionGroup {
|
||||||
return crypto.randomUUID();
|
parentLabel: ChecklistItem | null;
|
||||||
|
children: ChecklistItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseChecklist(text: string): ChecklistItem[] {
|
let doneSectionGroups = $derived.by(() => {
|
||||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
const groups: DoneSectionGroup[] = [];
|
||||||
const lines = text.split('\n');
|
const childrenByParent = new Map<string, ChecklistItem[]>();
|
||||||
return lines.map((line) => {
|
const topLevelDone: ChecklistItem[] = [];
|
||||||
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
|
||||||
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
for (const item of doneItems) {
|
||||||
return { id: generateId(), text: line, checked: false };
|
if (item.parentId) {
|
||||||
|
const siblings = childrenByParent.get(item.parentId) ?? [];
|
||||||
|
siblings.push(item);
|
||||||
|
childrenByParent.set(item.parentId, siblings);
|
||||||
|
} else {
|
||||||
|
topLevelDone.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of topLevelDone) {
|
||||||
|
const doneChildren = childrenByParent.get(item.id) ?? [];
|
||||||
|
groups.push({ parentLabel: null, children: [item, ...doneChildren] });
|
||||||
|
childrenByParent.delete(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [parentId, children] of childrenByParent) {
|
||||||
|
const parent = items.find((i) => i.id === parentId);
|
||||||
|
if (parent) {
|
||||||
|
groups.push({ parentLabel: parent, children });
|
||||||
|
} else {
|
||||||
|
groups.push({ parentLabel: null, children });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function serializeChecklist(list: ChecklistItem[]): string {
|
|
||||||
return list
|
|
||||||
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitChange() {
|
function emitChange() {
|
||||||
onChange(serializeChecklist(items));
|
const serialized = serializeChecklist(items);
|
||||||
|
lastEmitted = serialized;
|
||||||
|
onChange(serialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleItem(id: string) {
|
function toggleItem(id: string) {
|
||||||
const item = items.find((i) => i.id === id);
|
items = toggleItemWithCascade(id, items);
|
||||||
if (item) {
|
|
||||||
item.checked = !item.checked;
|
|
||||||
emitChange();
|
emitChange();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function updateText(id: string, text: string) {
|
function updateText(id: string, text: string) {
|
||||||
const item = items.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
@@ -68,18 +94,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addItem(afterIndex: number) {
|
function addItem(afterIndex: number) {
|
||||||
const newItem = { id: generateId(), text: '', checked: false };
|
const afterItem = items[afterIndex];
|
||||||
|
const newItem: ChecklistItem = {
|
||||||
|
id: generateId(),
|
||||||
|
text: '',
|
||||||
|
checked: false,
|
||||||
|
parentId: afterItem?.parentId ?? null
|
||||||
|
};
|
||||||
items.splice(afterIndex + 1, 0, newItem);
|
items.splice(afterIndex + 1, 0, newItem);
|
||||||
items = [...items];
|
items = [...items];
|
||||||
emitChange();
|
emitChange();
|
||||||
|
const newId = newItem.id;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
document.querySelector<HTMLInputElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||||
inputs[afterIndex + 1]?.focus();
|
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(id: string) {
|
function removeItem(id: string) {
|
||||||
if (items.length <= 1) return;
|
if (items.length <= 1) {
|
||||||
|
if (items[0].parentId) items[0].parentId = null;
|
||||||
|
items = [...items];
|
||||||
|
emitChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items = items.map((i) => (i.parentId === id ? { ...i, parentId: null } : i));
|
||||||
const index = items.findIndex((i) => i.id === id);
|
const index = items.findIndex((i) => i.id === id);
|
||||||
items.splice(index, 1);
|
items.splice(index, 1);
|
||||||
items = [...items];
|
items = [...items];
|
||||||
@@ -98,30 +136,137 @@
|
|||||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||||
inputs[Math.max(0, index - 1)]?.focus();
|
inputs[Math.max(0, index - 1)]?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]'));
|
||||||
|
const current = inputs.findIndex((el) => el.dataset.itemId === id);
|
||||||
|
const target = e.key === 'ArrowUp' ? current - 1 : current + 1;
|
||||||
|
if (target >= 0 && target < inputs.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputs[target].focus();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Tab' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
items = indentItem(id, items, activeItems);
|
||||||
|
emitChange();
|
||||||
|
} else if (e.key === 'Tab' && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
items = outdentItem(id, items);
|
||||||
|
emitChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Directional lock drag handle ---
|
||||||
|
// Instead of blocking DnD events (which corrupts its internal state),
|
||||||
|
// we detect horizontal intent and store the pending operation. DnD runs
|
||||||
|
// its full course unimpeded. After DnD finalizes, we apply the indent.
|
||||||
|
let dragStartX = 0;
|
||||||
|
let dragStartY = 0;
|
||||||
|
let pendingIndent: { id: string; direction: 'indent' | 'outdent' } | null = null;
|
||||||
|
let directionLocked = false;
|
||||||
|
const LOCK_THRESHOLD = 10;
|
||||||
|
const INDENT_THRESHOLD = 32;
|
||||||
|
|
||||||
|
function windowPointerMove(e: PointerEvent) {
|
||||||
|
if (directionLocked) return;
|
||||||
|
|
||||||
|
const dx = Math.abs(e.clientX - dragStartX);
|
||||||
|
const dy = Math.abs(e.clientY - dragStartY);
|
||||||
|
if (dx >= LOCK_THRESHOLD || dy >= LOCK_THRESHOLD) {
|
||||||
|
directionLocked = true;
|
||||||
|
if (dx <= dy) {
|
||||||
|
// Vertical — DnD handles it, we're done
|
||||||
|
removeWindowListeners();
|
||||||
|
}
|
||||||
|
// Horizontal — keep listening for pointerup to measure total dx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowPointerUp(e: PointerEvent) {
|
||||||
|
const dx = e.clientX - dragStartX;
|
||||||
|
if (Math.abs(dx) > INDENT_THRESHOLD && directionLocked && currentDragItemId) {
|
||||||
|
pendingIndent = {
|
||||||
|
id: currentDragItemId,
|
||||||
|
direction: dx > 0 ? 'indent' : 'outdent'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
removeWindowListeners();
|
||||||
|
directionLocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWindowListeners() {
|
||||||
|
window.addEventListener('pointermove', windowPointerMove, { capture: true });
|
||||||
|
window.addEventListener('pointerup', windowPointerUp, { capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWindowListeners() {
|
||||||
|
window.removeEventListener('pointermove', windowPointerMove, { capture: true });
|
||||||
|
window.removeEventListener('pointerup', windowPointerUp, { capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDragItemId = '';
|
||||||
|
function handleDragPointerDown(e: PointerEvent, id: string) {
|
||||||
|
dragStartX = e.clientX;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
directionLocked = false;
|
||||||
|
pendingIndent = null;
|
||||||
|
currentDragItemId = id;
|
||||||
|
addWindowListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(removeWindowListeners);
|
||||||
|
|
||||||
|
// --- DnD handlers ---
|
||||||
|
function recomputeParentIds(reorderedItems: ChecklistItem[]): ChecklistItem[] {
|
||||||
|
let lastTopLevelId: string | null = null;
|
||||||
|
return reorderedItems.map((item) => {
|
||||||
|
if (item.parentId === null) {
|
||||||
|
lastTopLevelId = item.id;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return { ...item, parentId: lastTopLevelId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||||
// Merge reordered active items with done items
|
|
||||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
const reorderedActive = recomputeParentIds(e.detail.items);
|
||||||
|
items = [...reorderedActive, ...items.filter((i) => i.checked)];
|
||||||
emitChange();
|
emitChange();
|
||||||
|
|
||||||
|
// Apply pending indent/outdent from horizontal drag (after DnD fully cleans up)
|
||||||
|
if (pendingIndent && pendingIndent.id) {
|
||||||
|
const { id, direction } = pendingIndent;
|
||||||
|
pendingIndent = null;
|
||||||
|
if (direction === 'indent') {
|
||||||
|
items = indentItem(id, items, activeItems);
|
||||||
|
} else {
|
||||||
|
items = outdentItem(id, items);
|
||||||
|
}
|
||||||
|
emitChange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-0.5" data-testid="checklist">
|
<div class="space-y-0.5" data-testid="checklist">
|
||||||
<section
|
<section
|
||||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
|
||||||
onconsider={handleDndConsider}
|
onconsider={handleDndConsider}
|
||||||
onfinalize={handleDndFinalize}
|
onfinalize={handleDndFinalize}
|
||||||
class="space-y-0.5"
|
class="space-y-0.5"
|
||||||
>
|
>
|
||||||
{#each activeItems as item (item.id)}
|
{#each activeItems as item (item.id)}
|
||||||
<div class="group flex items-center gap-2 py-1" animate:flip={{ duration: flipDurationMs }}>
|
<div class="group flex items-center gap-2 py-1.5 outline-none {item.parentId ? 'pl-8' : ''}"
|
||||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
data-testid={item.parentId ? 'checklist-child-row' : undefined}
|
||||||
|
animate:flip={{ duration: flipDurationMs }}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div use:dragHandle
|
||||||
|
aria-label="drag handle"
|
||||||
|
onpointerdown={(e) => handleDragPointerDown(e, item.id)}
|
||||||
|
class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150 touch-none outline-none"
|
||||||
|
data-testid="checklist-drag-handle">
|
||||||
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -136,9 +281,10 @@
|
|||||||
value={item.text}
|
value={item.text}
|
||||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
class="flex-1 min-w-0 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||||
placeholder="List item"
|
placeholder="List item"
|
||||||
data-testid="checklist-input"
|
data-testid="checklist-input"
|
||||||
|
data-item-id={item.id}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={() => removeItem(item.id)}
|
onclick={() => removeItem(item.id)}
|
||||||
@@ -154,7 +300,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => addItem(items.length - 1)}
|
onclick={() => addItem(items.length - 1)}
|
||||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||||
data-testid="checklist-add"
|
data-testid="checklist-add"
|
||||||
>
|
>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
@@ -173,9 +319,16 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if doneExpanded}
|
{#if doneExpanded}
|
||||||
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
|
<div class="mt-1 space-y-0.5" data-testid="checklist-done-section">
|
||||||
{#each doneItems as item (item.id)}
|
{#each doneSectionGroups as group}
|
||||||
<div class="group flex items-center gap-2 py-1">
|
{#if group.parentLabel}
|
||||||
|
<div class="flex items-center gap-2 py-1.5 pl-5" data-testid="checklist-done-parent-label">
|
||||||
|
<input type="checkbox" disabled class="h-4 w-4 rounded border-[var(--border-subtle)] opacity-50" />
|
||||||
|
<span class="text-sm text-[var(--text-muted)]">{group.parentLabel.text}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each group.children as item (item.id)}
|
||||||
|
<div class="group flex items-center gap-2 py-1.5 {item.parentId ? 'pl-10' : 'pl-5'}">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
@@ -194,8 +347,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Collaborator } from '$lib/types/index.js';
|
||||||
|
import Users from 'lucide-svelte/icons/users';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collaborators: Collaborator[];
|
||||||
|
ownerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collaborators, ownerName }: Props = $props();
|
||||||
|
|
||||||
|
let showPopover = $state(false);
|
||||||
|
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
hoverTimeout = setTimeout(() => {
|
||||||
|
showPopover = true;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
clearTimeout(hoverTimeout);
|
||||||
|
showPopover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showPopover = !showPopover;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="relative inline-flex"
|
||||||
|
onmouseenter={handleMouseEnter}
|
||||||
|
onmouseleave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||||
|
title="Shared note"
|
||||||
|
onclick={handleClick}
|
||||||
|
data-testid="collaborator-indicator"
|
||||||
|
>
|
||||||
|
<Users class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showPopover}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||||
|
data-testid="collaborator-popover"
|
||||||
|
>
|
||||||
|
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{#if ownerName}
|
||||||
|
<li class="text-xs text-[var(--text)]">
|
||||||
|
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#each collaborators as collab}
|
||||||
|
<li class="text-xs text-[var(--text)]">
|
||||||
|
{collab.displayName || collab.email}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
|
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
|
||||||
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||||
import type { NoteColor } from '$lib/types/index.js';
|
import type { NoteColor } from '$lib/types/index.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,11 +13,10 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-1" data-testid="color-picker">
|
<div class="flex flex-wrap gap-1" data-testid="color-picker">
|
||||||
{#each COLOR_OPTIONS as option}
|
{#each COLOR_OPTIONS as option}
|
||||||
{@const colors = NOTE_COLORS[option.value]}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => onSelect(option.value)}
|
onclick={() => onSelect(option.value)}
|
||||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
|
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
|
||||||
style="background-color: {colors.bg}"
|
style="background-color: {getNoteColor(option.value, getIsDarkMode())}"
|
||||||
title={option.label}
|
title={option.label}
|
||||||
data-testid="color-{option.value}"
|
data-testid="color-{option.value}"
|
||||||
></button>
|
></button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
Heading,
|
Heading,
|
||||||
List,
|
List,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
|
ListChecks,
|
||||||
TextQuote,
|
TextQuote,
|
||||||
CodeXml,
|
CodeXml,
|
||||||
Bold,
|
Bold,
|
||||||
@@ -23,7 +24,10 @@
|
|||||||
AlignJustify,
|
AlignJustify,
|
||||||
Minus,
|
Minus,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Ellipsis
|
Table2,
|
||||||
|
BetweenHorizontalEnd,
|
||||||
|
BetweenVerticalEnd,
|
||||||
|
RemoveFormatting
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -35,6 +39,9 @@
|
|||||||
|
|
||||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return editor?.isActive(nameOrAttrs, attrs) ?? false; }
|
||||||
|
function getAttrs(type: string) { void tick; return editor?.getAttributes(type) ?? {}; }
|
||||||
|
|
||||||
let openDropdown: string | null = $state(null);
|
let openDropdown: string | null = $state(null);
|
||||||
let linkUrl: string = $state('');
|
let linkUrl: string = $state('');
|
||||||
@@ -110,13 +117,35 @@
|
|||||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||||
data-testid="formatting-toolbar"
|
data-testid="formatting-toolbar"
|
||||||
>
|
>
|
||||||
|
<!-- Undo/Redo -->
|
||||||
|
<button
|
||||||
|
onclick={() => editor?.chain().focus().undo().run()}
|
||||||
|
disabled={!canUndo()}
|
||||||
|
class={btnClass(false, !canUndo())}
|
||||||
|
title="Undo"
|
||||||
|
data-testid="format-undo"
|
||||||
|
>
|
||||||
|
<Undo2 size={iconSize} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => editor?.chain().focus().redo().run()}
|
||||||
|
disabled={!canRedo()}
|
||||||
|
class={btnClass(false, !canRedo())}
|
||||||
|
title="Redo"
|
||||||
|
data-testid="format-redo"
|
||||||
|
>
|
||||||
|
<Redo2 size={iconSize} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||||
|
|
||||||
<!-- Inline -->
|
<!-- Inline -->
|
||||||
<button
|
<button
|
||||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||||
class={btnClass(editor?.isActive('bold') ?? false)}
|
class={btnClass(isActive('bold'))}
|
||||||
title="Bold (Ctrl+B)"
|
title="Bold (Ctrl+B)"
|
||||||
data-testid="format-bold"
|
data-testid="format-bold"
|
||||||
>
|
>
|
||||||
@@ -124,7 +153,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||||
class={btnClass(editor?.isActive('italic') ?? false)}
|
class={btnClass(isActive('italic'))}
|
||||||
title="Italic (Ctrl+I)"
|
title="Italic (Ctrl+I)"
|
||||||
data-testid="format-italic"
|
data-testid="format-italic"
|
||||||
>
|
>
|
||||||
@@ -132,7 +161,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||||
class={btnClass(editor?.isActive('strike') ?? false)}
|
class={btnClass(isActive('strike'))}
|
||||||
title="Strikethrough (Ctrl+Shift+X)"
|
title="Strikethrough (Ctrl+Shift+X)"
|
||||||
data-testid="format-strikethrough"
|
data-testid="format-strikethrough"
|
||||||
>
|
>
|
||||||
@@ -140,7 +169,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||||
class={btnClass(editor?.isActive('underline') ?? false)}
|
class={btnClass(isActive('underline'))}
|
||||||
title="Underline (Ctrl+U)"
|
title="Underline (Ctrl+U)"
|
||||||
data-testid="format-underline"
|
data-testid="format-underline"
|
||||||
>
|
>
|
||||||
@@ -153,7 +182,7 @@
|
|||||||
<div class="relative" data-dropdown="heading">
|
<div class="relative" data-dropdown="heading">
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleDropdown('heading')}
|
onclick={() => toggleDropdown('heading')}
|
||||||
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
|
class={dropdownBtnClass(isActive('heading'))}
|
||||||
title="Heading"
|
title="Heading"
|
||||||
data-testid="format-heading"
|
data-testid="format-heading"
|
||||||
>
|
>
|
||||||
@@ -164,7 +193,7 @@
|
|||||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
|
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
||||||
data-testid="format-h1"
|
data-testid="format-h1"
|
||||||
>
|
>
|
||||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||||
@@ -172,7 +201,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
|
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
||||||
data-testid="format-h2"
|
data-testid="format-h2"
|
||||||
>
|
>
|
||||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||||
@@ -180,7 +209,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
|
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
||||||
data-testid="format-h3"
|
data-testid="format-h3"
|
||||||
>
|
>
|
||||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
||||||
@@ -194,7 +223,7 @@
|
|||||||
<div class="relative" data-dropdown="list">
|
<div class="relative" data-dropdown="list">
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleDropdown('list')}
|
onclick={() => toggleDropdown('list')}
|
||||||
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
|
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||||
title="Lists"
|
title="Lists"
|
||||||
data-testid="format-list"
|
data-testid="format-list"
|
||||||
>
|
>
|
||||||
@@ -205,7 +234,7 @@
|
|||||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
|
class={dropdownItemClass(isActive('bulletList'))}
|
||||||
data-testid="format-bullet-list"
|
data-testid="format-bullet-list"
|
||||||
>
|
>
|
||||||
<List size={iconSize} />
|
<List size={iconSize} />
|
||||||
@@ -213,12 +242,20 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
|
class={dropdownItemClass(isActive('orderedList'))}
|
||||||
data-testid="format-ordered-list"
|
data-testid="format-ordered-list"
|
||||||
>
|
>
|
||||||
<ListOrdered size={iconSize} />
|
<ListOrdered size={iconSize} />
|
||||||
<span>Ordered list</span>
|
<span>Ordered list</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||||
|
class={dropdownItemClass(isActive('taskList'))}
|
||||||
|
data-testid="format-task-list"
|
||||||
|
>
|
||||||
|
<ListChecks size={iconSize} />
|
||||||
|
<span>Task list</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +264,7 @@
|
|||||||
<div class="relative" data-dropdown="link">
|
<div class="relative" data-dropdown="link">
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleDropdown('link')}
|
onclick={() => toggleDropdown('link')}
|
||||||
class={btnClass(editor?.isActive('link') ?? false)}
|
class={btnClass(isActive('link'))}
|
||||||
title="Insert link (Ctrl+K)"
|
title="Insert link (Ctrl+K)"
|
||||||
data-testid="format-link"
|
data-testid="format-link"
|
||||||
>
|
>
|
||||||
@@ -259,7 +296,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openLink}
|
onclick={openLink}
|
||||||
disabled={!editor?.getAttributes('link').href}
|
disabled={!getAttrs('link').href}
|
||||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||||
title="Open link"
|
title="Open link"
|
||||||
data-testid="format-link-open"
|
data-testid="format-link-open"
|
||||||
@@ -269,7 +306,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={removeLink}
|
onclick={removeLink}
|
||||||
disabled={!editor?.isActive('link')}
|
disabled={!isActive('link')}
|
||||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||||
title="Remove link"
|
title="Remove link"
|
||||||
data-testid="format-unlink"
|
data-testid="format-unlink"
|
||||||
@@ -281,99 +318,123 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
<!-- Table dropdown -->
|
||||||
|
<div class="relative" data-dropdown="table">
|
||||||
<!-- More dropdown -->
|
|
||||||
<div class="relative" data-dropdown="more">
|
|
||||||
<button
|
<button
|
||||||
onclick={() => toggleDropdown('more')}
|
onclick={() => toggleDropdown('table')}
|
||||||
class={dropdownBtnClass(false)}
|
class={dropdownBtnClass(isActive('table'))}
|
||||||
title="More options"
|
title="Table"
|
||||||
data-testid="format-more"
|
data-testid="format-table"
|
||||||
>
|
>
|
||||||
<Ellipsis size={iconSize} />
|
<Table2 size={iconSize} />
|
||||||
|
<ChevronDown size={chevronSize} />
|
||||||
</button>
|
</button>
|
||||||
{#if openDropdown === 'more'}
|
{#if openDropdown === 'table'}
|
||||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||||
disabled={!canUndo()}
|
|
||||||
class={dropdownItemClass()}
|
class={dropdownItemClass()}
|
||||||
data-testid="format-undo"
|
data-testid="format-table-insert"
|
||||||
>
|
>
|
||||||
<Undo2 size={iconSize} />
|
<Table2 size={iconSize} />
|
||||||
<span>Undo</span>
|
<span>Insert table</span>
|
||||||
|
</button>
|
||||||
|
{#if isActive('table')}
|
||||||
|
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||||
|
<button
|
||||||
|
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||||
|
class={dropdownItemClass()}
|
||||||
|
data-testid="format-table-add-row"
|
||||||
|
>
|
||||||
|
<BetweenHorizontalEnd size={iconSize} />
|
||||||
|
<span>Add row</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||||
disabled={!canRedo()}
|
|
||||||
class={dropdownItemClass()}
|
class={dropdownItemClass()}
|
||||||
data-testid="format-redo"
|
data-testid="format-table-add-col"
|
||||||
>
|
>
|
||||||
<Redo2 size={iconSize} />
|
<BetweenVerticalEnd size={iconSize} />
|
||||||
<span>Redo</span>
|
<span>Add column</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive('code') ?? false)}
|
|
||||||
data-testid="format-code"
|
|
||||||
>
|
|
||||||
<Code size={iconSize} />
|
|
||||||
<span>Inline code</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
|
|
||||||
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
|
|
||||||
data-testid="format-blockquote"
|
|
||||||
>
|
|
||||||
<TextQuote size={iconSize} />
|
|
||||||
<span>Blockquote</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
|
|
||||||
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
|
|
||||||
data-testid="format-code-block"
|
|
||||||
>
|
|
||||||
<CodeXml size={iconSize} />
|
|
||||||
<span>Code block</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
|
|
||||||
class={dropdownItemClass()}
|
class={dropdownItemClass()}
|
||||||
data-testid="format-hr"
|
data-testid="format-table-delete-row"
|
||||||
>
|
>
|
||||||
<Minus size={iconSize} />
|
<Minus size={iconSize} />
|
||||||
<span>Divider</span>
|
<span>Delete row</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
<button
|
||||||
|
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||||
|
class={dropdownItemClass()}
|
||||||
|
data-testid="format-table-delete-col"
|
||||||
|
>
|
||||||
|
<Minus size={iconSize} />
|
||||||
|
<span>Delete column</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||||
|
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||||
|
data-testid="format-table-delete"
|
||||||
|
>
|
||||||
|
<RemoveFormatting size={iconSize} />
|
||||||
|
<span>Delete table</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alignment dropdown -->
|
||||||
|
<div class="relative" data-dropdown="align">
|
||||||
|
<button
|
||||||
|
onclick={() => toggleDropdown('align')}
|
||||||
|
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
||||||
|
title="Text alignment"
|
||||||
|
data-testid="format-align"
|
||||||
|
>
|
||||||
|
{#if isActive({ textAlign: 'center' })}
|
||||||
|
<AlignCenter size={iconSize} />
|
||||||
|
{:else if isActive({ textAlign: 'right' })}
|
||||||
|
<AlignRight size={iconSize} />
|
||||||
|
{:else if isActive({ textAlign: 'justify' })}
|
||||||
|
<AlignJustify size={iconSize} />
|
||||||
|
{:else}
|
||||||
|
<AlignLeft size={iconSize} />
|
||||||
|
{/if}
|
||||||
|
<ChevronDown size={chevronSize} />
|
||||||
|
</button>
|
||||||
|
{#if openDropdown === 'align'}
|
||||||
|
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
|
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||||
data-testid="format-align-left"
|
data-testid="format-align-left"
|
||||||
>
|
>
|
||||||
<AlignLeft size={iconSize} />
|
<AlignLeft size={iconSize} />
|
||||||
<span>Align left</span>
|
<span>Left</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'center' }) ?? false)}
|
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||||
data-testid="format-align-center"
|
data-testid="format-align-center"
|
||||||
>
|
>
|
||||||
<AlignCenter size={iconSize} />
|
<AlignCenter size={iconSize} />
|
||||||
<span>Align center</span>
|
<span>Center</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'right' }) ?? false)}
|
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||||
data-testid="format-align-right"
|
data-testid="format-align-right"
|
||||||
>
|
>
|
||||||
<AlignRight size={iconSize} />
|
<AlignRight size={iconSize} />
|
||||||
<span>Align right</span>
|
<span>Right</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'justify' }) ?? false)}
|
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||||
data-testid="format-align-justify"
|
data-testid="format-align-justify"
|
||||||
>
|
>
|
||||||
<AlignJustify size={iconSize} />
|
<AlignJustify size={iconSize} />
|
||||||
@@ -382,4 +443,40 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||||
|
|
||||||
|
<!-- Block-level formatting -->
|
||||||
|
<button
|
||||||
|
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||||
|
class={btnClass(isActive('code'))}
|
||||||
|
title="Inline code"
|
||||||
|
data-testid="format-code"
|
||||||
|
>
|
||||||
|
<Code size={iconSize} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||||
|
class={btnClass(isActive('blockquote'))}
|
||||||
|
title="Blockquote"
|
||||||
|
data-testid="format-blockquote"
|
||||||
|
>
|
||||||
|
<TextQuote size={iconSize} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||||
|
class={btnClass(isActive('codeBlock'))}
|
||||||
|
title="Code block"
|
||||||
|
data-testid="format-code-block"
|
||||||
|
>
|
||||||
|
<CodeXml size={iconSize} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||||
|
class={btnClass()}
|
||||||
|
title="Divider"
|
||||||
|
data-testid="format-hr"
|
||||||
|
>
|
||||||
|
<Minus size={iconSize} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
const { src, alt, onClose }: Props = $props();
|
const { src, alt, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let dialogEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -21,14 +23,21 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dialogEl?.focus();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
|
bind:this={dialogEl}
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
|
||||||
onclick={handleBackgroundClick}
|
onclick={handleBackgroundClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Image lightbox"
|
||||||
|
tabindex="-1"
|
||||||
data-testid="image-lightbox"
|
data-testid="image-lightbox"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
onUpload: (attachment: Attachment) => void;
|
onUpload: (attachment: Attachment) => void;
|
||||||
onRemove: (attachmentId: string) => void;
|
onRemove: (attachmentId: string) => void;
|
||||||
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
|
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
|
||||||
|
showDropZone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured }: Props = $props();
|
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: Props = $props();
|
||||||
|
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
let optimizing = $state(false);
|
let optimizing = $state(false);
|
||||||
@@ -118,14 +119,18 @@
|
|||||||
{@const isPending = 'pending' in attachment && attachment.pending}
|
{@const isPending = 'pending' in attachment && attachment.pending}
|
||||||
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
||||||
<div class="group relative">
|
<div class="group relative">
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-20 w-20 cursor-pointer rounded-sm p-0 border-0 bg-transparent overflow-hidden"
|
||||||
|
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
|
||||||
|
data-testid="attachment-thumbnail"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
|
class="h-20 w-20 object-cover {isPending ? 'opacity-70' : ''}"
|
||||||
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
|
|
||||||
data-testid="attachment-thumbnail"
|
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
{#if isPending}
|
{#if isPending}
|
||||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||||
<RefreshCw class="h-3 w-3 text-white" />
|
<RefreshCw class="h-3 w-3 text-white" />
|
||||||
@@ -133,7 +138,7 @@
|
|||||||
{:else if onToggleFeatured}
|
{:else if onToggleFeatured}
|
||||||
<button
|
<button
|
||||||
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
|
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
|
||||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 {attachment.featured ? 'block' : 'hidden group-hover:block'}"
|
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 transition-opacity {attachment.featured ? 'opacity-100' : 'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100'}"
|
||||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||||
data-testid="toggle-featured"
|
data-testid="toggle-featured"
|
||||||
>
|
>
|
||||||
@@ -142,7 +147,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
onclick={() => onRemove(attachment.id)}
|
onclick={() => onRemove(attachment.id)}
|
||||||
class="absolute -right-1 -top-1 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
|
class="absolute -right-1 -top-1 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
||||||
aria-label="Remove attachment"
|
aria-label="Remove attachment"
|
||||||
data-testid="remove-attachment"
|
data-testid="remove-attachment"
|
||||||
>
|
>
|
||||||
@@ -154,8 +159,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Upload area -->
|
<!-- Upload area -->
|
||||||
|
{#if showDropZone}
|
||||||
|
<!-- Drop zone for desktop, browse-only label for mobile -->
|
||||||
<div
|
<div
|
||||||
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
class="hidden rounded-sm border-2 border-dashed p-4 text-center transition-colors md:block {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||||
ondrop={handleDrop}
|
ondrop={handleDrop}
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
ondragleave={() => (dragOver = false)}
|
ondragleave={() => (dragOver = false)}
|
||||||
@@ -181,6 +188,27 @@
|
|||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Mobile: compact browse button -->
|
||||||
|
<div class="md:hidden">
|
||||||
|
{#if optimizing}
|
||||||
|
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||||
|
{:else if uploading}
|
||||||
|
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||||
|
{:else}
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-sm text-[var(--text-muted)] hover:border-[var(--primary)]">
|
||||||
|
<span>Add images</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||||
|
data-testid="file-input-mobile"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if lightboxSrc}
|
{#if lightboxSrc}
|
||||||
|
|||||||
@@ -6,28 +6,12 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuToggle: () => void;
|
onMenuToggle: () => void;
|
||||||
user: { id: number; email: string; displayName: string; role: 'admin' | 'user' } | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onMenuToggle, user }: Props = $props();
|
let { onMenuToggle }: Props = $props();
|
||||||
let mobileSearchOpen = $state(false);
|
let mobileSearchOpen = $state(false);
|
||||||
let dropdownOpen = $state(false);
|
|
||||||
|
|
||||||
function getInitial(user: Props['user']): string {
|
|
||||||
if (!user) return '?';
|
|
||||||
if (user.displayName) return user.displayName.charAt(0).toUpperCase();
|
|
||||||
if (user.email) return user.email.charAt(0).toUpperCase();
|
|
||||||
return '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onclick={() => (dropdownOpen = false)} />
|
|
||||||
|
|
||||||
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
|
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
|
||||||
{#if mobileSearchOpen}
|
{#if mobileSearchOpen}
|
||||||
<!-- Mobile expanded search -->
|
<!-- Mobile expanded search -->
|
||||||
@@ -64,41 +48,6 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<SyncIndicator />
|
<SyncIndicator />
|
||||||
|
|
||||||
{#if user}
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
onclick={(e) => { e.stopPropagation(); dropdownOpen = !dropdownOpen; }}
|
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--primary)] text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
|
||||||
aria-label="User menu"
|
|
||||||
data-testid="user-menu-btn"
|
|
||||||
>
|
|
||||||
{getInitial(user)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if dropdownOpen}
|
|
||||||
<div class="absolute right-0 top-10 z-50 w-48 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]">
|
|
||||||
<div class="border-b border-[var(--border-subtle)] px-4 py-2">
|
|
||||||
<p class="text-sm font-medium text-[var(--text)]">{user.displayName || user.email}</p>
|
|
||||||
<p class="text-xs text-[var(--text-muted)]">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="/settings"
|
|
||||||
class="block px-4 py-2 text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onclick={handleLogout}
|
|
||||||
class="block w-full px-4 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--bg-base)]"
|
|
||||||
data-testid="logout-btn"
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
|
import { page } from '$app/stores';
|
||||||
import { StickyNote, Archive, Trash2, Tag } from 'lucide-svelte';
|
import { allTags } from '$lib/stores/notes.js';
|
||||||
import type { NoteFilter } from '$lib/types/index.js';
|
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
appVersion?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open, onClose, appVersion }: Props = $props();
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
function closeMobile() {
|
function closeMobile() {
|
||||||
if (window.matchMedia('(max-width: 1023px)').matches) {
|
if (window.matchMedia('(max-width: 1023px)').matches) {
|
||||||
@@ -17,17 +16,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFilter(filter: NoteFilter) {
|
const navItems = [
|
||||||
selectedTag.set(null);
|
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
|
||||||
loadNotes(filter);
|
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
|
||||||
closeMobile();
|
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
|
||||||
}
|
];
|
||||||
|
|
||||||
function selectTag(tag: string | null) {
|
|
||||||
selectedTag.set(tag);
|
|
||||||
loadNotes('all');
|
|
||||||
closeMobile();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
@@ -44,51 +37,19 @@
|
|||||||
>
|
>
|
||||||
<nav class="p-2">
|
<nav class="p-2">
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<li>
|
{#each navItems as item}
|
||||||
<button
|
|
||||||
onclick={() => setFilter('all')}
|
|
||||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
|
||||||
>
|
|
||||||
<StickyNote size={20} />
|
|
||||||
Crumbs
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
onclick={() => setFilter('archived')}
|
|
||||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
|
||||||
>
|
|
||||||
<Archive size={20} />
|
|
||||||
Archive
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
onclick={() => setFilter('trashed')}
|
|
||||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
|
||||||
>
|
|
||||||
<Trash2 size={20} />
|
|
||||||
Trash
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/settings"
|
href={item.href}
|
||||||
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
onclick={closeMobile}
|
||||||
|
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<item.icon size={20} />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
{item.label}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
Settings
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $allTags.length > 0}
|
{#if $allTags.length > 0}
|
||||||
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
|
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
|
||||||
@@ -96,22 +57,29 @@
|
|||||||
<ul class="mt-2 space-y-1">
|
<ul class="mt-2 space-y-1">
|
||||||
{#each $allTags as tag}
|
{#each $allTags as tag}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<a
|
||||||
onclick={() => selectTag($selectedTag === tag ? null : tag)}
|
href="/tag/{tag}"
|
||||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
onclick={closeMobile}
|
||||||
|
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||||
>
|
>
|
||||||
<Tag size={16} />
|
<Tag size={16} />
|
||||||
#{tag}
|
#{tag}
|
||||||
</button>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
{#if appVersion}
|
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
|
||||||
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)] px-6 py-3 text-right text-xs text-[var(--text-muted)]">
|
<a
|
||||||
v{appVersion}
|
href="/settings"
|
||||||
|
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$page.url.pathname.startsWith('/settings') ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||||
|
onclick={closeMobile}
|
||||||
|
data-testid="settings-link"
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
import { getNoteColor } from '$lib/utils/colors.js';
|
||||||
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||||
import TagChip from './TagChip.svelte';
|
|
||||||
import ImageLightbox from './ImageLightbox.svelte';
|
import ImageLightbox from './ImageLightbox.svelte';
|
||||||
|
import SharingIndicator from './SharingIndicator.svelte';
|
||||||
import type { Note } from '$lib/types/index.js';
|
import type { Note } from '$lib/types/index.js';
|
||||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||||
import Archive from 'lucide-svelte/icons/archive';
|
import Archive from 'lucide-svelte/icons/archive';
|
||||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||||
|
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||||
|
import { tooltip } from '$lib/utils/tooltip.js';
|
||||||
|
|
||||||
interface ChecklistItem {
|
interface ChecklistItem {
|
||||||
text: string;
|
text: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
|
indented: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,8 +29,7 @@
|
|||||||
let { note, onEdit, fullHeight = false }: Props = $props();
|
let { note, onEdit, fullHeight = false }: Props = $props();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const colors = NOTE_COLORS[note.color];
|
cardStyle = `background-color: ${getNoteColor(note.color, getIsDarkMode())}`;
|
||||||
cardStyle = `background-color: ${colors.bg}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let cardStyle = $state('');
|
let cardStyle = $state('');
|
||||||
@@ -37,10 +40,14 @@
|
|||||||
|
|
||||||
const checklistItems = $derived<ChecklistItem[]>(
|
const checklistItems = $derived<ChecklistItem[]>(
|
||||||
note.checklistMode
|
note.checklistMode
|
||||||
? note.content.split('\n').filter(l => l.trim()).map(line => ({
|
? note.content.split('\n').filter(l => l.trim()).map(line => {
|
||||||
text: line.replace(/^- \[[ x]\] /, ''),
|
const indented = line.startsWith(' - [');
|
||||||
checked: line.startsWith('- [x] ')
|
return {
|
||||||
}))
|
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
|
||||||
|
checked: /^ {0,2}- \[x\] /.test(line),
|
||||||
|
indented
|
||||||
|
};
|
||||||
|
})
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,24 +64,11 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleChecklistItem(item: ChecklistItem) {
|
|
||||||
const lines = note.content.split('\n');
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
const text = line.replace(/^- \[[ x]\] /, '');
|
|
||||||
if (text === item.text && line.startsWith(item.checked ? '- [x] ' : '- [ ] ')) {
|
|
||||||
lines[i] = item.checked
|
|
||||||
? `- [ ] ${text}`
|
|
||||||
: `- [x] ${text}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateNote(note.id, { content: lines.join('\n') });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||||
<article
|
<article
|
||||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] max-h-[17rem] overflow-hidden {fullHeight ? 'h-full' : ''}"
|
||||||
style={cardStyle}
|
style={cardStyle}
|
||||||
onclick={() => onEdit(note)}
|
onclick={() => onEdit(note)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||||
@@ -88,15 +82,19 @@
|
|||||||
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
|
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
|
||||||
{#each featuredAttachments.slice(0, 3) as attachment}
|
{#each featuredAttachments.slice(0, 3) as attachment}
|
||||||
<div class="relative min-w-0 flex-1">
|
<div class="relative min-w-0 flex-1">
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
|
||||||
|
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||||
|
data-testid="card-thumbnail"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="h-24 w-full cursor-pointer object-cover"
|
class="h-24 w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
|
||||||
data-testid="card-thumbnail"
|
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if featuredAttachments.length > 3}
|
{#if featuredAttachments.length > 3}
|
||||||
@@ -107,33 +105,44 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Status indicators (top-right) -->
|
||||||
|
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
|
||||||
|
{#if note.shareToken || (note.isShared && note.collaborators)}
|
||||||
|
<SharingIndicator
|
||||||
|
shareToken={note.shareToken}
|
||||||
|
collaborators={note.collaborators ?? []}
|
||||||
|
isOwner={note.isOwner ?? true}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if note.pinned}
|
{#if note.pinned}
|
||||||
<button
|
<button
|
||||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||||
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||||
title="Unpin"
|
use:tooltip={"Unpin"}
|
||||||
data-testid="pin-indicator"
|
data-testid="pin-indicator"
|
||||||
>
|
>
|
||||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if note.title}
|
{#if note.title}
|
||||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if note.checklistMode && checklistItems.length > 0}
|
{#if note.checklistMode && checklistItems.length > 0}
|
||||||
<ul class="space-y-1" data-testid="note-checklist-preview">
|
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
|
||||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||||
|
data-testid={item.indented ? 'card-checklist-child' : undefined}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
disabled
|
||||||
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
|
||||||
data-testid="card-checklist-checkbox"
|
data-testid="card-checklist-checkbox"
|
||||||
/>
|
/>
|
||||||
<span class="truncate">{item.text}</span>
|
<span class="break-words min-w-0">{item.text}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{#if checklistItems.length > 8}
|
{#if checklistItems.length > 8}
|
||||||
@@ -146,21 +155,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if note.tags && note.tags.length > 0}
|
|
||||||
<div class="mt-2 flex flex-wrap gap-1">
|
|
||||||
{#each note.tags as tag}
|
|
||||||
<TagChip {tag} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Action buttons - show on hover -->
|
<!-- Action buttons - show on hover -->
|
||||||
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
|
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
|
||||||
{#if $currentFilter === 'trashed'}
|
{#if $currentFilter === 'trashed'}
|
||||||
<button
|
<button
|
||||||
onclick={stop(() => restoreNote(note.id))}
|
onclick={stop(() => restoreNote(note.id))}
|
||||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
title="Restore"
|
use:tooltip={"Restore"}
|
||||||
data-testid="restore-btn"
|
data-testid="restore-btn"
|
||||||
>
|
>
|
||||||
<Undo2 class="h-4 w-4" />
|
<Undo2 class="h-4 w-4" />
|
||||||
@@ -168,7 +169,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={stop(() => deleteNote(note.id))}
|
onclick={stop(() => deleteNote(note.id))}
|
||||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
title="Delete forever"
|
use:tooltip={"Delete forever"}
|
||||||
data-testid="delete-forever-btn"
|
data-testid="delete-forever-btn"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||||
@@ -178,7 +179,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
title="Pin"
|
use:tooltip={"Pin"}
|
||||||
data-testid="pin-btn"
|
data-testid="pin-btn"
|
||||||
>
|
>
|
||||||
<Bookmark class="h-4 w-4" />
|
<Bookmark class="h-4 w-4" />
|
||||||
@@ -188,7 +189,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={stop(() => unarchiveNote(note.id))}
|
onclick={stop(() => unarchiveNote(note.id))}
|
||||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
title="Unarchive"
|
use:tooltip={"Unarchive"}
|
||||||
data-testid="unarchive-btn"
|
data-testid="unarchive-btn"
|
||||||
>
|
>
|
||||||
<ArchiveRestore class="h-4 w-4" />
|
<ArchiveRestore class="h-4 w-4" />
|
||||||
@@ -197,21 +198,32 @@
|
|||||||
<button
|
<button
|
||||||
onclick={stop(() => archiveNote(note.id))}
|
onclick={stop(() => archiveNote(note.id))}
|
||||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
title="Archive"
|
use:tooltip={"Archive"}
|
||||||
data-testid="archive-btn"
|
data-testid="archive-btn"
|
||||||
>
|
>
|
||||||
<Archive class="h-4 w-4" />
|
<Archive class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if note.isShared && !note.isOwner}
|
||||||
|
<button
|
||||||
|
onclick={stop(() => leaveNote(note.id))}
|
||||||
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
|
use:tooltip={"Leave note"}
|
||||||
|
data-testid="leave-btn"
|
||||||
|
>
|
||||||
|
<UserMinus class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
<button
|
<button
|
||||||
onclick={stop(() => trashNote(note.id))}
|
onclick={stop(() => trashNote(note.id))}
|
||||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
title="Delete"
|
use:tooltip={"Trash"}
|
||||||
data-testid="trash-btn"
|
data-testid="trash-btn"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4" />
|
<Trash2 class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if lightboxSrc}
|
{#if lightboxSrc}
|
||||||
@@ -219,24 +231,3 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
|
||||||
article :global(.prose li:has(.task-checkbox)) {
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
article :global(.prose li:has(.task-checkbox))::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
article :global(.prose li:has(.task-checkbox))::marker {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
article :global(.task-checkbox) {
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,45 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import ColorPicker from './ColorPicker.svelte';
|
import ColorPicker from './ColorPicker.svelte';
|
||||||
import Checklist from './Checklist.svelte';
|
import Checklist from './Checklist.svelte';
|
||||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||||
import TiptapEditor from './TiptapEditor.svelte';
|
import TiptapEditor from './TiptapEditor.svelte';
|
||||||
import ImageUpload from './ImageUpload.svelte';
|
import ImageUpload from './ImageUpload.svelte';
|
||||||
import ImageLightbox from './ImageLightbox.svelte';
|
import ShareDialog from './ShareDialog.svelte';
|
||||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
import NoteHistory from './NoteHistory.svelte';
|
||||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
|
||||||
|
import { notes } from '$lib/stores/notes.js';
|
||||||
|
import { getNoteColor } from '$lib/utils/colors.js';
|
||||||
|
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||||
|
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core';
|
||||||
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
|
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||||
import Palette from 'lucide-svelte/icons/palette';
|
import Palette from 'lucide-svelte/icons/palette';
|
||||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||||
import ImageIcon from 'lucide-svelte/icons/image';
|
import ImageIcon from 'lucide-svelte/icons/image';
|
||||||
import Type from 'lucide-svelte/icons/type';
|
import Type from 'lucide-svelte/icons/type';
|
||||||
import FileCode from 'lucide-svelte/icons/file-code';
|
import FileCode from 'lucide-svelte/icons/file-code';
|
||||||
import FileText from 'lucide-svelte/icons/file-text';
|
import FileText from 'lucide-svelte/icons/file-text';
|
||||||
|
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||||
|
import Users from 'lucide-svelte/icons/users';
|
||||||
|
import Globe from 'lucide-svelte/icons/globe';
|
||||||
|
import History from 'lucide-svelte/icons/history';
|
||||||
|
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical';
|
||||||
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||||
|
import ListX from 'lucide-svelte/icons/list-x';
|
||||||
|
import Archive from 'lucide-svelte/icons/archive';
|
||||||
|
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
|
||||||
|
import { tooltip } from '$lib/utils/tooltip.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
note: Note | null;
|
note: Note | null;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
initialChecklistMode?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { note, isNew = false, onClose }: Props = $props();
|
const { note, isNew = false, initialChecklistMode = false, onClose }: Props = $props();
|
||||||
|
const prefs = getPreferences();
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let title = $state(note?.title ?? '');
|
let title = $state(note?.title ?? '');
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let content = $state(note?.content ?? '');
|
let content = $state(note?.content ?? '');
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let color = $state<NoteColor>(note?.color ?? 'default');
|
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let checklistMode = $state(note?.checklistMode ?? false);
|
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
|
||||||
let showColorPicker = $state(false);
|
let showColorPicker = $state(false);
|
||||||
let showImageUpload = $state(false);
|
let showImageUpload = $state(false);
|
||||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
// svelte-ignore state_referenced_locally
|
||||||
|
let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown');
|
||||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||||
let tiptapEditor: Editor | undefined = $state();
|
let tiptapEditor: Editor | undefined = $state();
|
||||||
let editorTick = $state(0);
|
let editorTick = $state(0);
|
||||||
let lightboxSrc = $state<string | null>(null);
|
|
||||||
let lightboxAlt = $state('');
|
|
||||||
|
|
||||||
// Mutable note identity — allows transitioning from new → saved without closing
|
// Mutable note identity — allows transitioning from new → saved without closing
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
@@ -47,9 +63,212 @@
|
|||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let currentlyNew = $state(isNew);
|
let currentlyNew = $state(isNew);
|
||||||
|
|
||||||
|
// Auto-save: track last-saved state to detect real changes
|
||||||
|
let lastSavedTitle = note?.title ?? '';
|
||||||
|
let lastSavedContent = note?.content ?? '';
|
||||||
|
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
|
||||||
|
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
|
||||||
|
let isSaving = false;
|
||||||
|
let savingPromise: Promise<void> | null = null;
|
||||||
|
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
|
||||||
|
// from the original. Track whether auto-save should be active yet.
|
||||||
|
let autoSaveReady = isNew;
|
||||||
|
|
||||||
|
function hasUnsavedChanges(): boolean {
|
||||||
|
return (
|
||||||
|
title !== lastSavedTitle ||
|
||||||
|
content !== lastSavedContent ||
|
||||||
|
color !== lastSavedColor ||
|
||||||
|
checklistMode !== lastSavedChecklistMode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSave() {
|
||||||
|
if (isSaving) return;
|
||||||
|
// Snapshot current fields to avoid races with user edits during await
|
||||||
|
const snap = { title, content, color, checklistMode };
|
||||||
|
if (!snap.title.trim() && !snap.content.trim()) return;
|
||||||
|
if (
|
||||||
|
snap.title === lastSavedTitle &&
|
||||||
|
snap.content === lastSavedContent &&
|
||||||
|
snap.color === lastSavedColor &&
|
||||||
|
snap.checklistMode === lastSavedChecklistMode
|
||||||
|
) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
if (currentlyNew) {
|
||||||
|
const created = await createNote(snap);
|
||||||
|
if (created) {
|
||||||
|
noteId = created.id;
|
||||||
|
currentlyNew = false;
|
||||||
|
lastSavedTitle = snap.title;
|
||||||
|
lastSavedContent = snap.content;
|
||||||
|
lastSavedColor = snap.color;
|
||||||
|
lastSavedChecklistMode = snap.checklistMode;
|
||||||
|
}
|
||||||
|
} else if (noteId) {
|
||||||
|
const updated = await updateNote(noteId, snap);
|
||||||
|
if (updated) {
|
||||||
|
lastSavedTitle = snap.title;
|
||||||
|
lastSavedContent = snap.content;
|
||||||
|
lastSavedColor = snap.color;
|
||||||
|
lastSavedChecklistMode = snap.checklistMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced auto-save: triggers 2s after the user stops editing
|
||||||
|
$effect(() => {
|
||||||
|
const _t = title;
|
||||||
|
const _c = content;
|
||||||
|
const _col = color;
|
||||||
|
const _cm = checklistMode;
|
||||||
|
|
||||||
|
if (!autoSaveReady) {
|
||||||
|
// Absorb TiptapEditor's initial content normalization as the baseline
|
||||||
|
lastSavedContent = _c;
|
||||||
|
autoSaveReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
|
||||||
|
if (!_t.trim() && !_c.trim()) return;
|
||||||
|
|
||||||
|
autoSaveTimer = setTimeout(() => {
|
||||||
|
savingPromise = performSave();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(autoSaveTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safety net: save on tab close / navigation
|
||||||
|
$effect(() => {
|
||||||
|
function handleBeforeUnload() {
|
||||||
|
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
});
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||||
|
|
||||||
|
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
|
||||||
|
// can produce a synthetic click that lands on editor buttons rendered at the same
|
||||||
|
// coordinates. Suppress pointer events on toolbar controls until mount completes.
|
||||||
|
let toolbarInteractive = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
let showShareDialog = $state(false);
|
||||||
|
let showHistory = $state(false);
|
||||||
|
let showOverflowMenu = $state(false);
|
||||||
|
let overflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||||
|
let overflowMenuEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
// Close overflow menu when clicking outside
|
||||||
|
$effect(() => {
|
||||||
|
if (!showOverflowMenu) return;
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (overflowBtnEl?.contains(target) || overflowMenuEl?.contains(target)) return;
|
||||||
|
showOverflowMenu = false;
|
||||||
|
}
|
||||||
|
// Use setTimeout to avoid the current click from closing the menu
|
||||||
|
const timer = setTimeout(() => document.addEventListener('click', handleClickOutside), 0);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasDoneItems = $derived(
|
||||||
|
checklistMode && content.includes('[x]')
|
||||||
|
);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let currentShareToken = $state<string | undefined>(note?.shareToken);
|
||||||
|
const isOwner = $derived(note?.isOwner !== false);
|
||||||
|
const isShared = $derived(collaboratorsList.length > 0);
|
||||||
|
const hasPublicLink = $derived(!!currentShareToken);
|
||||||
|
// Use displayName from first user or 'You' for owner
|
||||||
|
const ownerName = $derived(isOwner ? 'You' : 'Owner');
|
||||||
|
|
||||||
|
function handleCollaboratorsUpdate(updated: Collaborator[]) {
|
||||||
|
collaboratorsList = updated;
|
||||||
|
if (noteId) {
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) =>
|
||||||
|
n.id === noteId
|
||||||
|
? { ...n, collaborators: updated, isShared: updated.length > 0 }
|
||||||
|
: n
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShareUpdate(token: string | null) {
|
||||||
|
currentShareToken = token ?? undefined;
|
||||||
|
if (noteId) {
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) =>
|
||||||
|
n.id === noteId
|
||||||
|
? { ...n, shareToken: token ?? undefined }
|
||||||
|
: n
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleShareDialog() {
|
||||||
|
if (currentlyNew) {
|
||||||
|
const id = await autoSave();
|
||||||
|
if (!id) return;
|
||||||
|
}
|
||||||
|
showShareDialog = !showShareDialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleHistory() {
|
||||||
|
if (currentlyNew) {
|
||||||
|
const id = await autoSave();
|
||||||
|
if (!id) return;
|
||||||
|
}
|
||||||
|
showHistory = !showHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHistoryRestored() {
|
||||||
|
if (!noteId) return;
|
||||||
|
// Reload the note content from the server after a restore
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
title = updated.title ?? '';
|
||||||
|
content = updated.content ?? '';
|
||||||
|
color = updated.color ?? 'default';
|
||||||
|
checklistMode = updated.checklistMode ?? false;
|
||||||
|
// Bump editor tick to force TiptapEditor to re-render
|
||||||
|
editorTick++;
|
||||||
|
// Update the store too
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) => (n.id === noteId ? { ...n, ...updated } : n))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// failed silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||||
@@ -63,7 +282,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleAttachmentUpload(attachment: Attachment) {
|
function handleAttachmentUpload(attachment: Attachment) {
|
||||||
attachmentsList = [...attachmentsList, attachment];
|
const updated = [...attachmentsList, attachment];
|
||||||
|
attachmentsList = updated;
|
||||||
|
if (noteId) {
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||||
@@ -75,9 +300,14 @@
|
|||||||
body: JSON.stringify({ featured })
|
body: JSON.stringify({ featured })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
attachmentsList = attachmentsList.map((a) =>
|
const updated = attachmentsList.map((a) =>
|
||||||
a.id === attachmentId ? { ...a, featured } : a
|
a.id === attachmentId ? { ...a, featured } : a
|
||||||
);
|
);
|
||||||
|
attachmentsList = updated;
|
||||||
|
// Keep the notes store in sync so the card reflects featured state
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to toggle featured:', err);
|
console.error('Failed to toggle featured:', err);
|
||||||
@@ -90,7 +320,11 @@
|
|||||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||||
|
attachmentsList = updated;
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove attachment:', err);
|
console.error('Failed to remove attachment:', err);
|
||||||
}
|
}
|
||||||
@@ -98,20 +332,37 @@
|
|||||||
|
|
||||||
let bgStyle = $state('');
|
let bgStyle = $state('');
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const colors = NOTE_COLORS[color];
|
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||||
bgStyle = `background-color: ${colors.bg}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
||||||
|
// pointer events are never synthetically re-dispatched from touch, so
|
||||||
|
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
||||||
|
// Both pointerdown and pointerup must target the overlay itself to close.
|
||||||
|
let pointerdownOnOverlay = false;
|
||||||
|
|
||||||
|
function handleOverlayPointerdown(e: PointerEvent) {
|
||||||
|
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayPointerup(e: PointerEvent) {
|
||||||
|
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
|
||||||
|
pointerdownOnOverlay = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAndClose() {
|
async function saveAndClose() {
|
||||||
|
clearTimeout(autoSaveTimer);
|
||||||
|
// Wait for any in-flight auto-save to finish
|
||||||
|
if (savingPromise) await savingPromise;
|
||||||
|
|
||||||
if (!title.trim() && !content.trim()) {
|
if (!title.trim() && !content.trim()) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentlyNew) {
|
// Final save if auto-save hasn't caught up yet
|
||||||
await createNote({ title, content, color, checklistMode });
|
if (hasUnsavedChanges()) {
|
||||||
} else if (noteId) {
|
await performSave();
|
||||||
await updateNote(noteId, { title, content, color, checklistMode });
|
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -119,18 +370,12 @@
|
|||||||
/** Auto-save a new note without closing, returns the new note ID */
|
/** Auto-save a new note without closing, returns the new note ID */
|
||||||
async function autoSave(): Promise<string | null> {
|
async function autoSave(): Promise<string | null> {
|
||||||
if (!currentlyNew) return noteId;
|
if (!currentlyNew) return noteId;
|
||||||
const created = await createNote({
|
clearTimeout(autoSaveTimer);
|
||||||
title: title || 'Untitled',
|
if (savingPromise) await savingPromise;
|
||||||
content,
|
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||||
color,
|
if (!title.trim()) title = 'Untitled';
|
||||||
checklistMode
|
await performSave();
|
||||||
});
|
return noteId;
|
||||||
if (created) {
|
|
||||||
noteId = created.id;
|
|
||||||
currentlyNew = false;
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleImageUpload() {
|
async function toggleImageUpload() {
|
||||||
@@ -158,20 +403,48 @@
|
|||||||
requestAnimationFrame(() => textareaEl?.focus());
|
requestAnimationFrame(() => textareaEl?.focus());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleArchive() {
|
||||||
|
showOverflowMenu = false;
|
||||||
|
if (noteId) {
|
||||||
|
await archiveNote(noteId);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTrash() {
|
||||||
|
showOverflowMenu = false;
|
||||||
|
if (noteId) {
|
||||||
|
await trashNote(noteId);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCheckedItems() {
|
||||||
|
const items = parseChecklist(content).filter((i) => !i.checked);
|
||||||
|
content = serializeChecklist(items);
|
||||||
|
showOverflowMenu = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uncheckAllItems() {
|
||||||
|
const items = parseChecklist(content).map((i) => ({ ...i, checked: false }));
|
||||||
|
content = serializeChecklist(items);
|
||||||
|
showOverflowMenu = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||||
onclick={saveAndClose}
|
onpointerdown={handleOverlayPointerdown}
|
||||||
|
onpointerup={handleOverlayPointerup}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
data-testid="note-editor-overlay"
|
data-testid="note-editor-overlay"
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||||
style={bgStyle}
|
style={bgStyle}
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||||
data-testid="note-editor"
|
data-testid="note-editor"
|
||||||
>
|
>
|
||||||
@@ -182,6 +455,13 @@
|
|||||||
bind:value={title}
|
bind:value={title}
|
||||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||||
data-testid="note-title-input"
|
data-testid="note-title-input"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (textareaEl) textareaEl.focus();
|
||||||
|
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
@@ -211,7 +491,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image attachments -->
|
<!-- Image attachments -->
|
||||||
{#if noteId && showImageUpload}
|
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
|
||||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||||
<ImageUpload
|
<ImageUpload
|
||||||
noteId={noteId}
|
noteId={noteId}
|
||||||
@@ -219,43 +499,30 @@
|
|||||||
onUpload={handleAttachmentUpload}
|
onUpload={handleAttachmentUpload}
|
||||||
onRemove={handleAttachmentRemove}
|
onRemove={handleAttachmentRemove}
|
||||||
onToggleFeatured={handleToggleFeatured}
|
onToggleFeatured={handleToggleFeatured}
|
||||||
|
showDropZone={showImageUpload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if noteId && attachmentsList.length > 0}
|
|
||||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each attachmentsList as attachment}
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
|
||||||
<img
|
|
||||||
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
|
|
||||||
alt={attachment.filename}
|
|
||||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover"
|
|
||||||
onclick={() => { lightboxSrc = `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
|
||||||
data-testid="attachment-thumbnail"
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if lightboxSrc}
|
|
||||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Formatting toolbar -->
|
<!-- Formatting toolbar -->
|
||||||
{#if !rawMarkdownMode && !checklistMode}
|
{#if !rawMarkdownMode && !checklistMode}
|
||||||
|
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2">
|
<div
|
||||||
|
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
|
||||||
|
style={toolbarInteractive ? '' : 'pointer-events: none'}
|
||||||
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<!-- Color picker toggle -->
|
<!-- Color picker toggle -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
onclick={() => (showColorPicker = !showColorPicker)}
|
onclick={() => (showColorPicker = !showColorPicker)}
|
||||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||||
title="Background color"
|
use:tooltip={"Background color"}
|
||||||
data-testid="color-picker-toggle"
|
data-testid="color-picker-toggle"
|
||||||
>
|
>
|
||||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||||
@@ -267,44 +534,60 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checklist mode toggle -->
|
|
||||||
<button
|
|
||||||
onclick={() => (checklistMode = !checklistMode)}
|
|
||||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
|
||||||
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
|
|
||||||
data-testid="checklist-toggle"
|
|
||||||
>
|
|
||||||
{#if checklistMode}
|
|
||||||
<Type class="h-5 w-5" />
|
|
||||||
{:else}
|
|
||||||
<SquareCheck class="h-5 w-5" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Image attachment toggle -->
|
<!-- Image attachment toggle -->
|
||||||
<button
|
<button
|
||||||
onclick={toggleImageUpload}
|
onclick={toggleImageUpload}
|
||||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||||
title="Image attachments"
|
use:tooltip={"Attachments"}
|
||||||
data-testid="image-toggle"
|
data-testid="image-toggle"
|
||||||
>
|
>
|
||||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Raw markdown mode toggle -->
|
<!-- Share button -->
|
||||||
|
{#if isOwner}
|
||||||
<button
|
<button
|
||||||
onclick={toggleMarkdownMode}
|
onclick={toggleShareDialog}
|
||||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
use:tooltip={"Share"}
|
||||||
disabled={checklistMode}
|
data-testid="share-toggle"
|
||||||
data-testid="markdown-toggle"
|
|
||||||
>
|
>
|
||||||
{#if rawMarkdownMode}
|
{#if hasPublicLink}
|
||||||
<FileText class="h-5 w-5" />
|
<Globe class="h-5 w-5 text-[var(--primary)]" />
|
||||||
|
{:else if isShared}
|
||||||
|
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||||
{:else}
|
{:else}
|
||||||
<FileCode class="h-5 w-5" />
|
<UserPlus class="h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{:else if isShared}
|
||||||
|
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
|
||||||
|
<Users class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Archive button (only for saved notes) -->
|
||||||
|
{#if !currentlyNew}
|
||||||
|
<button
|
||||||
|
onclick={handleArchive}
|
||||||
|
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||||
|
use:tooltip={"Archive"}
|
||||||
|
data-testid="archive-note-btn"
|
||||||
|
>
|
||||||
|
<Archive class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Overflow menu trigger -->
|
||||||
|
<button
|
||||||
|
bind:this={overflowBtnEl}
|
||||||
|
onclick={() => (showOverflowMenu = !showOverflowMenu)}
|
||||||
|
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||||
|
use:tooltip={"More"}
|
||||||
|
data-testid="overflow-menu-btn"
|
||||||
|
>
|
||||||
|
<EllipsisVertical class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -316,4 +599,115 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
|
||||||
|
{#if showOverflowMenu && overflowBtnEl}
|
||||||
|
{@const rect = overflowBtnEl.getBoundingClientRect()}
|
||||||
|
<div
|
||||||
|
bind:this={overflowMenuEl}
|
||||||
|
class="fixed z-50 w-max whitespace-nowrap rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] py-1 shadow-[var(--card-shadow)]"
|
||||||
|
style="bottom: {window.innerHeight - rect.top + 4}px; right: {window.innerWidth - rect.right}px;"
|
||||||
|
data-testid="overflow-menu"
|
||||||
|
>
|
||||||
|
<!-- Checklist mode toggle -->
|
||||||
|
<button
|
||||||
|
onclick={() => { checklistMode = !checklistMode; showOverflowMenu = false; }}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||||
|
data-testid="checklist-toggle"
|
||||||
|
>
|
||||||
|
{#if checklistMode}
|
||||||
|
<Type class="h-4 w-4" />
|
||||||
|
Switch to text
|
||||||
|
{:else}
|
||||||
|
<SquareCheck class="h-4 w-4" />
|
||||||
|
Checklist mode
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Markdown mode toggle (not available in checklist mode) -->
|
||||||
|
{#if !checklistMode}
|
||||||
|
<button
|
||||||
|
onclick={() => { toggleMarkdownMode(); showOverflowMenu = false; }}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||||
|
data-testid="markdown-toggle"
|
||||||
|
>
|
||||||
|
{#if rawMarkdownMode}
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
Rich text mode
|
||||||
|
{:else}
|
||||||
|
<FileCode class="h-4 w-4" />
|
||||||
|
Markdown mode
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- History -->
|
||||||
|
{#if !currentlyNew}
|
||||||
|
<button
|
||||||
|
onclick={() => { toggleHistory(); showOverflowMenu = false; }}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||||
|
data-testid="history-toggle"
|
||||||
|
>
|
||||||
|
<History class="h-4 w-4 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||||
|
Version history
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Trash (only for saved notes) -->
|
||||||
|
{#if !currentlyNew}
|
||||||
|
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||||
|
<button
|
||||||
|
onclick={handleTrash}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--destructive)] hover:bg-[var(--border)]/10"
|
||||||
|
data-testid="trash-note-btn"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Move to trash
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Checklist-specific actions -->
|
||||||
|
{#if checklistMode}
|
||||||
|
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||||
|
{#if hasDoneItems}
|
||||||
|
<button
|
||||||
|
onclick={deleteCheckedItems}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||||
|
data-testid="delete-checked-btn"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Delete checked
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={uncheckAllItems}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||||
|
data-testid="uncheck-all-btn"
|
||||||
|
>
|
||||||
|
<ListX class="h-4 w-4" />
|
||||||
|
Uncheck all
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showShareDialog && noteId}
|
||||||
|
<ShareDialog
|
||||||
|
noteId={noteId}
|
||||||
|
collaborators={collaboratorsList}
|
||||||
|
{ownerName}
|
||||||
|
shareToken={currentShareToken}
|
||||||
|
onClose={() => (showShareDialog = false)}
|
||||||
|
onUpdate={handleCollaboratorsUpdate}
|
||||||
|
onShareUpdate={handleShareUpdate}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showHistory && noteId}
|
||||||
|
<NoteHistory
|
||||||
|
noteId={noteId}
|
||||||
|
onClose={() => (showHistory = false)}
|
||||||
|
onRestored={handleHistoryRestored}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NoteCard from './NoteCard.svelte';
|
import NoteCard from './NoteCard.svelte';
|
||||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
import { dndzone, type DndEvent } from 'svelte-dnd-action';
|
||||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import type { Note } from '$lib/types/index.js';
|
import type { Note } from '$lib/types/index.js';
|
||||||
|
|
||||||
@@ -44,20 +43,12 @@
|
|||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||||
data-testid="note-grid"
|
data-testid="note-grid"
|
||||||
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
|
use:dndzone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {}, delayTouchStart: 400 }}
|
||||||
onconsider={handleConsider}
|
onconsider={handleConsider}
|
||||||
onfinalize={handleFinalize}
|
onfinalize={handleFinalize}
|
||||||
>
|
>
|
||||||
{#each localItems as note (note.id)}
|
{#each localItems as note (note.id)}
|
||||||
<div class="relative h-full" animate:flip={{ duration: flipDurationMs }}>
|
<div class="h-full outline-none" animate:flip={{ duration: flipDurationMs }}>
|
||||||
<div
|
|
||||||
use:dragHandle
|
|
||||||
aria-label="drag handle for {note.title || 'note'}"
|
|
||||||
class="absolute top-2 right-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
|
||||||
data-testid="note-drag-handle"
|
|
||||||
>
|
|
||||||
<GripVertical class="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<NoteCard {note} {onEdit} fullHeight />
|
<NoteCard {note} {onEdit} fullHeight />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
|
||||||
|
import X from 'lucide-svelte/icons/x';
|
||||||
|
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
|
||||||
|
import Clock from 'lucide-svelte/icons/clock';
|
||||||
|
import { showToast } from '$lib/stores/toast.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
noteId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onRestored: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { noteId, onClose, onRestored }: Props = $props();
|
||||||
|
|
||||||
|
let versions = $state<NoteVersionSummary[]>([]);
|
||||||
|
let selectedVersion = $state<NoteVersion | null>(null);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let isLoadingVersion = $state(false);
|
||||||
|
let isRestoring = $state(false);
|
||||||
|
let showConfirm = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Track noteId explicitly so this only re-runs when the note changes
|
||||||
|
noteId;
|
||||||
|
void loadVersions();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/versions`);
|
||||||
|
if (res.ok) {
|
||||||
|
versions = await res.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// failed silently
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectVersion(versionId: string) {
|
||||||
|
isLoadingVersion = true;
|
||||||
|
selectedVersion = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
selectedVersion = await res.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// failed silently
|
||||||
|
} finally {
|
||||||
|
isLoadingVersion = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreVersion() {
|
||||||
|
if (!selectedVersion) return;
|
||||||
|
isRestoring = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/notes/${noteId}/versions/${selectedVersion.id}/restore`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Version restored successfully', 'success');
|
||||||
|
onRestored();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast('Failed to restore version', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to restore version', 'error');
|
||||||
|
} finally {
|
||||||
|
isRestoring = false;
|
||||||
|
showConfirm = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date | string): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10"
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
data-testid="history-overlay"
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
data-testid="history-panel"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Clock class="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
|
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||||
|
title="Close history"
|
||||||
|
data-testid="close-history-btn"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 max-h-[60vh] flex-1">
|
||||||
|
<!-- Version list -->
|
||||||
|
<div class="w-56 flex-none border-r border-[var(--border-subtle)] overflow-y-auto">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
{:else if versions.length === 0}
|
||||||
|
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||||
|
No history yet
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="divide-y divide-[var(--border-subtle)]">
|
||||||
|
{#each versions as v (v.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
|
||||||
|
onclick={() => selectVersion(v.id)}
|
||||||
|
data-testid="version-item"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-1">
|
||||||
|
<span class="text-xs font-semibold text-[var(--primary)]">v{v.version}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs text-[var(--text-muted)]">{formatDate(v.createdAt)}</div>
|
||||||
|
{#if v.title}
|
||||||
|
<div class="mt-1 truncate text-xs font-medium text-[var(--text)]">{v.title}</div>
|
||||||
|
{/if}
|
||||||
|
{#if v.contentPreview}
|
||||||
|
<div class="mt-0.5 line-clamp-2 text-xs text-[var(--text-muted)]">{v.contentPreview}</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version preview -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
{#if isLoadingVersion}
|
||||||
|
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
{:else if selectedVersion}
|
||||||
|
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{#if selectedVersion.title}
|
||||||
|
<h3 class="mb-2 text-base font-semibold text-[var(--text)]">{selectedVersion.title}</h3>
|
||||||
|
{/if}
|
||||||
|
<pre class="whitespace-pre-wrap text-sm text-[var(--text)] font-[inherit]">{selectedVersion.content || '(empty)'}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore footer -->
|
||||||
|
<div class="border-t border-[var(--border-subtle)] px-4 py-3">
|
||||||
|
{#if showConfirm}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-xs text-[var(--text-muted)]">
|
||||||
|
This will replace the current note. The current version will be saved as a snapshot.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={restoreVersion}
|
||||||
|
disabled={isRestoring}
|
||||||
|
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
data-testid="confirm-restore-btn"
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-3.5 w-3.5" />
|
||||||
|
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showConfirm = false)}
|
||||||
|
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (showConfirm = true)}
|
||||||
|
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
|
||||||
|
data-testid="restore-btn"
|
||||||
|
>
|
||||||
|
<RotateCcw class="h-3.5 w-3.5" />
|
||||||
|
Restore this version
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||||
|
Select a version to preview
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<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, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||||
|
import Plus from 'lucide-svelte/icons/plus';
|
||||||
|
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||||
|
import { tooltip } from '$lib/utils/tooltip.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filter: NoteFilter;
|
||||||
|
tag?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { filter, tag = null }: Props = $props();
|
||||||
|
|
||||||
|
let editingNote: Note | null = $state(null);
|
||||||
|
let showNewNote = $state(false);
|
||||||
|
let newNoteChecklist = $state(false);
|
||||||
|
|
||||||
|
// Sync store with route props and reload notes when filter changes
|
||||||
|
$effect(() => {
|
||||||
|
currentFilter.set(filter);
|
||||||
|
selectedTag.set(tag);
|
||||||
|
loadNotes(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEditor(note: Note) {
|
||||||
|
editingNote = note;
|
||||||
|
history.replaceState(null, '', `#${note.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editingNote = null;
|
||||||
|
showNewNote = false;
|
||||||
|
newNoteChecklist = false;
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReorder(noteIds: string[]) {
|
||||||
|
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||||
|
updateSortOrders(orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const hash = location.hash.slice(1);
|
||||||
|
if (!hash) return;
|
||||||
|
|
||||||
|
const unsubscribe = notes.subscribe(($notes) => {
|
||||||
|
if ($notes.length === 0) return;
|
||||||
|
const note = $notes.find((n) => n.id === hash);
|
||||||
|
if (note) editingNote = note;
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if filter === 'all' && !tag}
|
||||||
|
<div class="mx-auto mb-6 hidden max-w-xl md:block">
|
||||||
|
<div class="flex cursor-pointer items-center gap-0 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] transition-colors hover:border-[var(--primary)]">
|
||||||
|
<button
|
||||||
|
onclick={() => (showNewNote = true)}
|
||||||
|
class="flex flex-1 cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||||
|
data-testid="new-note-btn"
|
||||||
|
>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
Add a crumb...
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => { newNoteChecklist = true; showNewNote = true; }}
|
||||||
|
class="cursor-pointer rounded-sm p-3 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||||
|
use:tooltip={"New checklist"}
|
||||||
|
data-testid="new-checklist-btn"
|
||||||
|
>
|
||||||
|
<SquareCheck class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (showNewNote = true)}
|
||||||
|
class="fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-sm border border-[var(--border)] bg-[var(--primary)] text-white shadow-[var(--card-shadow)] transition-colors hover:bg-[var(--primary-hover)] md:hidden"
|
||||||
|
aria-label="Add a crumb"
|
||||||
|
data-testid="new-note-fab"
|
||||||
|
>
|
||||||
|
<Plus class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<TagFilter />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filter === 'archived'}
|
||||||
|
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||||
|
{:else if filter === 'trashed'}
|
||||||
|
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
|
||||||
|
{:else if tag}
|
||||||
|
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $pinnedNotes.length > 0}
|
||||||
|
<div class="mb-6">
|
||||||
|
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<NoteGrid
|
||||||
|
notes={$unpinnedNotes}
|
||||||
|
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||||
|
onEdit={openEditor}
|
||||||
|
draggable
|
||||||
|
dndType="unpinned-notes"
|
||||||
|
onReorder={handleReorder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||||
|
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||||
|
<p class="font-['Press_Start_2P'] text-xs">
|
||||||
|
{#if filter === 'trashed'}
|
||||||
|
No crumbs in trash
|
||||||
|
{:else if filter === 'archived'}
|
||||||
|
No archived crumbs
|
||||||
|
{:else if tag}
|
||||||
|
No crumbs with tag #{tag}
|
||||||
|
{:else}
|
||||||
|
No crumbs yet
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showNewNote}
|
||||||
|
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editingNote}
|
||||||
|
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||||
|
{/if}
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import zxcvbn from 'zxcvbn';
|
import type { ZXCVBNResult } from 'zxcvbn';
|
||||||
|
|
||||||
let { password = '' }: { password: string } = $props();
|
let { password = '' }: { password: string } = $props();
|
||||||
|
|
||||||
const result = $derived(password.length > 0 ? zxcvbn(password) : null);
|
let result = $state<ZXCVBNResult | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (password.length > 0) {
|
||||||
|
import('zxcvbn').then((mod) => {
|
||||||
|
result = mod.default(password);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
|
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
|
||||||
const colors = [
|
const colors = [
|
||||||
'bg-red-500',
|
'bg-[var(--destructive)]',
|
||||||
'bg-orange-500',
|
'bg-[var(--destructive)]',
|
||||||
'bg-yellow-500',
|
'bg-[var(--primary)]',
|
||||||
'bg-lime-500',
|
'bg-[var(--success-text)]',
|
||||||
'bg-green-500'
|
'bg-[var(--success-text)]'
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -20,13 +30,13 @@
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{#each { length: 4 } as _, i}
|
{#each { length: 4 } as _, i}
|
||||||
<div
|
<div
|
||||||
class="h-1 flex-1 rounded-full transition-colors {i <= result.score
|
class="h-1 flex-1 rounded-sm transition-colors {i <= result.score
|
||||||
? colors[result.score]
|
? colors[result.score]
|
||||||
: 'bg-gray-200 dark:bg-gray-600'}"
|
: 'bg-[var(--border-subtle)]'}"
|
||||||
></div>
|
></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-[var(--text-muted)]">
|
||||||
{labels[result.score]}
|
{labels[result.score]}
|
||||||
{#if result.feedback.warning}
|
{#if result.feedback.warning}
|
||||||
— {result.feedback.warning}
|
— {result.feedback.warning}
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Collaborator } from '$lib/types/index.js';
|
||||||
|
import X from 'lucide-svelte/icons/x';
|
||||||
|
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||||
|
import Search from 'lucide-svelte/icons/search';
|
||||||
|
import Globe from 'lucide-svelte/icons/globe';
|
||||||
|
import Link from 'lucide-svelte/icons/link';
|
||||||
|
import Copy from 'lucide-svelte/icons/copy';
|
||||||
|
import Check from 'lucide-svelte/icons/check';
|
||||||
|
import { showToast } from '$lib/stores/toast.js';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
interface UserResult {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
noteId: string;
|
||||||
|
collaborators: Collaborator[];
|
||||||
|
ownerName: string;
|
||||||
|
shareToken?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: (collaborators: Collaborator[]) => void;
|
||||||
|
onShareUpdate: (token: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { noteId, collaborators, ownerName, shareToken, onClose, onUpdate, onShareUpdate }: Props = $props();
|
||||||
|
|
||||||
|
let publicShareToken = $state(shareToken ?? null);
|
||||||
|
let copying = $state(false);
|
||||||
|
let togglingShare = $state(false);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state<UserResult[]>([]);
|
||||||
|
let isSearching = $state(false);
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const shareUrl = $derived(publicShareToken ? `${page.url.origin}/s/${publicShareToken}` : null);
|
||||||
|
|
||||||
|
async function togglePublicShare() {
|
||||||
|
togglingShare = true;
|
||||||
|
try {
|
||||||
|
if (publicShareToken) {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast('Failed to remove share link', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publicShareToken = null;
|
||||||
|
onShareUpdate(null);
|
||||||
|
showToast('Public link removed', 'success');
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/share`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
showToast('Failed to create share link', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
publicShareToken = data.token;
|
||||||
|
onShareUpdate(data.token);
|
||||||
|
showToast('Public link created', 'success');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to update share link', 'error');
|
||||||
|
} finally {
|
||||||
|
togglingShare = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyShareUrl() {
|
||||||
|
if (!shareUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
copying = true;
|
||||||
|
setTimeout(() => (copying = false), 2000);
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to copy link', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(query: string) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
if (!query.trim()) {
|
||||||
|
searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimeout = setTimeout(async () => {
|
||||||
|
isSearching = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const users: UserResult[] = await res.json();
|
||||||
|
const collabIds = new Set(collaborators.map((c) => c.userId));
|
||||||
|
searchResults = users.filter((u) => !collabIds.has(u.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Search failed silently
|
||||||
|
} finally {
|
||||||
|
isSearching = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addCollaborator(user: UserResult) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: user.id })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated: Collaborator[] = await res.json();
|
||||||
|
onUpdate(updated);
|
||||||
|
searchQuery = '';
|
||||||
|
searchResults = [];
|
||||||
|
showToast(`Shared with ${user.displayName || user.email}`, 'success');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
|
||||||
|
showToast(err.message || 'Failed to share', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to share note', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCollaborator(userId: number) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = collaborators.filter((c) => c.userId !== userId);
|
||||||
|
onUpdate(updated);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to remove collaborator', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
data-testid="share-dialog-overlay"
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
data-testid="share-dialog"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||||
|
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Public link section -->
|
||||||
|
<div class="px-4 pt-3 pb-3" data-testid="public-link-section">
|
||||||
|
<div
|
||||||
|
class="rounded-sm border p-3 transition-colors {publicShareToken
|
||||||
|
? 'border-[var(--primary)] bg-[var(--primary)]/5'
|
||||||
|
: 'border-[var(--border-subtle)]'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Globe class="h-4 w-4 {publicShareToken ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'}" />
|
||||||
|
<span class="text-sm font-medium text-[var(--text)]">Public link</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={togglePublicShare}
|
||||||
|
disabled={togglingShare}
|
||||||
|
class="relative h-5 w-9 rounded-sm transition-colors {publicShareToken
|
||||||
|
? 'bg-[var(--primary)]'
|
||||||
|
: 'bg-[var(--border-subtle)]'}"
|
||||||
|
title={publicShareToken ? 'Disable public link' : 'Enable public link'}
|
||||||
|
data-testid="public-share-toggle"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 h-4 w-4 rounded-sm bg-[var(--bg-surface)] shadow-[1px_1px_0px_var(--border-subtle)] transition-all {publicShareToken
|
||||||
|
? 'left-[1.125rem]'
|
||||||
|
: 'left-0.5'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if publicShareToken && shareUrl}
|
||||||
|
<div class="mt-2 flex items-center gap-1" data-testid="share-url-container">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-2 py-1.5">
|
||||||
|
<Link class="h-3 w-3 shrink-0 text-[var(--text-muted)]" />
|
||||||
|
<span class="truncate text-xs text-[var(--text-muted)]" data-testid="share-url-text">{shareUrl}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={copyShareUrl}
|
||||||
|
class="shrink-0 rounded-sm border border-[var(--border-subtle)] p-1.5 hover:border-[var(--primary)] hover:bg-[var(--primary)]/5 transition-colors"
|
||||||
|
title="Copy link"
|
||||||
|
data-testid="copy-share-url-btn"
|
||||||
|
>
|
||||||
|
{#if copying}
|
||||||
|
<Check class="h-4 w-4 text-[var(--success-text)]" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-[var(--border-subtle)]"></div>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="relative px-4 pt-3">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||||
|
data-testid="share-search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search results dropdown -->
|
||||||
|
{#if searchResults.length > 0}
|
||||||
|
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
|
||||||
|
{#each searchResults as user}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
|
||||||
|
onclick={() => addCollaborator(user)}
|
||||||
|
data-testid="share-user-result"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{user.displayName || 'User'}</span>
|
||||||
|
{#if user.email}
|
||||||
|
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current collaborators -->
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<!-- Owner -->
|
||||||
|
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
|
||||||
|
<span class="text-sm text-[var(--text)]">
|
||||||
|
{ownerName}
|
||||||
|
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<!-- Collaborators -->
|
||||||
|
{#each collaborators as collab}
|
||||||
|
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
|
||||||
|
<span class="text-sm text-[var(--text)]">
|
||||||
|
{collab.displayName || collab.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => removeCollaborator(collab.userId)}
|
||||||
|
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
|
||||||
|
title="Remove"
|
||||||
|
data-testid="remove-collaborator-btn"
|
||||||
|
>
|
||||||
|
<UserMinus class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Collaborator } from '$lib/types/index.js';
|
||||||
|
import Globe from 'lucide-svelte/icons/globe';
|
||||||
|
import Users from 'lucide-svelte/icons/users';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shareToken?: string;
|
||||||
|
collaborators: Collaborator[];
|
||||||
|
isOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shareToken, collaborators, isOwner }: Props = $props();
|
||||||
|
|
||||||
|
let showPopover = $state(false);
|
||||||
|
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const hasPublicLink = $derived(!!shareToken);
|
||||||
|
const hasCollaborators = $derived(collaborators.length > 0);
|
||||||
|
|
||||||
|
const tooltipLines = $derived.by(() => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (hasPublicLink) lines.push('Shared publicly via link');
|
||||||
|
if (hasCollaborators) {
|
||||||
|
lines.push(`Shared with ${collaborators.length} collaborator${collaborators.length > 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
hoverTimeout = setTimeout(() => {
|
||||||
|
showPopover = true;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
clearTimeout(hoverTimeout);
|
||||||
|
showPopover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showPopover = !showPopover;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="relative inline-flex"
|
||||||
|
onmouseenter={handleMouseEnter}
|
||||||
|
onmouseleave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm p-1 {hasPublicLink ? 'text-[var(--primary)]' : 'text-[var(--text-muted)]'} hover:text-[var(--primary)]"
|
||||||
|
title={tooltipLines.join(', ')}
|
||||||
|
aria-label={tooltipLines.join(', ')}
|
||||||
|
onclick={handleClick}
|
||||||
|
data-testid="sharing-indicator"
|
||||||
|
>
|
||||||
|
{#if hasPublicLink}
|
||||||
|
<Globe class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Users class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showPopover}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full z-20 mt-1 min-w-[180px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||||
|
data-testid="sharing-popover"
|
||||||
|
>
|
||||||
|
{#if hasPublicLink}
|
||||||
|
<div class="mb-1 flex items-center gap-1.5">
|
||||||
|
<Globe class="h-3 w-3 text-[var(--primary)]" />
|
||||||
|
<span class="text-xs font-semibold text-[var(--primary)]">Public link active</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if hasCollaborators}
|
||||||
|
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{#if isOwner}
|
||||||
|
<li class="text-xs text-[var(--text)]">
|
||||||
|
You <span class="text-[var(--text-muted)]">(Owner)</span>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#each collaborators as collab}
|
||||||
|
<li class="text-xs text-[var(--text)]">
|
||||||
|
{collab.displayName || collab.email}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Clock from 'lucide-svelte/icons/clock';
|
|
||||||
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
|
|
||||||
import Hand from 'lucide-svelte/icons/hand';
|
|
||||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
|
||||||
|
|
||||||
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
|
|
||||||
{ value: 'updated', label: 'Updated', icon: Clock },
|
|
||||||
{ value: 'created', label: 'Created', icon: CalendarPlus },
|
|
||||||
{ value: 'custom', label: 'Custom', icon: Hand }
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
|
|
||||||
{#each modes as mode}
|
|
||||||
<button
|
|
||||||
onclick={() => sortMode.set(mode.value)}
|
|
||||||
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
|
|
||||||
{$sortMode === mode.value
|
|
||||||
? 'bg-[var(--primary)] text-white'
|
|
||||||
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
|
|
||||||
data-testid="sort-{mode.value}"
|
|
||||||
aria-label="Sort by {mode.label}"
|
|
||||||
>
|
|
||||||
<mode.icon class="h-3.5 w-3.5" />
|
|
||||||
<span class="hidden sm:inline">{mode.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
|
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
|
||||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||||
@@ -44,7 +44,12 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
|
<button
|
||||||
|
class="flex cursor-pointer items-center rounded-sm p-1 transition-colors hover:bg-[var(--border-subtle)]/50"
|
||||||
|
title="Sync status: {status} — click to sync"
|
||||||
|
onclick={() => sync()}
|
||||||
|
data-testid="sync-indicator"
|
||||||
|
>
|
||||||
{#if displayStatus === 'synced'}
|
{#if displayStatus === 'synced'}
|
||||||
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
||||||
{:else if displayStatus === 'syncing'}
|
{:else if displayStatus === 'syncing'}
|
||||||
@@ -54,4 +59,4 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{#if onclick}
|
{#if onclick}
|
||||||
<button
|
<button
|
||||||
{onclick}
|
{onclick}
|
||||||
class="rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
|
class="whitespace-nowrap rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
|
||||||
? 'bg-[var(--primary)] text-white'
|
? 'bg-[var(--primary)] text-white'
|
||||||
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
|
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
|
||||||
data-testid="tag-chip"
|
data-testid="tag-chip"
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { allTags, selectedTag } from '$lib/stores/notes.js';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { allTags } from '$lib/stores/notes.js';
|
||||||
import TagChip from './TagChip.svelte';
|
import TagChip from './TagChip.svelte';
|
||||||
|
|
||||||
function toggleTag(tag: string) {
|
function toggleTag(tag: string) {
|
||||||
selectedTag.update((current) => (current === tag ? null : tag));
|
const isActive = $page.url.pathname === `/tag/${tag}`;
|
||||||
|
goto(isActive ? '/' : `/tag/${tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTagActive(tag: string, pathname: string): boolean {
|
||||||
|
return pathname === `/tag/${tag}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $allTags.length > 0}
|
{#if $allTags.length > 0}
|
||||||
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
|
<!-- svelte-ignore css_unused_selector -->
|
||||||
|
<div class="tag-scroll flex gap-2 overflow-x-auto max-md:flex-nowrap md:flex-wrap" data-testid="tag-filter">
|
||||||
{#each $allTags as tag}
|
{#each $allTags as tag}
|
||||||
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
|
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tag-scroll {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tag-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
import Underline from '@tiptap/extension-underline';
|
import Underline from '@tiptap/extension-underline';
|
||||||
import TextAlign from '@tiptap/extension-text-align';
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import { Table } from '@tiptap/extension-table';
|
||||||
|
import { TableRow } from '@tiptap/extension-table-row';
|
||||||
|
import { TableHeader } from '@tiptap/extension-table-header';
|
||||||
|
import { TableCell } from '@tiptap/extension-table-cell';
|
||||||
|
import TaskList from '@tiptap/extension-task-list';
|
||||||
|
import TaskItem from '@tiptap/extension-task-item';
|
||||||
import { Markdown } from 'tiptap-markdown';
|
import { Markdown } from 'tiptap-markdown';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,6 +36,12 @@
|
|||||||
Underline,
|
Underline,
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
Placeholder.configure({ placeholder }),
|
Placeholder.configure({ placeholder }),
|
||||||
|
Table.configure({ resizable: false }),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({ nested: true }),
|
||||||
Markdown
|
Markdown
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
@@ -57,16 +69,21 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
|
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||||
data-testid="tiptap-editor"
|
data-testid="tiptap-editor"
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => editor?.commands.focus()}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tiptap-wrapper :global(.tiptap) {
|
.tiptap-wrapper :global(.tiptap) {
|
||||||
outline: none;
|
outline: none;
|
||||||
min-height: 80px;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
|
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { generateApiKey, validateApiKey, listApiKeys, deleteApiKey } from './api-keys.js';
|
||||||
|
import { users } from './db/schema.js';
|
||||||
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import type * as schema from './db/schema.js';
|
||||||
|
|
||||||
|
describe('API Keys', () => {
|
||||||
|
let db: BetterSQLite3Database<typeof schema>;
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
// Create a test user
|
||||||
|
db.insert(users)
|
||||||
|
.values({ email: 'test@example.com', displayName: 'Test', createdAt: new Date() })
|
||||||
|
.run();
|
||||||
|
userId = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateApiKey', () => {
|
||||||
|
it('returns key with crumbs_ prefix', () => {
|
||||||
|
const result = generateApiKey('Test Key', userId, db);
|
||||||
|
expect(result.key).toMatch(/^crumbs_[a-f0-9]{64}$/);
|
||||||
|
expect(result.name).toBe('Test Key');
|
||||||
|
expect(result.id).toBeTruthy();
|
||||||
|
expect(result.keyPrefix).toBe(result.key.slice(0, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique keys each time', () => {
|
||||||
|
const key1 = generateApiKey('Key 1', userId, db);
|
||||||
|
const key2 = generateApiKey('Key 2', userId, db);
|
||||||
|
expect(key1.key).not.toBe(key2.key);
|
||||||
|
expect(key1.id).not.toBe(key2.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateApiKey', () => {
|
||||||
|
it('returns valid with userId for a valid key', () => {
|
||||||
|
const { key } = generateApiKey('Test Key', userId, db);
|
||||||
|
const result = validateApiKey(key, db);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.userId).toBe(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid for an invalid key', () => {
|
||||||
|
const result = validateApiKey('crumbs_invalid', db);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.userId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid for empty string', () => {
|
||||||
|
expect(validateApiKey('', db).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid for key without prefix', () => {
|
||||||
|
expect(validateApiKey('no_prefix_here', db).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates lastUsedAt on validation', () => {
|
||||||
|
const { key, id } = generateApiKey('Test Key', userId, db);
|
||||||
|
const before = listApiKeys(userId, db).find((k) => k.id === id);
|
||||||
|
expect(before?.lastUsedAt).toBeNull();
|
||||||
|
|
||||||
|
validateApiKey(key, db);
|
||||||
|
|
||||||
|
const after = listApiKeys(userId, db).find((k) => k.id === id);
|
||||||
|
expect(after?.lastUsedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listApiKeys', () => {
|
||||||
|
it('returns empty array when no keys exist', () => {
|
||||||
|
expect(listApiKeys(userId, db)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns keys without hash', () => {
|
||||||
|
generateApiKey('Key 1', userId, db);
|
||||||
|
generateApiKey('Key 2', userId, db);
|
||||||
|
|
||||||
|
const keys = listApiKeys(userId, db);
|
||||||
|
expect(keys).toHaveLength(2);
|
||||||
|
expect(keys[0]).toHaveProperty('name');
|
||||||
|
expect(keys[0]).toHaveProperty('keyPrefix');
|
||||||
|
expect(keys[0]).toHaveProperty('createdAt');
|
||||||
|
expect(keys[0]).not.toHaveProperty('keyHash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only returns keys for the specified user', () => {
|
||||||
|
db.insert(users)
|
||||||
|
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
|
||||||
|
.run();
|
||||||
|
const otherUserId = 2;
|
||||||
|
|
||||||
|
generateApiKey('My Key', userId, db);
|
||||||
|
generateApiKey('Other Key', otherUserId, db);
|
||||||
|
|
||||||
|
expect(listApiKeys(userId, db)).toHaveLength(1);
|
||||||
|
expect(listApiKeys(otherUserId, db)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteApiKey', () => {
|
||||||
|
it('deletes an existing key and returns true', () => {
|
||||||
|
const { id, key } = generateApiKey('Test Key', userId, db);
|
||||||
|
expect(deleteApiKey(id, userId, db)).toBe(true);
|
||||||
|
expect(validateApiKey(key, db).valid).toBe(false);
|
||||||
|
expect(listApiKeys(userId, db)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-existent key', () => {
|
||||||
|
expect(deleteApiKey('nonexistent', userId, db)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot delete another user\'s key', () => {
|
||||||
|
db.insert(users)
|
||||||
|
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
|
||||||
|
.run();
|
||||||
|
const otherUserId = 2;
|
||||||
|
|
||||||
|
const { id } = generateApiKey('Other Key', otherUserId, db);
|
||||||
|
expect(deleteApiKey(id, userId, db)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { db } from './db/index.js';
|
||||||
|
import { apiKeys, users } from './db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { randomBytes, createHash } from 'crypto';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import type * as schema from './db/schema.js';
|
||||||
|
|
||||||
|
type Db = BetterSQLite3Database<typeof schema>;
|
||||||
|
|
||||||
|
const PREFIX = 'crumbs_';
|
||||||
|
|
||||||
|
function hashKey(key: string): string {
|
||||||
|
return createHash('sha256').update(key).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateApiKey(
|
||||||
|
name: string,
|
||||||
|
userId: number,
|
||||||
|
database: Db = db
|
||||||
|
): { id: string; key: string; name: string; keyPrefix: string } {
|
||||||
|
const id = uuidv4();
|
||||||
|
const raw = randomBytes(32).toString('hex');
|
||||||
|
const key = `${PREFIX}${raw}`;
|
||||||
|
const keyHash = hashKey(key);
|
||||||
|
const keyPrefix = key.slice(0, 12);
|
||||||
|
|
||||||
|
database
|
||||||
|
.insert(apiKeys)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
keyHash,
|
||||||
|
keyPrefix,
|
||||||
|
createdAt: new Date()
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { id, key, name, keyPrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateApiKey(
|
||||||
|
key: string,
|
||||||
|
database: Db = db
|
||||||
|
): { valid: boolean; userId: number | null } {
|
||||||
|
if (!key || !key.startsWith(PREFIX)) return { valid: false, userId: null };
|
||||||
|
|
||||||
|
const keyHash = hashKey(key);
|
||||||
|
const row = database.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get();
|
||||||
|
if (!row) return { valid: false, userId: null };
|
||||||
|
|
||||||
|
database
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({ lastUsedAt: new Date() })
|
||||||
|
.where(eq(apiKeys.id, row.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { valid: true, userId: row.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserForApiKey(
|
||||||
|
userId: number,
|
||||||
|
database: Db = db
|
||||||
|
): App.Locals['user'] | null {
|
||||||
|
const user = database
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
displayName: users.displayName,
|
||||||
|
role: users.role
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return user ? (user as App.Locals['user']) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listApiKeys(userId: number, database: Db = db) {
|
||||||
|
return database
|
||||||
|
.select({
|
||||||
|
id: apiKeys.id,
|
||||||
|
name: apiKeys.name,
|
||||||
|
keyPrefix: apiKeys.keyPrefix,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
lastUsedAt: apiKeys.lastUsedAt
|
||||||
|
})
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.userId, userId))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteApiKey(id: string, userId: number, database: Db = db): boolean {
|
||||||
|
const existing = database
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existing || existing.userId !== userId) return false;
|
||||||
|
|
||||||
|
const result = database.delete(apiKeys).where(eq(apiKeys.id, id)).run();
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { notes, noteCollaborators, users } from './db/schema.js';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { canAccessNote, requireNoteAccess, requireNoteOwnership, getUserId } from './api-utils.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const OWNER_ID = 1;
|
||||||
|
const COLLAB_ID = 2;
|
||||||
|
const STRANGER_ID = 3;
|
||||||
|
|
||||||
|
function seedUsers() {
|
||||||
|
db.insert(users)
|
||||||
|
.values([
|
||||||
|
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||||
|
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() },
|
||||||
|
{ email: 'stranger@test.com', displayName: 'Stranger', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNote(id: string) {
|
||||||
|
db.insert(notes)
|
||||||
|
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareNote(noteId: string) {
|
||||||
|
db.insert(noteCollaborators)
|
||||||
|
.values({ noteId, userId: COLLAB_ID, addedBy: OWNER_ID, addedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb();
|
||||||
|
db = testDb.db;
|
||||||
|
seedUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canAccessNote', () => {
|
||||||
|
it('should allow owner to access their note', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
const result = canAccessNote(db, 'n1', OWNER_ID);
|
||||||
|
expect(result).toEqual({ canAccess: true, isOwner: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow collaborator to access shared note', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
shareNote('n1');
|
||||||
|
const result = canAccessNote(db, 'n1', COLLAB_ID);
|
||||||
|
expect(result).toEqual({ canAccess: true, isOwner: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny non-collaborator access', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
const result = canAccessNote(db, 'n1', STRANGER_ID);
|
||||||
|
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent note', () => {
|
||||||
|
const result = canAccessNote(db, 'nonexistent', OWNER_ID);
|
||||||
|
expect(result).toEqual({ canAccess: false, isOwner: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireNoteAccess', () => {
|
||||||
|
it('should throw 404 for unauthorized user', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
expect(() => requireNoteAccess(db, 'n1', STRANGER_ID)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return isOwner for authorized owner', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
expect(requireNoteAccess(db, 'n1', OWNER_ID)).toEqual({ isOwner: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireNoteOwnership', () => {
|
||||||
|
it('should throw 404 for collaborator', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
shareNote('n1');
|
||||||
|
expect(() => requireNoteOwnership(db, 'n1', COLLAB_ID)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw for owner', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
expect(() => requireNoteOwnership(db, 'n1', OWNER_ID)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserId', () => {
|
||||||
|
it('should throw 401 when no user in locals', () => {
|
||||||
|
expect(() => getUserId({ locals: {} as App.Locals })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user id from locals', () => {
|
||||||
|
const id = getUserId({ locals: { user: { id: 42, role: 'user', email: '', displayName: '' } } as App.Locals });
|
||||||
|
expect(id).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { notes, noteCollaborators } from './db/schema.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
type EventWithLocals = {
|
type EventWithLocals = {
|
||||||
locals: App.Locals;
|
locals: App.Locals;
|
||||||
@@ -14,3 +17,36 @@ export function requireAdmin(event: EventWithLocals) {
|
|||||||
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
|
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user can access a note (as owner or collaborator).
|
||||||
|
*/
|
||||||
|
export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } {
|
||||||
|
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||||
|
if (!note) return { canAccess: false, isOwner: false };
|
||||||
|
if (note.userId === userId) return { canAccess: true, isOwner: true };
|
||||||
|
|
||||||
|
const collab = db
|
||||||
|
.select({ userId: noteCollaborators.userId })
|
||||||
|
.from(noteCollaborators)
|
||||||
|
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||||
|
.get();
|
||||||
|
return { canAccess: !!collab, isOwner: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require that a user can access a note. Throws 404 if no access.
|
||||||
|
*/
|
||||||
|
export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } {
|
||||||
|
const { canAccess, isOwner } = canAccessNote(db, noteId, userId);
|
||||||
|
if (!canAccess) throw error(404, 'Not found');
|
||||||
|
return { isOwner };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require that a user is the owner of a note. Throws 404 if not owner.
|
||||||
|
*/
|
||||||
|
export function requireNoteOwnership(db: Db, noteId: string, userId: number): void {
|
||||||
|
const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get();
|
||||||
|
if (!note || note.userId !== userId) throw error(404, 'Not found');
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { existsSync, mkdirSync } from 'fs';
|
|||||||
import { writeFile, unlink } from 'fs/promises';
|
import { writeFile, unlink } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { db } from './db/index.js';
|
import type { Db } from './db/index.js';
|
||||||
import { attachments } from './db/schema.js';
|
import { attachments } from './db/schema.js';
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import type { Attachment } from '$lib/types/index.js';
|
import type { Attachment } from '$lib/types/index.js';
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||||
@@ -16,6 +16,7 @@ if (!existsSync(ATTACHMENTS_DIR)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveAttachment(
|
export async function saveAttachment(
|
||||||
|
db: Db,
|
||||||
noteId: string,
|
noteId: string,
|
||||||
file: File,
|
file: File,
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -59,7 +60,7 @@ export async function saveAttachment(
|
|||||||
* Batch fetch attachments for multiple notes in a single query.
|
* Batch fetch attachments for multiple notes in a single query.
|
||||||
* Returns a Map of noteId → Attachment[].
|
* Returns a Map of noteId → Attachment[].
|
||||||
*/
|
*/
|
||||||
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
|
export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map<string, Attachment[]> {
|
||||||
if (noteIds.length === 0) return new Map();
|
if (noteIds.length === 0) return new Map();
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
@@ -80,25 +81,29 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAttachment(id: string) {
|
export async function getAttachment(db: Db, id: string, noteId: string) {
|
||||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
return db
|
||||||
|
.select()
|
||||||
|
.from(attachments)
|
||||||
|
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
|
||||||
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAttachmentsByNote(noteId: string) {
|
export async function getAttachmentsByNote(db: Db, noteId: string) {
|
||||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAttachment(id: string, data: { featured: boolean }) {
|
export async function updateAttachment(db: Db, id: string, noteId: string, data: { featured: boolean }) {
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(attachments)
|
.update(attachments)
|
||||||
.set({ featured: data.featured })
|
.set({ featured: data.featured })
|
||||||
.where(eq(attachments.id, id))
|
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
|
||||||
.returning();
|
.returning();
|
||||||
return updated ?? null;
|
return updated ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAttachment(id: string) {
|
export async function deleteAttachment(db: Db, id: string, noteId: string) {
|
||||||
const attachment = await getAttachment(id);
|
const attachment = await getAttachment(db, id, noteId);
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
try {
|
try {
|
||||||
await unlink(attachment.path);
|
await unlink(attachment.path);
|
||||||
@@ -112,6 +117,6 @@ export async function deleteAttachment(id: string) {
|
|||||||
// Thumbnail may already be deleted
|
// Thumbnail may already be deleted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await db.delete(attachments).where(eq(attachments.id, id));
|
await db.delete(attachments).where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
|||||||
import { users, sessions } from './db/schema.js';
|
import { users, sessions } from './db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes, randomUUID } from 'crypto';
|
||||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||||
import type * as schema from './db/schema.js';
|
import type * as schema from './db/schema.js';
|
||||||
|
|
||||||
@@ -16,16 +16,16 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('Auth - Password Hashing', () => {
|
describe('Auth - Password Hashing', () => {
|
||||||
it('should hash and verify a password correctly', async () => {
|
it('should hash and verify a password correctly', async () => {
|
||||||
const password = 'mysecurepassword';
|
const password = randomUUID();
|
||||||
const hash = await argon2.hash(password);
|
const hash = await argon2.hash(password);
|
||||||
|
|
||||||
expect(hash).not.toBe(password);
|
expect(hash).not.toBe(password);
|
||||||
expect(await argon2.verify(hash, password)).toBe(true);
|
expect(await argon2.verify(hash, password)).toBe(true);
|
||||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
expect(await argon2.verify(hash, randomUUID())).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should produce different hashes for the same password', async () => {
|
it('should produce different hashes for the same password', async () => {
|
||||||
const password = 'mysecurepassword';
|
const password = randomUUID();
|
||||||
const hash1 = await argon2.hash(password);
|
const hash1 = await argon2.hash(password);
|
||||||
const hash2 = await argon2.hash(password);
|
const hash2 = await argon2.hash(password);
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
|
|||||||
|
|
||||||
describe('Auth - User Setup', () => {
|
describe('Auth - User Setup', () => {
|
||||||
it('should create a user with hashed password', async () => {
|
it('should create a user with hashed password', async () => {
|
||||||
const password = 'testpassword123';
|
const password = randomUUID();
|
||||||
const hash = await argon2.hash(password);
|
const hash = await argon2.hash(password);
|
||||||
|
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
|
|||||||
+14
-3
@@ -194,10 +194,10 @@ export function revokeAllSessions(userId: number, exceptToken?: string): void {
|
|||||||
export async function createUser(
|
export async function createUser(
|
||||||
email: string,
|
email: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
password: string,
|
password: string | null,
|
||||||
role: 'admin' | 'user' = 'user'
|
role: 'admin' | 'user' = 'user'
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const hash = await argon2.hash(password);
|
const hash = password ? await argon2.hash(password) : null;
|
||||||
const result = db
|
const result = db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
@@ -205,7 +205,7 @@ export async function createUser(
|
|||||||
displayName,
|
displayName,
|
||||||
role,
|
role,
|
||||||
passwordHash: hash,
|
passwordHash: hash,
|
||||||
authProvider: 'password',
|
authProvider: password ? 'password' : 'none',
|
||||||
createdAt: new Date()
|
createdAt: new Date()
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
@@ -228,8 +228,19 @@ export function getUser(userId: number): User | null {
|
|||||||
|
|
||||||
export async function deleteUser(userId: number): Promise<void> {
|
export async function deleteUser(userId: number): Promise<void> {
|
||||||
// Cascade: delete user's data in dependency order
|
// Cascade: delete user's data in dependency order
|
||||||
|
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
|
||||||
|
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
|
||||||
|
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
|
||||||
|
sqlite
|
||||||
|
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
|
||||||
|
.run(userId, userId);
|
||||||
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
|
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
|
||||||
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
|
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||||
|
)
|
||||||
|
.run(userId);
|
||||||
sqlite
|
sqlite
|
||||||
.prepare(
|
.prepare(
|
||||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { fetchCollaboratorsForNotes, addCollaborator, removeCollaborator } from './collaborators.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const OWNER_ID = 1;
|
||||||
|
const COLLAB_ID = 2;
|
||||||
|
|
||||||
|
function seedUsers() {
|
||||||
|
db.insert(users)
|
||||||
|
.values([
|
||||||
|
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||||
|
{ email: 'collab@test.com', displayName: 'Collab', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNote(id: string) {
|
||||||
|
db.insert(notes)
|
||||||
|
.values({ id, userId: OWNER_ID, title: 'Test', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb();
|
||||||
|
db = testDb.db;
|
||||||
|
seedUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchCollaboratorsForNotes', () => {
|
||||||
|
it('should return Map with user details for shared notes', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||||
|
|
||||||
|
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||||
|
const collabs = map.get('n1');
|
||||||
|
expect(collabs).toHaveLength(1);
|
||||||
|
expect(collabs![0].userId).toBe(COLLAB_ID);
|
||||||
|
expect(collabs![0].displayName).toBe('Collab');
|
||||||
|
expect(collabs![0].email).toBe('collab@test.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty Map for notes with no collaborators', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
const map = fetchCollaboratorsForNotes(db, ['n1']);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addCollaborator', () => {
|
||||||
|
it('should insert collaborator record', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||||
|
|
||||||
|
const rows = db.select().from(noteCollaborators).all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].userId).toBe(COLLAB_ID);
|
||||||
|
expect(rows[0].addedBy).toBe(OWNER_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeCollaborator', () => {
|
||||||
|
it('should clean up noteUserState on removal', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
addCollaborator(db, 'n1', COLLAB_ID, OWNER_ID);
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'n1', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 0 })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
removeCollaborator(db, 'n1', COLLAB_ID);
|
||||||
|
|
||||||
|
const collabs = db.select().from(noteCollaborators).all();
|
||||||
|
expect(collabs).toHaveLength(0);
|
||||||
|
|
||||||
|
const states = db.select().from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, COLLAB_ID)))
|
||||||
|
.all();
|
||||||
|
expect(states).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||||
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
|
import type { Collaborator } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch fetch collaborators for multiple notes.
|
||||||
|
* Returns a Map of noteId → Collaborator[].
|
||||||
|
*/
|
||||||
|
export function fetchCollaboratorsForNotes(db: Db, noteIds: string[]): Map<string, Collaborator[]> {
|
||||||
|
if (noteIds.length === 0) return new Map();
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
noteId: noteCollaborators.noteId,
|
||||||
|
userId: noteCollaborators.userId,
|
||||||
|
displayName: users.displayName,
|
||||||
|
email: users.email,
|
||||||
|
addedAt: noteCollaborators.addedAt
|
||||||
|
})
|
||||||
|
.from(noteCollaborators)
|
||||||
|
.innerJoin(users, eq(noteCollaborators.userId, users.id))
|
||||||
|
.where(inArray(noteCollaborators.noteId, noteIds))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const map = new Map<string, Collaborator[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const collab: Collaborator = {
|
||||||
|
userId: row.userId,
|
||||||
|
displayName: row.displayName,
|
||||||
|
email: row.email,
|
||||||
|
addedAt: row.addedAt
|
||||||
|
};
|
||||||
|
const existing = map.get(row.noteId);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(collab);
|
||||||
|
} else {
|
||||||
|
map.set(row.noteId, [collab]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a collaborator to a note.
|
||||||
|
*/
|
||||||
|
export function addCollaborator(db: Db, noteId: string, userId: number, addedBy: number): void {
|
||||||
|
db.insert(noteCollaborators)
|
||||||
|
.values({ noteId, userId, addedBy, addedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a collaborator from a note. Also cleans up their per-user state.
|
||||||
|
*/
|
||||||
|
export function removeCollaborator(db: Db, noteId: string, userId: number): void {
|
||||||
|
db.delete(noteCollaborators)
|
||||||
|
.where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId)))
|
||||||
|
.run();
|
||||||
|
db.delete(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, noteId), eq(noteUserState.userId, userId)))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all collaborator user IDs for a note.
|
||||||
|
*/
|
||||||
|
export function getCollaboratorIds(db: Db, noteId: string): number[] {
|
||||||
|
return db
|
||||||
|
.select({ userId: noteCollaborators.userId })
|
||||||
|
.from(noteCollaborators)
|
||||||
|
.where(eq(noteCollaborators.noteId, noteId))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './test-helpers.js';
|
||||||
|
import { notes, noteCollaborators, noteUserState, users } 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>;
|
||||||
|
|
||||||
|
function seedTwoUsers(db: BetterSQLite3Database<typeof schema>) {
|
||||||
|
db.insert(users)
|
||||||
|
.values([
|
||||||
|
{ email: 'owner@test.com', displayName: 'Owner', role: 'admin', authProvider: 'password', createdAt: new Date() },
|
||||||
|
{ email: 'collab@test.com', displayName: 'Collaborator', role: 'user', authProvider: 'password', createdAt: new Date() }
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNote(db: BetterSQLite3Database<typeof schema>, id: string, userId: number) {
|
||||||
|
const now = new Date();
|
||||||
|
db.insert(notes).values({ id, userId, title: 'Test', content: '', createdAt: now, updatedAt: now }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb();
|
||||||
|
db = testDb.db;
|
||||||
|
seedTwoUsers(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noteCollaborators table', () => {
|
||||||
|
it('should add a collaborator to a note', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
db.insert(noteCollaborators)
|
||||||
|
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].userId).toBe(2);
|
||||||
|
expect(result[0].addedBy).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce unique constraint on (noteId, userId)', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
db.insert(noteCollaborators)
|
||||||
|
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
db.insert(noteCollaborators)
|
||||||
|
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||||
|
.run()
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow multiple collaborators per note', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
// Add a third user
|
||||||
|
db.insert(users)
|
||||||
|
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(noteCollaborators).values([
|
||||||
|
{ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() },
|
||||||
|
{ noteId: 'n1', userId: 3, addedBy: 1, addedAt: new Date() }
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a collaborator', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
db.insert(noteCollaborators)
|
||||||
|
.values({ noteId: 'n1', userId: 2, addedBy: 1, addedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.delete(noteCollaborators)
|
||||||
|
.where(and(eq(noteCollaborators.noteId, 'n1'), eq(noteCollaborators.userId, 2)))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const result = db.select().from(noteCollaborators).where(eq(noteCollaborators.noteId, 'n1')).all();
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noteUserState table', () => {
|
||||||
|
it('should store per-user pin/archive/sortOrder state', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 5 })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const result = db.select().from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.pinned).toBe(true);
|
||||||
|
expect(result!.archived).toBe(false);
|
||||||
|
expect(result!.sortOrder).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce unique constraint on (noteId, userId)', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'n1', userId: 2, pinned: false, archived: false, sortOrder: 0 })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 0 })
|
||||||
|
.run()
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to false/false/0 for new state', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'n1', userId: 2 })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const result = db.select().from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(result!.pinned).toBe(false);
|
||||||
|
expect(result!.archived).toBe(false);
|
||||||
|
expect(result!.sortOrder).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow independent state per user per note', () => {
|
||||||
|
seedNote(db, 'n1', 1);
|
||||||
|
db.insert(users)
|
||||||
|
.values({ email: 'third@test.com', displayName: 'Third', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
db.insert(noteUserState).values([
|
||||||
|
{ noteId: 'n1', userId: 2, pinned: true, archived: false, sortOrder: 1 },
|
||||||
|
{ noteId: 'n1', userId: 3, pinned: false, archived: true, sortOrder: 2 }
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const user2State = db.select().from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 2))).get();
|
||||||
|
const user3State = db.select().from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, 'n1'), eq(noteUserState.userId, 3))).get();
|
||||||
|
|
||||||
|
expect(user2State!.pinned).toBe(true);
|
||||||
|
expect(user2State!.archived).toBe(false);
|
||||||
|
expect(user3State!.pinned).toBe(false);
|
||||||
|
expect(user3State!.archived).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,6 +93,21 @@ sqlite.exec(`
|
|||||||
client_id TEXT NOT NULL
|
client_id TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS note_collaborators (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
added_by INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
added_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS note_user_state (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ip TEXT NOT NULL,
|
ip TEXT NOT NULL,
|
||||||
@@ -100,6 +115,33 @@ sqlite.exec(`
|
|||||||
success INTEGER NOT NULL,
|
success INTEGER NOT NULL,
|
||||||
timestamp INTEGER NOT NULL
|
timestamp INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shared_notes (
|
||||||
|
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS note_versions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color TEXT NOT NULL DEFAULT 'default',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(note_id, version)
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Migration: detect old schema (no email column on users) and add columns
|
// Migration: detect old schema (no email column on users) and add columns
|
||||||
@@ -167,6 +209,23 @@ sqlite.exec(`
|
|||||||
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
|
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
|
||||||
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
||||||
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS note_versions_note_id_idx ON note_versions(note_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Replace old tags unique index with compound index including user_id
|
// Replace old tags unique index with compound index including user_id
|
||||||
@@ -191,8 +250,32 @@ try {
|
|||||||
// Column already exists
|
// Column already exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: make password_hash nullable (for OAuth-only users)
|
||||||
|
const pwCol = (
|
||||||
|
sqlite.prepare("PRAGMA table_info('users')").all() as { name: string; notnull: number }[]
|
||||||
|
).find((col) => col.name === 'password_hash');
|
||||||
|
if (pwCol && pwCol.notnull) {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE __new_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
password_hash TEXT,
|
||||||
|
auth_provider TEXT NOT NULL DEFAULT 'password',
|
||||||
|
provider_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO __new_users(id, email, display_name, role, password_hash, auth_provider, provider_id, created_at)
|
||||||
|
SELECT id, email, display_name, role, password_hash, auth_provider, provider_id, created_at FROM users;
|
||||||
|
DROP TABLE users;
|
||||||
|
ALTER TABLE __new_users RENAME TO users;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-enable foreign keys
|
// Re-enable foreign keys
|
||||||
sqlite.pragma('foreign_keys = ON');
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
export type Db = import('drizzle-orm/better-sqlite3').BetterSQLite3Database<typeof schema>;
|
||||||
export { sqlite };
|
export { sqlite };
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type * as schema from './schema.js';
|
|||||||
let db: BetterSQLite3Database<typeof schema>;
|
let db: BetterSQLite3Database<typeof schema>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const testDb = createTestDb();
|
const testDb = createTestDb({ seedUser: true });
|
||||||
db = testDb.db;
|
db = testDb.db;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ describe('Notes CRUD', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id,
|
id,
|
||||||
|
userId: 1,
|
||||||
title: 'Test Note',
|
title: 'Test Note',
|
||||||
content: 'Hello world',
|
content: 'Hello world',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -45,6 +46,7 @@ describe('Notes CRUD', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id,
|
id,
|
||||||
|
userId: 1,
|
||||||
title: 'Colored Note',
|
title: 'Colored Note',
|
||||||
content: '',
|
content: '',
|
||||||
color: 'coral',
|
color: 'coral',
|
||||||
@@ -62,16 +64,17 @@ describe('Notes CRUD', () => {
|
|||||||
it('should read all active notes (not archived, not trashed)', async () => {
|
it('should read all active notes (not archived, not trashed)', async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await db.insert(notes).values([
|
await db.insert(notes).values([
|
||||||
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
|
{ id: 'n1', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now },
|
||||||
{
|
{
|
||||||
id: 'n2',
|
id: 'n2',
|
||||||
|
userId: 1,
|
||||||
title: 'Archived',
|
title: 'Archived',
|
||||||
content: '',
|
content: '',
|
||||||
archived: true,
|
archived: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
},
|
},
|
||||||
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
{ id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const active = await db
|
const active = await db
|
||||||
@@ -89,6 +92,7 @@ describe('Notes CRUD', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id,
|
id,
|
||||||
|
userId: 1,
|
||||||
title: 'Original',
|
title: 'Original',
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -113,6 +117,7 @@ describe('Notes CRUD', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id,
|
id,
|
||||||
|
userId: 1,
|
||||||
title: 'To Trash',
|
title: 'To Trash',
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -132,6 +137,7 @@ describe('Notes CRUD', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id,
|
id,
|
||||||
|
userId: 1,
|
||||||
title: 'To Delete',
|
title: 'To Delete',
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -149,6 +155,7 @@ describe('Notes CRUD', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id,
|
id,
|
||||||
|
userId: 1,
|
||||||
title: 'To Archive',
|
title: 'To Archive',
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -170,6 +177,7 @@ describe('Notes CRUD', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id,
|
id,
|
||||||
|
userId: 1,
|
||||||
title: 'Pin Me',
|
title: 'Pin Me',
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -193,6 +201,7 @@ describe('Notes with Tags', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id: noteId,
|
id: noteId,
|
||||||
|
userId: 1,
|
||||||
title: 'Tagged Note',
|
title: 'Tagged Note',
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -200,8 +209,8 @@ describe('Notes with Tags', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create tags
|
// Create tags
|
||||||
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
|
const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
|
||||||
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
|
const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning();
|
||||||
|
|
||||||
// Associate
|
// Associate
|
||||||
await db.insert(noteTags).values([
|
await db.insert(noteTags).values([
|
||||||
@@ -226,13 +235,14 @@ describe('Notes with Tags', () => {
|
|||||||
|
|
||||||
await db.insert(notes).values({
|
await db.insert(notes).values({
|
||||||
id: noteId,
|
id: noteId,
|
||||||
|
userId: 1,
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
|
const [tag] = await db.insert(tags).values({ userId: 1, name: 'temp' }).returning();
|
||||||
await db.insert(noteTags).values({ noteId, tagId: tag.id });
|
await db.insert(noteTags).values({ noteId, tagId: tag.id });
|
||||||
|
|
||||||
// Delete note_tags first (FK cascade is disabled in test DB), then note
|
// Delete note_tags first (FK cascade is disabled in test DB), then note
|
||||||
@@ -258,6 +268,7 @@ describe('Note ordering', () => {
|
|||||||
await db.insert(notes).values([
|
await db.insert(notes).values([
|
||||||
{
|
{
|
||||||
id: 'unpinned',
|
id: 'unpinned',
|
||||||
|
userId: 1,
|
||||||
title: 'Unpinned',
|
title: 'Unpinned',
|
||||||
content: '',
|
content: '',
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -266,6 +277,7 @@ describe('Note ordering', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pinned',
|
id: 'pinned',
|
||||||
|
userId: 1,
|
||||||
title: 'Pinned',
|
title: 'Pinned',
|
||||||
content: '',
|
content: '',
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ export const attachments = sqliteTable('attachments', {
|
|||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiKeys = sqliteTable('api_keys', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
keyHash: text('key_hash').notNull(),
|
||||||
|
keyPrefix: text('key_prefix').notNull(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
|
||||||
|
});
|
||||||
|
|
||||||
export const syncLog = sqliteTable(
|
export const syncLog = sqliteTable(
|
||||||
'sync_log',
|
'sync_log',
|
||||||
{
|
{
|
||||||
@@ -116,6 +128,42 @@ export const syncLog = sqliteTable(
|
|||||||
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
|
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const noteCollaborators = sqliteTable(
|
||||||
|
'note_collaborators',
|
||||||
|
{
|
||||||
|
noteId: text('note_id')
|
||||||
|
.references(() => notes.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
addedBy: integer('added_by')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
addedAt: integer('added_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex('note_collaborators_unique').on(table.noteId, table.userId),
|
||||||
|
index('note_collaborators_user_id_idx').on(table.userId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const noteUserState = sqliteTable(
|
||||||
|
'note_user_state',
|
||||||
|
{
|
||||||
|
noteId: text('note_id')
|
||||||
|
.references(() => notes.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
|
||||||
|
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
|
||||||
|
sortOrder: integer('sort_order').default(0).notNull()
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('note_user_state_unique').on(table.noteId, table.userId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const loginAttempts = sqliteTable(
|
export const loginAttempts = sqliteTable(
|
||||||
'login_attempts',
|
'login_attempts',
|
||||||
{
|
{
|
||||||
@@ -127,3 +175,46 @@ export const loginAttempts = sqliteTable(
|
|||||||
},
|
},
|
||||||
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
|
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const userPreferences = sqliteTable(
|
||||||
|
'user_preferences',
|
||||||
|
{
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
key: text('key').notNull(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const sharedNotes = sqliteTable('shared_notes', {
|
||||||
|
noteId: text('note_id')
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => notes.id, { onDelete: 'cascade' }),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
expiresAt: integer('expires_at', { mode: 'timestamp' })
|
||||||
|
});
|
||||||
|
|
||||||
|
export const noteVersions = sqliteTable(
|
||||||
|
'note_versions',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
noteId: text('note_id')
|
||||||
|
.references(() => notes.id, { onDelete: 'cascade' })
|
||||||
|
.notNull(),
|
||||||
|
version: integer('version').notNull(),
|
||||||
|
title: text('title').notNull().default(''),
|
||||||
|
content: text('content').notNull().default(''),
|
||||||
|
checklistMode: integer('checklist_mode', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
color: text('color').notNull().default('default'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('note_versions_note_id_idx').on(table.noteId),
|
||||||
|
uniqueIndex('note_versions_note_id_version_unique').on(table.noteId, table.version)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ import type * as schema from './schema.js';
|
|||||||
let db: BetterSQLite3Database<typeof schema>;
|
let db: BetterSQLite3Database<typeof schema>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const testDb = createTestDb();
|
const testDb = createTestDb({ seedUser: true });
|
||||||
db = testDb.db;
|
db = testDb.db;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await db.insert(notes).values([
|
await db.insert(notes).values([
|
||||||
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
|
{ id: 'n1', userId: 1, 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: 'n2', userId: 1, 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: 'n3', userId: 1, 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 }
|
{ id: 'n4', userId: 1, title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
|
const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
|
||||||
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
|
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as schema from './schema.js';
|
|||||||
* Create an in-memory SQLite database for testing.
|
* Create an in-memory SQLite database for testing.
|
||||||
* Each test gets a fresh database.
|
* Each test gets a fresh database.
|
||||||
*/
|
*/
|
||||||
export function createTestDb() {
|
export function createTestDb(options?: { seedUser?: boolean }) {
|
||||||
const sqlite = new Database(':memory:');
|
const sqlite = new Database(':memory:');
|
||||||
sqlite.pragma('journal_mode = WAL');
|
sqlite.pragma('journal_mode = WAL');
|
||||||
sqlite.pragma('foreign_keys = OFF');
|
sqlite.pragma('foreign_keys = OFF');
|
||||||
@@ -68,6 +68,24 @@ export function createTestDb() {
|
|||||||
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
||||||
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
||||||
|
|
||||||
|
CREATE TABLE note_collaborators (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
added_by INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
added_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX note_collaborators_unique ON note_collaborators(note_id, user_id);
|
||||||
|
CREATE INDEX note_collaborators_user_id_idx ON note_collaborators(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE note_user_state (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX note_user_state_unique ON note_user_state(note_id, user_id);
|
||||||
|
|
||||||
CREATE TABLE attachments (
|
CREATE TABLE attachments (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||||
@@ -76,6 +94,8 @@ export function createTestDb() {
|
|||||||
mime_type TEXT NOT NULL,
|
mime_type TEXT NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
|
thumbnail_path TEXT,
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,8 +117,58 @@ export function createTestDb() {
|
|||||||
timestamp INTEGER NOT NULL
|
timestamp INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key);
|
||||||
|
|
||||||
|
CREATE TABLE shared_notes (
|
||||||
|
note_id TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE note_versions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color TEXT NOT NULL DEFAULT 'default',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(note_id, version)
|
||||||
|
);
|
||||||
|
CREATE INDEX note_versions_note_id_idx ON note_versions(note_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const db = drizzle(sqlite, { schema });
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
if (options?.seedUser) {
|
||||||
|
db.insert(schema.users).values({
|
||||||
|
email: 'test@test.com',
|
||||||
|
displayName: 'Test User',
|
||||||
|
role: 'admin',
|
||||||
|
authProvider: 'password',
|
||||||
|
createdAt: new Date()
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
return { db, sqlite };
|
return { db, sqlite };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import type { Transporter } from 'nodemailer';
|
||||||
|
|
||||||
|
let transporter: Transporter | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if SMTP email is configured via environment variables.
|
||||||
|
*/
|
||||||
|
export function isEmailConfigured(): boolean {
|
||||||
|
return !!process.env.SMTP_HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the nodemailer transport (lazy singleton).
|
||||||
|
*/
|
||||||
|
function getTransporter(): Transporter {
|
||||||
|
if (!transporter) {
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||||
|
secure: process.env.SMTP_PORT === '465',
|
||||||
|
auth:
|
||||||
|
process.env.SMTP_USER
|
||||||
|
? {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS || ''
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email. Best-effort: logs errors but does not throw.
|
||||||
|
*/
|
||||||
|
async function sendEmail(to: string, subject: string, html: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const transport = getTransporter();
|
||||||
|
await transport.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'Crumbs <noreply@localhost>',
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[email] Failed to send:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a share notification email to a collaborator.
|
||||||
|
*/
|
||||||
|
export async function sendShareNotification(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
fromName: string,
|
||||||
|
noteTitle: string,
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = `${fromName} shared a note with you`;
|
||||||
|
const html = buildShareEmailHtml(toName, fromName, noteTitle, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a welcome email to a newly created user.
|
||||||
|
*/
|
||||||
|
export async function sendWelcomeEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Welcome to Crumbs';
|
||||||
|
const html = buildWelcomeEmailHtml(toName, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a password reset notification to a user (triggered by admin reset).
|
||||||
|
*/
|
||||||
|
export async function sendPasswordResetEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs password has been reset';
|
||||||
|
const html = buildPasswordResetEmailHtml(toName, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an account deletion confirmation email.
|
||||||
|
*/
|
||||||
|
export async function sendAccountDeletedEmail(toEmail: string, toName: string): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs account has been deleted';
|
||||||
|
const html = buildAccountDeletedEmailHtml(toName);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification to the old email when the account email address changes.
|
||||||
|
*/
|
||||||
|
export async function sendEmailChangedEmail(
|
||||||
|
oldEmail: string,
|
||||||
|
toName: string,
|
||||||
|
newEmail: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!oldEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs email address has been changed';
|
||||||
|
const html = buildEmailChangedEmailHtml(toName, newEmail);
|
||||||
|
await sendEmail(oldEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a role change notification to a user.
|
||||||
|
*/
|
||||||
|
export async function sendRoleChangedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
newRole: 'admin' | 'user',
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Your Crumbs account role has been updated';
|
||||||
|
const html = buildRoleChangedEmailHtml(toName, newRole, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification to a collaborator that they have been removed from a note.
|
||||||
|
*/
|
||||||
|
export async function sendCollaboratorRemovedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
ownerName: string,
|
||||||
|
noteTitle: string,
|
||||||
|
appUrl: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = `You've been removed from a shared note`;
|
||||||
|
const html = buildCollaboratorRemovedEmailHtml(toName, ownerName, noteTitle, appUrl);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification to collaborators that a shared note has been permanently deleted.
|
||||||
|
*/
|
||||||
|
export async function sendNotePermanentlyDeletedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
noteTitle: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = `A shared note has been deleted`;
|
||||||
|
const html = buildNotePermanentlyDeletedEmailHtml(toName, noteTitle);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a security alert to a user when their account is locked due to too many failed logins.
|
||||||
|
*/
|
||||||
|
export async function sendAccountLockedEmail(
|
||||||
|
toEmail: string,
|
||||||
|
toName: string,
|
||||||
|
retryAfterMinutes: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (!toEmail) return;
|
||||||
|
|
||||||
|
const subject = 'Crumbs account temporarily locked';
|
||||||
|
const html = buildAccountLockedEmailHtml(toName, retryAfterMinutes);
|
||||||
|
await sendEmail(toEmail, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareEmailHtml(
|
||||||
|
toName: string,
|
||||||
|
fromName: string,
|
||||||
|
noteTitle: string,
|
||||||
|
appUrl: string
|
||||||
|
): string {
|
||||||
|
const displayName = toName || 'there';
|
||||||
|
const escapedTitle = escapeHtml(noteTitle);
|
||||||
|
const escapedFrom = escapeHtml(fromName || 'Someone');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${escapeHtml(displayName)},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
<strong>${escapedFrom}</strong> shared a note with you: <strong>“${escapedTitle}”</strong>
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because someone shared a note with you on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailLayout(content: string, footer: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f0e6d3;font-family:'JetBrains Mono',monospace;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0e6d3;padding:40px 20px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
|
||||||
|
<tr><td style="padding:32px;">
|
||||||
|
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
|
||||||
|
${content}
|
||||||
|
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">${footer}</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenButton(appUrl: string, label = 'Open Crumbs'): string {
|
||||||
|
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||||
|
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
|
||||||
|
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">${escapeHtml(label)}</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWelcomeEmailHtml(toName: string, appUrl: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Welcome to Crumbs</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
An admin has created an account for you on Crumbs. You can log in and start taking notes right away.
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because an admin created an account for you on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPasswordResetEmailHtml(toName: string, appUrl: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Password reset</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
An admin has reset your Crumbs password. Please log in and change it to something only you know.
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because an admin reset your password on Crumbs. If you didn’t expect this, please contact your admin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccountDeletedEmailHtml(toName: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account deleted</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Your Crumbs account and all associated data have been permanently deleted.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this as confirmation that your Crumbs account was deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailChangedEmailHtml(toName: string, newEmail: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Email address changed</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Your Crumbs account email address has been changed to <strong>${escapeHtml(newEmail)}</strong>. If you did not make this change, please contact your admin immediately.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this security notice because your Crumbs account email was updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoleChangedEmailHtml(toName: string, newRole: 'admin' | 'user', appUrl: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const roleLabel = newRole === 'admin' ? 'Admin' : 'User';
|
||||||
|
const detail = newRole === 'admin'
|
||||||
|
? 'You now have admin privileges, including the ability to manage users and settings.'
|
||||||
|
: 'Your account no longer has admin privileges.';
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Role updated: ${roleLabel}</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Your Crumbs account role has been updated to <strong>${escapeHtml(roleLabel)}</strong>. ${escapeHtml(detail)}
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because your account role was changed on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCollaboratorRemovedEmailHtml(
|
||||||
|
toName: string,
|
||||||
|
ownerName: string,
|
||||||
|
noteTitle: string,
|
||||||
|
appUrl: string
|
||||||
|
): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const escapedOwner = escapeHtml(ownerName || 'Someone');
|
||||||
|
const escapedTitle = escapeHtml(noteTitle);
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Removed from shared note</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
<strong>${escapedOwner}</strong> has removed you from the shared note <strong>“${escapedTitle}”</strong>. You no longer have access to this note.
|
||||||
|
</p>
|
||||||
|
${buildOpenButton(appUrl)}`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because you were removed from a shared note on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNotePermanentlyDeletedEmailHtml(toName: string, noteTitle: string): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const escapedTitle = escapeHtml(noteTitle);
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Shared note deleted</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
The shared note <strong>“${escapedTitle}”</strong> has been permanently deleted by its owner. You no longer have access to it.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this because a shared note you had access to was deleted on Crumbs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAccountLockedEmailHtml(toName: string, retryAfterMinutes: number): string {
|
||||||
|
const displayName = escapeHtml(toName || 'there');
|
||||||
|
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account temporarily locked</h1>
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Hey ${displayName},
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||||
|
Too many failed login attempts were made on your Crumbs account. For your security, login has been temporarily blocked for <strong>${retryAfterMinutes} minute${retryAfterMinutes === 1 ? '' : 's'}</strong>. If this wasn’t you, please change your password once you regain access.
|
||||||
|
</p>`;
|
||||||
|
return buildEmailLayout(content, 'You’re receiving this security alert because of repeated failed login attempts on your Crumbs account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// We need to re-import for each test to get a fresh module state
|
||||||
|
let checkIpRateLimit: (ip: string) => boolean;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./ip-rate-limit.js');
|
||||||
|
checkIpRateLimit = mod.checkIpRateLimit;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkIpRateLimit', () => {
|
||||||
|
it('allows requests under the limit', () => {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks requests over the limit', () => {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
checkIpRateLimit('1.2.3.4');
|
||||||
|
}
|
||||||
|
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks different IPs independently', () => {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
checkIpRateLimit('1.1.1.1');
|
||||||
|
}
|
||||||
|
expect(checkIpRateLimit('1.1.1.1')).toBe(false);
|
||||||
|
expect(checkIpRateLimit('2.2.2.2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets after the window expires', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
checkIpRateLimit('1.2.3.4');
|
||||||
|
}
|
||||||
|
expect(checkIpRateLimit('1.2.3.4')).toBe(false);
|
||||||
|
|
||||||
|
// Advance past the 1-minute window
|
||||||
|
vi.advanceTimersByTime(61_000);
|
||||||
|
expect(checkIpRateLimit('1.2.3.4')).toBe(true);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const store = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
const WINDOW_MS = 60_000; // 1 minute
|
||||||
|
const MAX_REQUESTS = 60;
|
||||||
|
const MAX_ENTRIES = 10_000;
|
||||||
|
let cleanupCounter = 0;
|
||||||
|
|
||||||
|
export function checkIpRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = store.get(ip);
|
||||||
|
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
||||||
|
store.set(ip, { count: 1, windowStart: now });
|
||||||
|
pruneIfNeeded(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return entry.count <= MAX_REQUESTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneIfNeeded(now: number) {
|
||||||
|
cleanupCounter++;
|
||||||
|
if (cleanupCounter < 100 || store.size < MAX_ENTRIES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanupCounter = 0;
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (now - entry.windowStart > WINDOW_MS) {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { NoteFilter } from '$lib/types/index.js';
|
||||||
|
import {
|
||||||
|
listNotes,
|
||||||
|
getNote,
|
||||||
|
createNote,
|
||||||
|
updateNote,
|
||||||
|
deleteNote,
|
||||||
|
searchNotes,
|
||||||
|
listAllTags,
|
||||||
|
reorderNotes
|
||||||
|
} from '../notes-service.js';
|
||||||
|
import { saveAttachment } from '../attachments.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
const NOTE_COLORS = [
|
||||||
|
'default',
|
||||||
|
'coral',
|
||||||
|
'peach',
|
||||||
|
'sand',
|
||||||
|
'mint',
|
||||||
|
'sage',
|
||||||
|
'fog',
|
||||||
|
'storm',
|
||||||
|
'dusk',
|
||||||
|
'blossom',
|
||||||
|
'clay',
|
||||||
|
'chalk'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function createMcpServer(userId: number): McpServer {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: 'crumbs',
|
||||||
|
version: '0.7.2'
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'list_notes',
|
||||||
|
'List notes with optional filter (all, archived, trashed) and tag filter',
|
||||||
|
{
|
||||||
|
filter: z
|
||||||
|
.enum(['all', 'archived', 'trashed'])
|
||||||
|
.optional()
|
||||||
|
.describe('Filter notes by status'),
|
||||||
|
tag: z.string().optional().describe('Filter by tag name')
|
||||||
|
},
|
||||||
|
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
|
||||||
|
let result = listNotes(db, userId, filter as NoteFilter);
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
result = result.filter((n) =>
|
||||||
|
(n.tags ?? []).some((t: string) => t.toLowerCase() === tag.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'get_note',
|
||||||
|
'Get a single note by ID with its tags and attachments',
|
||||||
|
{ id: z.string().describe('Note ID') },
|
||||||
|
async ({ id }: { id: string }) => {
|
||||||
|
const note = getNote(db, userId, id);
|
||||||
|
if (!note) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'create_note',
|
||||||
|
'Create a new note',
|
||||||
|
{
|
||||||
|
title: z.string().optional().describe('Note title'),
|
||||||
|
content: z.string().optional().describe('Note content (supports markdown)'),
|
||||||
|
color: z.enum(NOTE_COLORS).optional().describe('Note color'),
|
||||||
|
checklistMode: z.boolean().optional().describe('Enable checklist mode')
|
||||||
|
},
|
||||||
|
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
|
||||||
|
const note = createNote(db, userId, { title, content, color, checklistMode });
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'update_note',
|
||||||
|
'Update an existing note',
|
||||||
|
{
|
||||||
|
id: z.string().describe('Note ID'),
|
||||||
|
title: z.string().optional().describe('New title'),
|
||||||
|
content: z.string().optional().describe('New content'),
|
||||||
|
color: z.enum(NOTE_COLORS).optional().describe('New color'),
|
||||||
|
pinned: z.boolean().optional().describe('Pin/unpin'),
|
||||||
|
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
|
||||||
|
},
|
||||||
|
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
|
||||||
|
const result = updateNote(db, userId, id, updates);
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'trash_note',
|
||||||
|
'Move a note to trash',
|
||||||
|
{ id: z.string().describe('Note ID') },
|
||||||
|
async ({ id }: { id: string }) => {
|
||||||
|
const result = updateNote(db, userId, id, { trashed: true });
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Note "${result.title}" trashed` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'restore_note',
|
||||||
|
'Restore a note from trash',
|
||||||
|
{ id: z.string().describe('Note ID') },
|
||||||
|
async ({ id }: { id: string }) => {
|
||||||
|
const result = updateNote(db, userId, id, { trashed: false });
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Note "${result.title}" restored` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'archive_note',
|
||||||
|
'Archive a note',
|
||||||
|
{ id: z.string().describe('Note ID') },
|
||||||
|
async ({ id }: { id: string }) => {
|
||||||
|
const result = updateNote(db, userId, id, { archived: true });
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Note "${result.title}" archived` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'unarchive_note',
|
||||||
|
'Unarchive a note',
|
||||||
|
{ id: z.string().describe('Note ID') },
|
||||||
|
async ({ id }: { id: string }) => {
|
||||||
|
const result = updateNote(db, userId, id, { archived: false });
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Note "${result.title}" unarchived` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'delete_note',
|
||||||
|
'Permanently delete a note',
|
||||||
|
{ id: z.string().describe('Note ID') },
|
||||||
|
async ({ id }: { id: string }) => {
|
||||||
|
const existing = getNote(db, userId, id);
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteNote(db, userId, id);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Note "${existing.title}" permanently deleted`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'search_notes',
|
||||||
|
'Search notes by title, content, or tag name',
|
||||||
|
{ query: z.string().describe('Search query') },
|
||||||
|
async ({ query }: { query: string }) => {
|
||||||
|
const results = searchNotes(db, userId, query);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool('list_tags', 'List all tags', {}, async () => {
|
||||||
|
const allTags = listAllTags(db, userId);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'pin_note',
|
||||||
|
'Pin or unpin a note',
|
||||||
|
{
|
||||||
|
id: z.string().describe('Note ID'),
|
||||||
|
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||||
|
},
|
||||||
|
async ({ id, pinned }: { id: string; pinned: boolean }) => {
|
||||||
|
const result = updateNote(db, userId, id, { pinned });
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'reorder_notes',
|
||||||
|
'Reorder notes by setting sort orders',
|
||||||
|
{
|
||||||
|
orders: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().describe('Note ID'),
|
||||||
|
sortOrder: z.number().describe('New sort order')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.describe('Array of note IDs with their new sort orders')
|
||||||
|
},
|
||||||
|
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
|
||||||
|
reorderNotes(db, userId, orders);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'upload_image',
|
||||||
|
'Attach an image to a note by fetching it from a URL',
|
||||||
|
{
|
||||||
|
noteId: z.string().describe('Note ID to attach the image to'),
|
||||||
|
imageUrl: z.string().url().describe('URL of the image to fetch')
|
||||||
|
},
|
||||||
|
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
|
||||||
|
const existing = getNote(db, userId, noteId);
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Failed to fetch image: ${response.status} ${response.statusText}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || 'image/png';
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||||
|
const filename = `upload_${Date.now()}.${ext}`;
|
||||||
|
|
||||||
|
const file = new File([buffer], filename, { type: contentType });
|
||||||
|
const attachment = await saveAttachment(db, noteId, file, userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `Failed to upload image: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
isError: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { notes, noteCollaborators, noteUserState, noteVersions, users } from './db/schema.js';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import {
|
||||||
|
listNotes,
|
||||||
|
getNote,
|
||||||
|
createNote,
|
||||||
|
updateNote,
|
||||||
|
deleteNote,
|
||||||
|
searchNotes,
|
||||||
|
reorderNotes
|
||||||
|
} from './notes-service.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
|
||||||
|
const OWNER_ID = 1;
|
||||||
|
const COLLAB_ID = 2;
|
||||||
|
|
||||||
|
function seedUsers() {
|
||||||
|
db.insert(users)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
email: 'owner@test.com',
|
||||||
|
displayName: 'Owner',
|
||||||
|
role: 'admin',
|
||||||
|
authProvider: 'password',
|
||||||
|
createdAt: new Date()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'collab@test.com',
|
||||||
|
displayName: 'Collab',
|
||||||
|
role: 'user',
|
||||||
|
authProvider: 'password',
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNote(
|
||||||
|
id: string,
|
||||||
|
overrides: Partial<typeof notes.$inferInsert> = {}
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
db.insert(notes)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
userId: OWNER_ID,
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareNote(noteId: string, userId = COLLAB_ID) {
|
||||||
|
db.insert(noteCollaborators)
|
||||||
|
.values({
|
||||||
|
noteId,
|
||||||
|
userId,
|
||||||
|
addedBy: OWNER_ID,
|
||||||
|
addedAt: new Date()
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb();
|
||||||
|
db = testDb.db;
|
||||||
|
seedUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listNotes', () => {
|
||||||
|
it('should return only active owned notes (excludes archived/trashed)', () => {
|
||||||
|
seedNote('active', { title: 'Active' });
|
||||||
|
seedNote('archived', { title: 'Archived', archived: true });
|
||||||
|
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||||
|
|
||||||
|
const result = listNotes(db, OWNER_ID);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include shared notes with per-user state overlay', () => {
|
||||||
|
seedNote('shared', { title: 'Shared Note' });
|
||||||
|
shareNote('shared');
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: true, archived: false, sortOrder: 5 })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const result = listNotes(db, COLLAB_ID);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Shared Note');
|
||||||
|
expect(result[0].pinned).toBeTruthy();
|
||||||
|
expect(result[0].sortOrder).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort pinned first, then by updatedAt desc', () => {
|
||||||
|
const old = new Date('2024-01-01');
|
||||||
|
const recent = new Date('2024-01-02');
|
||||||
|
seedNote('old-pinned', { title: 'Old Pinned', pinned: true, updatedAt: old, createdAt: old });
|
||||||
|
seedNote('recent', { title: 'Recent', updatedAt: recent, createdAt: recent });
|
||||||
|
seedNote('old', { title: 'Old', updatedAt: old, createdAt: old });
|
||||||
|
|
||||||
|
const result = listNotes(db, OWNER_ID);
|
||||||
|
expect(result.map((n) => n.title)).toEqual(['Old Pinned', 'Recent', 'Old']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return only archived notes with archived filter', () => {
|
||||||
|
seedNote('active', { title: 'Active' });
|
||||||
|
seedNote('archived', { title: 'Archived', archived: true });
|
||||||
|
seedNote('shared', { title: 'Shared Archived' });
|
||||||
|
shareNote('shared');
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 0 })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Owner sees their archived note
|
||||||
|
const ownerResult = listNotes(db, OWNER_ID, 'archived');
|
||||||
|
expect(ownerResult).toHaveLength(1);
|
||||||
|
expect(ownerResult[0].title).toBe('Archived');
|
||||||
|
|
||||||
|
// Collaborator sees shared note they archived
|
||||||
|
const collabResult = listNotes(db, COLLAB_ID, 'archived');
|
||||||
|
expect(collabResult).toHaveLength(1);
|
||||||
|
expect(collabResult[0].title).toBe('Shared Archived');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude shared notes from trashed filter', () => {
|
||||||
|
seedNote('trashed', { title: 'Trashed', trashed: true });
|
||||||
|
seedNote('shared', { title: 'Shared' });
|
||||||
|
shareNote('shared');
|
||||||
|
|
||||||
|
const result = listNotes(db, COLLAB_ID, 'trashed');
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNote', () => {
|
||||||
|
it('should return null for non-existent note', () => {
|
||||||
|
expect(getNote(db, OWNER_ID, 'nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if user has no access', () => {
|
||||||
|
seedNote('private', { title: 'Private' });
|
||||||
|
expect(getNote(db, COLLAB_ID, 'private')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overlay collaborator per-user state', () => {
|
||||||
|
seedNote('shared', { title: 'Shared', pinned: true, sortOrder: 10 });
|
||||||
|
shareNote('shared');
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: 'shared', userId: COLLAB_ID, pinned: false, archived: true, sortOrder: 5 })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const note = getNote(db, COLLAB_ID, 'shared');
|
||||||
|
expect(note).not.toBeNull();
|
||||||
|
expect(note!.pinned).toBeFalsy();
|
||||||
|
expect(note!.archived).toBeTruthy();
|
||||||
|
expect(note!.sortOrder).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createNote', () => {
|
||||||
|
it('should generate UUID and set version=1', () => {
|
||||||
|
const note = createNote(db, OWNER_ID, { title: 'Test' });
|
||||||
|
expect(note.id).toBeDefined();
|
||||||
|
expect(note.id.length).toBe(36); // UUID format
|
||||||
|
expect(note.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use provided ID when given', () => {
|
||||||
|
const note = createNote(db, OWNER_ID, { id: 'custom-id', title: 'Test' });
|
||||||
|
expect(note.id).toBe('custom-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract tags from content', () => {
|
||||||
|
const note = createNote(db, OWNER_ID, {
|
||||||
|
title: 'My #todo note',
|
||||||
|
content: 'Some #important content'
|
||||||
|
});
|
||||||
|
expect(note.tags).toContain('todo');
|
||||||
|
expect(note.tags).toContain('important');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateNote', () => {
|
||||||
|
it('should allow owner to update shared fields directly', () => {
|
||||||
|
seedNote('n1', { title: 'Original' });
|
||||||
|
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Updated' });
|
||||||
|
expect(updated).not.toBeNull();
|
||||||
|
expect(updated!.title).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow collaborator to edit content', () => {
|
||||||
|
seedNote('n1', { title: 'Original', content: 'Old' });
|
||||||
|
shareNote('n1');
|
||||||
|
const updated = updateNote(db, COLLAB_ID, 'n1', { content: 'New content' });
|
||||||
|
expect(updated).not.toBeNull();
|
||||||
|
expect(updated!.content).toBe('New content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route collaborator per-user fields to noteUserState', () => {
|
||||||
|
seedNote('n1', { title: 'Note', pinned: false });
|
||||||
|
shareNote('n1');
|
||||||
|
updateNote(db, COLLAB_ID, 'n1', { pinned: true, sortOrder: 7 });
|
||||||
|
|
||||||
|
// Owner's note pinned should be unchanged
|
||||||
|
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||||
|
expect(ownerView!.pinned).toBeFalsy();
|
||||||
|
|
||||||
|
// Collaborator sees their per-user state
|
||||||
|
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||||
|
expect(collabView!.pinned).toBeTruthy();
|
||||||
|
expect(collabView!.sortOrder).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when collaborator tries to trash', () => {
|
||||||
|
seedNote('n1', { title: 'Note' });
|
||||||
|
shareNote('n1');
|
||||||
|
const result = updateNote(db, COLLAB_ID, 'n1', { trashed: true });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment version on update', () => {
|
||||||
|
seedNote('n1', { title: 'V1' });
|
||||||
|
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'V2' });
|
||||||
|
expect(updated!.version).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a snapshot when content changes', () => {
|
||||||
|
seedNote('n1', { title: 'Original', content: 'Hello', color: 'default', checklistMode: false });
|
||||||
|
updateNote(db, OWNER_ID, 'n1', { title: 'Changed' });
|
||||||
|
|
||||||
|
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||||
|
expect(snapshots).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create a snapshot when no content field actually changed', () => {
|
||||||
|
seedNote('n1', { title: 'Same', content: 'Same', color: 'default', checklistMode: false });
|
||||||
|
// Send the same values — nothing actually changes
|
||||||
|
updateNote(db, OWNER_ID, 'n1', { title: 'Same', content: 'Same' });
|
||||||
|
|
||||||
|
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||||
|
expect(snapshots).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create a snapshot when only non-content fields change (pinned, archived)', () => {
|
||||||
|
seedNote('n1', { title: 'Note', pinned: false });
|
||||||
|
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||||
|
|
||||||
|
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||||
|
expect(snapshots).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract and sync tags on title/content change', () => {
|
||||||
|
seedNote('n1', { title: 'Original' });
|
||||||
|
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||||
|
expect(updated!.tags).toContain('newtag');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteNote', () => {
|
||||||
|
it('should allow owner to permanently delete', () => {
|
||||||
|
seedNote('n1', { title: 'Delete me' });
|
||||||
|
const result = deleteNote(db, OWNER_ID, 'n1');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(getNote(db, OWNER_ID, 'n1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent or unauthorized', () => {
|
||||||
|
seedNote('n1', { title: 'Private' });
|
||||||
|
expect(deleteNote(db, COLLAB_ID, 'n1')).toBe(false);
|
||||||
|
expect(deleteNote(db, OWNER_ID, 'nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchNotes', () => {
|
||||||
|
it('should match title and content', () => {
|
||||||
|
seedNote('n1', { title: 'Meeting notes', content: 'Discuss budget' });
|
||||||
|
seedNote('n2', { title: 'Shopping list', content: 'Buy milk' });
|
||||||
|
|
||||||
|
const results = searchNotes(db, OWNER_ID, 'budget');
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe('n1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include shared notes in results', () => {
|
||||||
|
seedNote('shared', { title: 'Shared doc', content: 'Collaboration' });
|
||||||
|
shareNote('shared');
|
||||||
|
|
||||||
|
const results = searchNotes(db, COLLAB_ID, 'Collaboration');
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe('shared');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for empty query', () => {
|
||||||
|
seedNote('n1', { title: 'Test' });
|
||||||
|
expect(searchNotes(db, OWNER_ID, '')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reorderNotes', () => {
|
||||||
|
it('should update owner sortOrder in notes table', () => {
|
||||||
|
seedNote('n1', { title: 'A' });
|
||||||
|
seedNote('n2', { title: 'B' });
|
||||||
|
reorderNotes(db, OWNER_ID, [
|
||||||
|
{ id: 'n1', sortOrder: 2 },
|
||||||
|
{ id: 'n2', sortOrder: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const n1 = getNote(db, OWNER_ID, 'n1');
|
||||||
|
const n2 = getNote(db, OWNER_ID, 'n2');
|
||||||
|
expect(n1!.sortOrder).toBe(2);
|
||||||
|
expect(n2!.sortOrder).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update collaborator sortOrder in noteUserState', () => {
|
||||||
|
seedNote('n1', { title: 'Note', sortOrder: 0 });
|
||||||
|
shareNote('n1');
|
||||||
|
reorderNotes(db, COLLAB_ID, [{ id: 'n1', sortOrder: 5 }]);
|
||||||
|
|
||||||
|
// Owner's sortOrder unchanged
|
||||||
|
const ownerView = getNote(db, OWNER_ID, 'n1');
|
||||||
|
expect(ownerView!.sortOrder).toBe(0);
|
||||||
|
|
||||||
|
// Collaborator sees their sortOrder
|
||||||
|
const collabView = getNote(db, COLLAB_ID, 'n1');
|
||||||
|
expect(collabView!.sortOrder).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
|
||||||
|
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { extractTags } from '$lib/utils/tags.js';
|
||||||
|
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||||
|
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||||
|
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||||
|
import { fetchSharesForNotes } from './shares-service.js';
|
||||||
|
import { canAccessNote } from './api-utils.js';
|
||||||
|
import { createSnapshot } from './versions-service.js';
|
||||||
|
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||||
|
import type { NoteFilter } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
interface NoteRow {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
color: string;
|
||||||
|
pinned: boolean;
|
||||||
|
archived: boolean;
|
||||||
|
trashed: boolean;
|
||||||
|
trashedAt: Date | null;
|
||||||
|
checklistMode: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||||
|
const noteIds = noteRows.map((n) => n.id);
|
||||||
|
const tagMap = fetchTagsForNotes(db, noteIds);
|
||||||
|
const attachmentMap = fetchAttachmentsForNotes(db, noteIds);
|
||||||
|
const collabMap = fetchCollaboratorsForNotes(db, noteIds);
|
||||||
|
const shareMap = fetchSharesForNotes(db, noteIds);
|
||||||
|
|
||||||
|
return noteRows.map((note) => {
|
||||||
|
const collaborators = collabMap.get(note.id) ?? [];
|
||||||
|
const isOwner = note.userId === userId;
|
||||||
|
const isShared = collaborators.length > 0;
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
tags: tagMap.get(note.id) ?? [],
|
||||||
|
attachments: attachmentMap.get(note.id) ?? [],
|
||||||
|
collaborators,
|
||||||
|
isOwner,
|
||||||
|
isShared,
|
||||||
|
shareToken: shareMap.get(note.id) ?? undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shared notes for a user with per-user state overlay.
|
||||||
|
*/
|
||||||
|
function getSharedNotes(db: Db, userId: number, filter: 'all' | 'archived'): NoteRow[] {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: notes.id,
|
||||||
|
userId: notes.userId,
|
||||||
|
title: notes.title,
|
||||||
|
content: notes.content,
|
||||||
|
color: notes.color,
|
||||||
|
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||||
|
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||||
|
trashed: notes.trashed,
|
||||||
|
trashedAt: notes.trashedAt,
|
||||||
|
checklistMode: notes.checklistMode,
|
||||||
|
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||||
|
createdAt: notes.createdAt,
|
||||||
|
updatedAt: notes.updatedAt,
|
||||||
|
version: notes.version
|
||||||
|
})
|
||||||
|
.from(noteCollaborators)
|
||||||
|
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||||
|
.leftJoin(
|
||||||
|
noteUserState,
|
||||||
|
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(noteCollaborators.userId, userId),
|
||||||
|
eq(notes.trashed, false),
|
||||||
|
filter === 'archived'
|
||||||
|
? sql`COALESCE(${noteUserState.archived}, 0) = 1`
|
||||||
|
: sql`COALESCE(${noteUserState.archived}, 0) = 0`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return rows as NoteRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNotes(db: Db, userId: number, filter: NoteFilter = 'all') {
|
||||||
|
// Owned notes
|
||||||
|
let conditions;
|
||||||
|
switch (filter) {
|
||||||
|
case 'archived':
|
||||||
|
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
|
||||||
|
break;
|
||||||
|
case 'trashed':
|
||||||
|
// Collaborators never see trashed notes — only owner's trashed notes
|
||||||
|
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedNotes = db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(conditions)
|
||||||
|
.all() as NoteRow[];
|
||||||
|
|
||||||
|
// Shared notes (not for trashed filter — collaborators don't see trash)
|
||||||
|
let sharedNotes: NoteRow[] = [];
|
||||||
|
if (filter !== 'trashed') {
|
||||||
|
sharedNotes = getSharedNotes(db, userId, filter === 'archived' ? 'archived' : 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = [...ownedNotes, ...sharedNotes];
|
||||||
|
combined.sort((a, b) => {
|
||||||
|
// Pinned first, then by updatedAt descending
|
||||||
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||||
|
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return hydrateNotes(db, combined, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNote(db: Db, userId: number, id: string) {
|
||||||
|
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||||
|
if (!canAccess) return null;
|
||||||
|
|
||||||
|
const note = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
let effectiveNote = note as NoteRow;
|
||||||
|
|
||||||
|
// For collaborators, overlay per-user state
|
||||||
|
if (!isOwner) {
|
||||||
|
const userState = db
|
||||||
|
.select()
|
||||||
|
.from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||||
|
.get();
|
||||||
|
effectiveNote = {
|
||||||
|
...note,
|
||||||
|
pinned: userState?.pinned ?? false,
|
||||||
|
archived: userState?.archived ?? false,
|
||||||
|
sortOrder: userState?.sortOrder ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = hydrateNotes(db, [effectiveNote], userId);
|
||||||
|
return result[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateNoteInput {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
color?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
checklistMode?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
||||||
|
const now = new Date();
|
||||||
|
const id = input.id || uuidv4();
|
||||||
|
|
||||||
|
const newNote = {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
title: input.title || '',
|
||||||
|
content: input.content || '',
|
||||||
|
color: input.color || 'default',
|
||||||
|
pinned: input.pinned || false,
|
||||||
|
archived: false,
|
||||||
|
trashed: false,
|
||||||
|
trashedAt: null,
|
||||||
|
checklistMode: input.checklistMode || false,
|
||||||
|
sortOrder: input.sortOrder || 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = `${newNote.title} ${newNote.content}`;
|
||||||
|
const extractedTags = extractTags(content);
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(notes).values(newNote).run();
|
||||||
|
});
|
||||||
|
syncNoteTags(db, id, extractedTags, userId);
|
||||||
|
|
||||||
|
return { ...newNote, tags: extractedTags };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNoteInput {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
color?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
archived?: boolean;
|
||||||
|
trashed?: boolean;
|
||||||
|
checklistMode?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
baseVersion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-user fields that go to noteUserState for collaborators */
|
||||||
|
const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const;
|
||||||
|
|
||||||
|
export function updateNote(db: Db, userId: number, id: string, input: UpdateNoteInput) {
|
||||||
|
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||||
|
if (!canAccess) return null;
|
||||||
|
|
||||||
|
const existing = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
// Collaborators cannot trash notes
|
||||||
|
if (!isOwner && input.trashed !== undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Shared fields: update the notes table (all participants can edit content)
|
||||||
|
const sharedUpdates: Record<string, unknown> = {
|
||||||
|
updatedAt: now,
|
||||||
|
version: existing.version + 1
|
||||||
|
};
|
||||||
|
let hasSharedUpdates = false;
|
||||||
|
|
||||||
|
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||||
|
if (input.content !== undefined) {
|
||||||
|
// 3-way merge to preserve concurrent edits from other users.
|
||||||
|
// Only attempt merge when there's a concurrent edit: the client's
|
||||||
|
// baseVersion is behind the current version, meaning someone else
|
||||||
|
// saved between the client's load and this save.
|
||||||
|
const hasConcurrentEdit = input.baseVersion !== undefined
|
||||||
|
&& input.baseVersion < existing.version;
|
||||||
|
|
||||||
|
if (hasConcurrentEdit && input.content !== existing.content) {
|
||||||
|
const baseSnapshot = db.select({ content: noteVersions.content })
|
||||||
|
.from(noteVersions)
|
||||||
|
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion!)))
|
||||||
|
.orderBy(desc(noteVersions.version))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (baseSnapshot && baseSnapshot.content !== existing.content) {
|
||||||
|
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
|
||||||
|
} else {
|
||||||
|
sharedUpdates.content = input.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sharedUpdates.content = input.content;
|
||||||
|
}
|
||||||
|
hasSharedUpdates = true;
|
||||||
|
}
|
||||||
|
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||||
|
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
// Owner: per-user fields go to notes table directly
|
||||||
|
if (input.pinned !== undefined) { sharedUpdates.pinned = input.pinned; hasSharedUpdates = true; }
|
||||||
|
if (input.archived !== undefined) { sharedUpdates.archived = input.archived; hasSharedUpdates = true; }
|
||||||
|
if (input.trashed !== undefined) {
|
||||||
|
sharedUpdates.trashed = input.trashed;
|
||||||
|
sharedUpdates.trashedAt = input.trashed ? now : null;
|
||||||
|
hasSharedUpdates = true;
|
||||||
|
}
|
||||||
|
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSharedUpdates || isOwner) {
|
||||||
|
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collaborator: per-user fields go to noteUserState
|
||||||
|
if (!isOwner) {
|
||||||
|
const hasPerUserFields = PER_USER_FIELDS.some((f) => input[f] !== undefined);
|
||||||
|
if (hasPerUserFields) {
|
||||||
|
const userState = db
|
||||||
|
.select()
|
||||||
|
.from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (userState) {
|
||||||
|
const stateUpdates: Record<string, unknown> = {};
|
||||||
|
if (input.pinned !== undefined) stateUpdates.pinned = input.pinned;
|
||||||
|
if (input.archived !== undefined) stateUpdates.archived = input.archived;
|
||||||
|
if (input.sortOrder !== undefined) stateUpdates.sortOrder = input.sortOrder;
|
||||||
|
db.update(noteUserState)
|
||||||
|
.set(stateUpdates)
|
||||||
|
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({
|
||||||
|
noteId: id,
|
||||||
|
userId,
|
||||||
|
pinned: input.pinned ?? false,
|
||||||
|
archived: input.archived ?? false,
|
||||||
|
sortOrder: input.sortOrder ?? 0
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.title !== undefined || input.content !== undefined) {
|
||||||
|
const updated = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||||
|
if (updated) {
|
||||||
|
const content = `${updated.title} ${updated.content}`;
|
||||||
|
const extractedTags = extractTags(content);
|
||||||
|
syncNoteTags(db, id, extractedTags, existing.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a version snapshot only when at least one content field actually changed
|
||||||
|
const hasActualContentChange =
|
||||||
|
(input.title !== undefined && input.title !== existing.title) ||
|
||||||
|
(input.content !== undefined && input.content !== existing.content) ||
|
||||||
|
(input.color !== undefined && input.color !== existing.color) ||
|
||||||
|
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||||
|
|
||||||
|
if (hasActualContentChange) {
|
||||||
|
createSnapshot(db, id, {
|
||||||
|
version: existing.version,
|
||||||
|
title: existing.title,
|
||||||
|
content: existing.content,
|
||||||
|
checklistMode: existing.checklistMode,
|
||||||
|
color: existing.color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNote(db, userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNote(db: Db, userId: number, id: string): boolean {
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||||
|
.get();
|
||||||
|
if (!existing) return false;
|
||||||
|
|
||||||
|
db.delete(notes)
|
||||||
|
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchNotes(db: Db, userId: number, query: string) {
|
||||||
|
if (!query) return [];
|
||||||
|
|
||||||
|
const pattern = `%${query}%`;
|
||||||
|
|
||||||
|
// Owned notes matching query
|
||||||
|
const ownedResults = db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(notes.userId, userId),
|
||||||
|
eq(notes.trashed, false),
|
||||||
|
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Shared notes matching query
|
||||||
|
const sharedResults = db
|
||||||
|
.select({
|
||||||
|
id: notes.id,
|
||||||
|
userId: notes.userId,
|
||||||
|
title: notes.title,
|
||||||
|
content: notes.content,
|
||||||
|
color: notes.color,
|
||||||
|
pinned: sql<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
|
||||||
|
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
|
||||||
|
trashed: notes.trashed,
|
||||||
|
trashedAt: notes.trashedAt,
|
||||||
|
checklistMode: notes.checklistMode,
|
||||||
|
sortOrder: sql<number>`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'),
|
||||||
|
createdAt: notes.createdAt,
|
||||||
|
updatedAt: notes.updatedAt,
|
||||||
|
version: notes.version
|
||||||
|
})
|
||||||
|
.from(noteCollaborators)
|
||||||
|
.innerJoin(notes, eq(noteCollaborators.noteId, notes.id))
|
||||||
|
.leftJoin(
|
||||||
|
noteUserState,
|
||||||
|
and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId))
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(noteCollaborators.userId, userId),
|
||||||
|
eq(notes.trashed, false),
|
||||||
|
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Tag search for owned notes
|
||||||
|
const tagResults = db
|
||||||
|
.select({ noteId: noteTags.noteId })
|
||||||
|
.from(noteTags)
|
||||||
|
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||||
|
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const resultIds = new Set([...ownedResults.map((n) => n.id), ...sharedResults.map((n) => n.id)]);
|
||||||
|
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
|
||||||
|
(id) => !resultIds.has(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let extraNotes: typeof ownedResults = [];
|
||||||
|
if (extraNoteIds.length > 0) {
|
||||||
|
extraNotes = db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[];
|
||||||
|
return hydrateNotes(db, combined, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllTags(db: Db, userId: number) {
|
||||||
|
return db.select().from(tags).where(eq(tags.userId, userId)).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderNotes(db: Db, userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||||
|
const now = new Date();
|
||||||
|
for (const { id, sortOrder } of orders) {
|
||||||
|
const { canAccess, isOwner } = canAccessNote(db, id, userId);
|
||||||
|
if (!canAccess) continue;
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
db.update(notes)
|
||||||
|
.set({ sortOrder, updatedAt: now })
|
||||||
|
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// Collaborator: upsert into noteUserState
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(noteUserState)
|
||||||
|
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
db.update(noteUserState)
|
||||||
|
.set({ sortOrder })
|
||||||
|
.where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId)))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
db.insert(noteUserState)
|
||||||
|
.values({ noteId: id, userId, pinned: false, archived: false, sortOrder })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { getPreferences, upsertPreferences } from './preferences.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const USER_ID = 1;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb({ seedUser: true });
|
||||||
|
db = testDb.db;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPreferences', () => {
|
||||||
|
it('should return empty object for user with no preferences', () => {
|
||||||
|
const prefs = getPreferences(db, USER_ID);
|
||||||
|
expect(prefs).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upsertPreferences', () => {
|
||||||
|
it('should insert new preferences', () => {
|
||||||
|
const result = upsertPreferences(db, USER_ID, {
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'true'
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'true'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update existing preferences', () => {
|
||||||
|
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||||
|
const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' });
|
||||||
|
expect(result.defaultNoteMode).toBe('richtext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial updates without affecting other keys', () => {
|
||||||
|
upsertPreferences(db, USER_ID, {
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'true'
|
||||||
|
});
|
||||||
|
const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' });
|
||||||
|
expect(result).toEqual({
|
||||||
|
defaultNoteMode: 'markdown',
|
||||||
|
hideFooter: 'false'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep preferences isolated between users', () => {
|
||||||
|
// Create a second user
|
||||||
|
db.run(
|
||||||
|
/* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})`
|
||||||
|
);
|
||||||
|
upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' });
|
||||||
|
upsertPreferences(db, 2, { defaultNoteMode: 'richtext' });
|
||||||
|
|
||||||
|
expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown');
|
||||||
|
expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { userPreferences } from './db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all preferences for a user as a key-value record.
|
||||||
|
*/
|
||||||
|
export function getPreferences(db: Db, userId: number): Record<string, string> {
|
||||||
|
const rows = db
|
||||||
|
.select({ key: userPreferences.key, value: userPreferences.value })
|
||||||
|
.from(userPreferences)
|
||||||
|
.where(eq(userPreferences.userId, userId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.key] = row.value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert multiple preferences for a user.
|
||||||
|
* Uses INSERT OR REPLACE with the unique (user_id, key) index.
|
||||||
|
*/
|
||||||
|
export function upsertPreferences(
|
||||||
|
db: Db,
|
||||||
|
userId: number,
|
||||||
|
prefs: Record<string, string>
|
||||||
|
): Record<string, string> {
|
||||||
|
const now = new Date();
|
||||||
|
for (const [key, value] of Object.entries(prefs)) {
|
||||||
|
db.insert(userPreferences)
|
||||||
|
.values({ userId, key, value, updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [userPreferences.userId, userPreferences.key],
|
||||||
|
set: { value, updatedAt: now }
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
return getPreferences(db, userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { loginAttempts } from './db/schema.js';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { checkRateLimit, recordLoginAttempt, clearOldAttempts } from './rate-limit.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb();
|
||||||
|
db = testDb.db;
|
||||||
|
});
|
||||||
|
|
||||||
|
const IP = '192.168.1.1';
|
||||||
|
const EMAIL = 'test@test.com';
|
||||||
|
|
||||||
|
function addFailedAttempts(count: number, now: Date) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
recordLoginAttempt(db, IP, EMAIL, false, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('checkRateLimit', () => {
|
||||||
|
it('should allow first attempt', () => {
|
||||||
|
const result = checkRateLimit(db, IP, EMAIL);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow up to 4 failed attempts', () => {
|
||||||
|
const now = new Date();
|
||||||
|
addFailedAttempts(4, now);
|
||||||
|
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block after 5 failed attempts within window', () => {
|
||||||
|
const now = new Date();
|
||||||
|
addFailedAttempts(5, now);
|
||||||
|
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.retryAfter).toBeDefined();
|
||||||
|
expect(result.retryAfter!).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct retryAfter value', () => {
|
||||||
|
const now = new Date();
|
||||||
|
addFailedAttempts(5, now);
|
||||||
|
const result = checkRateLimit(db, IP, EMAIL, now);
|
||||||
|
// Lockout is 15 minutes (900 seconds)
|
||||||
|
expect(result.retryAfter).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow again after lockout expires', () => {
|
||||||
|
const attemptTime = new Date('2024-01-01T00:00:00Z');
|
||||||
|
addFailedAttempts(5, attemptTime);
|
||||||
|
|
||||||
|
// Check at attempt time — should be blocked
|
||||||
|
const blocked = checkRateLimit(db, IP, EMAIL, attemptTime);
|
||||||
|
expect(blocked.allowed).toBe(false);
|
||||||
|
|
||||||
|
// Check 16 minutes later — should be allowed
|
||||||
|
const later = new Date(attemptTime.getTime() + 16 * 60 * 1000);
|
||||||
|
const allowed = checkRateLimit(db, IP, EMAIL, later);
|
||||||
|
expect(allowed.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordLoginAttempt', () => {
|
||||||
|
it('should insert attempt record', () => {
|
||||||
|
recordLoginAttempt(db, IP, EMAIL, false);
|
||||||
|
const rows = db.select().from(loginAttempts).all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].ip).toBe(IP);
|
||||||
|
expect(rows[0].email).toBe(EMAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should record success/failure flag correctly', () => {
|
||||||
|
recordLoginAttempt(db, IP, EMAIL, true);
|
||||||
|
recordLoginAttempt(db, IP, EMAIL, false);
|
||||||
|
const rows = db.select().from(loginAttempts).all();
|
||||||
|
expect(rows[0].success).toBe(true);
|
||||||
|
expect(rows[1].success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearOldAttempts', () => {
|
||||||
|
it('should remove attempts older than 2x lockout', () => {
|
||||||
|
const old = new Date('2024-01-01T00:00:00Z');
|
||||||
|
const recent = new Date('2024-01-01T01:00:00Z');
|
||||||
|
recordLoginAttempt(db, IP, EMAIL, false, old);
|
||||||
|
recordLoginAttempt(db, IP, EMAIL, false, recent);
|
||||||
|
|
||||||
|
// Clear at a time where old is > 2x lockout (30 min) ago but recent is not
|
||||||
|
const clearTime = new Date(old.getTime() + 31 * 60 * 1000);
|
||||||
|
clearOldAttempts(db, clearTime);
|
||||||
|
|
||||||
|
const rows = db.select().from(loginAttempts).all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
import { db } from './db/index.js';
|
import type { Db } from './db/index.js';
|
||||||
import { loginAttempts } from './db/schema.js';
|
import { loginAttempts } from './db/schema.js';
|
||||||
import { and, eq, gte, sql } from 'drizzle-orm';
|
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 5;
|
const MAX_ATTEMPTS = 5;
|
||||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||||
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
export function checkRateLimit(
|
export function checkRateLimit(
|
||||||
|
db: Db,
|
||||||
ip: string,
|
ip: string,
|
||||||
email: string
|
email: string,
|
||||||
|
now?: Date
|
||||||
): { allowed: boolean; retryAfter?: number } {
|
): { allowed: boolean; retryAfter?: number } {
|
||||||
// Skip rate limiting in test environment
|
const currentTime = now ?? new Date();
|
||||||
if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') {
|
const windowStart = new Date(currentTime.getTime() - LOCKOUT_MS);
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const windowStart = new Date(Date.now() - LOCKOUT_MS);
|
|
||||||
|
|
||||||
// Count failed attempts from this IP in the lockout window
|
// Count failed attempts from this IP in the lockout window
|
||||||
const result = db
|
const result = db
|
||||||
@@ -50,7 +48,7 @@ export function checkRateLimit(
|
|||||||
|
|
||||||
if (latest) {
|
if (latest) {
|
||||||
const retryAfter = Math.ceil(
|
const retryAfter = Math.ceil(
|
||||||
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
|
(latest.timestamp.getTime() + LOCKOUT_MS - currentTime.getTime()) / 1000
|
||||||
);
|
);
|
||||||
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
|
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
|
||||||
}
|
}
|
||||||
@@ -60,20 +58,20 @@ export function checkRateLimit(
|
|||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
|
export function recordLoginAttempt(db: Db, ip: string, email: string, success: boolean, now?: Date): void {
|
||||||
db.insert(loginAttempts)
|
db.insert(loginAttempts)
|
||||||
.values({
|
.values({
|
||||||
ip,
|
ip,
|
||||||
email,
|
email,
|
||||||
success,
|
success,
|
||||||
timestamp: new Date()
|
timestamp: now ?? new Date()
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearOldAttempts(): void {
|
export function clearOldAttempts(db: Db, now?: Date): void {
|
||||||
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
|
const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2);
|
||||||
db.delete(loginAttempts)
|
db.delete(loginAttempts)
|
||||||
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
|
.where(lt(loginAttempts.timestamp, cutoff))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { notes, users, attachments } from './db/schema.js';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import {
|
||||||
|
fetchSharesForNotes,
|
||||||
|
createShare,
|
||||||
|
revokeShare,
|
||||||
|
getShareByNoteId,
|
||||||
|
getSharedNote
|
||||||
|
} from './shares-service.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
|
||||||
|
const OWNER_ID = 1;
|
||||||
|
|
||||||
|
function seedUsers() {
|
||||||
|
db.insert(users)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
email: 'owner@test.com',
|
||||||
|
displayName: 'Owner',
|
||||||
|
role: 'admin',
|
||||||
|
authProvider: 'password',
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNote(
|
||||||
|
id: string,
|
||||||
|
overrides: Partial<typeof notes.$inferInsert> = {}
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
db.insert(notes)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
userId: OWNER_ID,
|
||||||
|
title: 'Test Note',
|
||||||
|
content: 'Test content',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const result = createTestDb();
|
||||||
|
db = result.db;
|
||||||
|
seedUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createShare', () => {
|
||||||
|
it('creates a share and returns a token', () => {
|
||||||
|
seedNote('note-1');
|
||||||
|
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
expect(token).toBeDefined();
|
||||||
|
expect(token.length).toBe(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same token on duplicate creation (idempotent)', () => {
|
||||||
|
seedNote('note-1');
|
||||||
|
const first = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
const second = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
expect(first.token).toBe(second.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 404 for non-existent note', () => {
|
||||||
|
expect(() => createShare(db, 'nonexistent', OWNER_ID)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeShare', () => {
|
||||||
|
it('removes the share token', () => {
|
||||||
|
seedNote('note-1');
|
||||||
|
createShare(db, 'note-1', OWNER_ID);
|
||||||
|
revokeShare(db, 'note-1', OWNER_ID);
|
||||||
|
const share = getShareByNoteId(db, 'note-1');
|
||||||
|
expect(share).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getShareByNoteId', () => {
|
||||||
|
it('returns null when no share exists', () => {
|
||||||
|
seedNote('note-1');
|
||||||
|
expect(getShareByNoteId(db, 'note-1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns share when one exists', () => {
|
||||||
|
seedNote('note-1');
|
||||||
|
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
const share = getShareByNoteId(db, 'note-1');
|
||||||
|
expect(share).not.toBeNull();
|
||||||
|
expect(share!.token).toBe(token);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSharedNote', () => {
|
||||||
|
it('returns note data for valid token', () => {
|
||||||
|
seedNote('note-1', { title: 'Shared Title', content: 'Shared content' });
|
||||||
|
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
|
||||||
|
const data = getSharedNote(db, token);
|
||||||
|
expect(data).not.toBeNull();
|
||||||
|
expect(data!.title).toBe('Shared Title');
|
||||||
|
expect(data!.content).toBe('Shared content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for trashed note', () => {
|
||||||
|
seedNote('note-1', { trashed: true });
|
||||||
|
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
|
||||||
|
expect(getSharedNote(db, token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid token', () => {
|
||||||
|
expect(getSharedNote(db, 'invalid-token')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes attachments', () => {
|
||||||
|
seedNote('note-1');
|
||||||
|
db.insert(attachments)
|
||||||
|
.values({
|
||||||
|
id: 'att-1',
|
||||||
|
userId: OWNER_ID,
|
||||||
|
noteId: 'note-1',
|
||||||
|
filename: 'image.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
size: 1024,
|
||||||
|
path: '/data/attachments/att-1.png',
|
||||||
|
createdAt: new Date()
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const { token } = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
|
||||||
|
const data = getSharedNote(db, token);
|
||||||
|
expect(data!.attachments).toHaveLength(1);
|
||||||
|
expect(data!.attachments[0].filename).toBe('image.png');
|
||||||
|
// Verify server paths are not exposed
|
||||||
|
expect(data!.attachments[0]).not.toHaveProperty('path');
|
||||||
|
expect(data!.attachments[0]).not.toHaveProperty('thumbnailPath');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchSharesForNotes', () => {
|
||||||
|
it('returns empty map for no notes', () => {
|
||||||
|
const result = fetchSharesForNotes(db, []);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns tokens for shared notes', () => {
|
||||||
|
seedNote('note-1');
|
||||||
|
seedNote('note-2');
|
||||||
|
seedNote('note-3');
|
||||||
|
const { token: token1 } = createShare(db, 'note-1', OWNER_ID);
|
||||||
|
const { token: token2 } = createShare(db, 'note-2', OWNER_ID);
|
||||||
|
|
||||||
|
const result = fetchSharesForNotes(db, ['note-1', 'note-2', 'note-3']);
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
expect(result.get('note-1')).toBe(token1);
|
||||||
|
expect(result.get('note-2')).toBe(token2);
|
||||||
|
expect(result.get('note-3')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { sharedNotes, notes, attachments } from './db/schema.js';
|
||||||
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { requireNoteOwnership } from './api-utils.js';
|
||||||
|
import type { SharedNoteData } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch fetch share tokens for multiple notes.
|
||||||
|
* Returns a Map of noteId → token.
|
||||||
|
*/
|
||||||
|
export function fetchSharesForNotes(db: Db, noteIds: string[]): Map<string, string> {
|
||||||
|
if (noteIds.length === 0) return new Map();
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.select({ noteId: sharedNotes.noteId, token: sharedNotes.token })
|
||||||
|
.from(sharedNotes)
|
||||||
|
.where(inArray(sharedNotes.noteId, noteIds))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
map.set(row.noteId, row.token);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a public share link for a note. Idempotent — returns existing token if already shared.
|
||||||
|
*/
|
||||||
|
export function createShare(db: Db, noteId: string, userId: number): { token: string } {
|
||||||
|
requireNoteOwnership(db, noteId, userId);
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.select({ token: sharedNotes.token })
|
||||||
|
.from(sharedNotes)
|
||||||
|
.where(eq(sharedNotes.noteId, noteId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) return { token: existing.token };
|
||||||
|
|
||||||
|
const token = nanoid(21);
|
||||||
|
db.insert(sharedNotes)
|
||||||
|
.values({ noteId, token, createdAt: new Date() })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a public share link for a note.
|
||||||
|
*/
|
||||||
|
export function revokeShare(db: Db, noteId: string, userId: number): void {
|
||||||
|
requireNoteOwnership(db, noteId, userId);
|
||||||
|
|
||||||
|
db.delete(sharedNotes)
|
||||||
|
.where(eq(sharedNotes.noteId, noteId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get share info for a specific note.
|
||||||
|
*/
|
||||||
|
export function getShareByNoteId(db: Db, noteId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(sharedNotes)
|
||||||
|
.where(eq(sharedNotes.noteId, noteId))
|
||||||
|
.get() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a shared note by its public token. Returns null if note is trashed or token is invalid.
|
||||||
|
*/
|
||||||
|
export function getSharedNote(db: Db, token: string): (SharedNoteData & { noteId: string }) | null {
|
||||||
|
const share = db
|
||||||
|
.select()
|
||||||
|
.from(sharedNotes)
|
||||||
|
.where(eq(sharedNotes.token, token))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!share) return null;
|
||||||
|
|
||||||
|
const note = db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(and(eq(notes.id, share.noteId), eq(notes.trashed, false)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
const noteAttachments = db
|
||||||
|
.select()
|
||||||
|
.from(attachments)
|
||||||
|
.where(eq(attachments.noteId, share.noteId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const publicAttachments = noteAttachments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
size: a.size,
|
||||||
|
featured: a.featured,
|
||||||
|
createdAt: a.createdAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
noteId: note.id,
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
checklistMode: note.checklistMode,
|
||||||
|
color: note.color as SharedNoteData['color'],
|
||||||
|
attachments: publicAttachments,
|
||||||
|
createdAt: note.createdAt,
|
||||||
|
updatedAt: note.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { notes, tags, noteTags, users } from './db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
const USER_ID = 1;
|
||||||
|
|
||||||
|
function seedUser() {
|
||||||
|
db.insert(users)
|
||||||
|
.values({ email: 'test@test.com', displayName: 'Test', role: 'admin', authProvider: 'password', createdAt: new Date() })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNote(id: string) {
|
||||||
|
db.insert(notes)
|
||||||
|
.values({ id, userId: USER_ID, title: '', content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const testDb = createTestDb();
|
||||||
|
db = testDb.db;
|
||||||
|
seedUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncNoteTags', () => {
|
||||||
|
it('should create new tags and associations', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
syncNoteTags(db, 'n1', ['todo', 'urgent'], USER_ID);
|
||||||
|
|
||||||
|
const tagRows = db.select().from(tags).all();
|
||||||
|
expect(tagRows).toHaveLength(2);
|
||||||
|
|
||||||
|
const associations = db.select().from(noteTags).all();
|
||||||
|
expect(associations).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove old associations and add new ones', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
syncNoteTags(db, 'n1', ['old-tag'], USER_ID);
|
||||||
|
syncNoteTags(db, 'n1', ['new-tag'], USER_ID);
|
||||||
|
|
||||||
|
const map = fetchTagsForNotes(db, ['n1']);
|
||||||
|
expect(map.get('n1')).toEqual(['new-tag']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty tag list (removes all)', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
syncNoteTags(db, 'n1', ['tag1', 'tag2'], USER_ID);
|
||||||
|
syncNoteTags(db, 'n1', [], USER_ID);
|
||||||
|
|
||||||
|
const associations = db.select().from(noteTags).where(eq(noteTags.noteId, 'n1')).all();
|
||||||
|
expect(associations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate existing tags', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
seedNote('n2');
|
||||||
|
syncNoteTags(db, 'n1', ['shared-tag'], USER_ID);
|
||||||
|
syncNoteTags(db, 'n2', ['shared-tag'], USER_ID);
|
||||||
|
|
||||||
|
// Should reuse the same tag row
|
||||||
|
const tagRows = db.select().from(tags).all();
|
||||||
|
expect(tagRows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchTagsForNotes', () => {
|
||||||
|
it('should return Map of noteId to tag names', () => {
|
||||||
|
seedNote('n1');
|
||||||
|
seedNote('n2');
|
||||||
|
syncNoteTags(db, 'n1', ['alpha', 'beta'], USER_ID);
|
||||||
|
syncNoteTags(db, 'n2', ['gamma'], USER_ID);
|
||||||
|
|
||||||
|
const map = fetchTagsForNotes(db, ['n1', 'n2']);
|
||||||
|
expect(map.get('n1')).toHaveLength(2);
|
||||||
|
expect(map.get('n1')).toContain('alpha');
|
||||||
|
expect(map.get('n1')).toContain('beta');
|
||||||
|
expect(map.get('n2')).toEqual(['gamma']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty map for empty noteIds', () => {
|
||||||
|
const map = fetchTagsForNotes(db, []);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-6
@@ -1,12 +1,12 @@
|
|||||||
import { db } from './db/index.js';
|
import type { Db } from './db/index.js';
|
||||||
import { noteTags, tags } from './db/schema.js';
|
import { noteTags, tags } from './db/schema.js';
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch fetch tags for multiple notes in a single query.
|
* Batch fetch tags for multiple notes in a single query.
|
||||||
* Returns a Map of noteId → tag names.
|
* Returns a Map of noteId → tag names.
|
||||||
*/
|
*/
|
||||||
export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
export function fetchTagsForNotes(db: Db, noteIds: string[]): Map<string, string[]> {
|
||||||
if (noteIds.length === 0) return new Map();
|
if (noteIds.length === 0) return new Map();
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
@@ -32,13 +32,17 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
|||||||
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
|
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
|
||||||
* Should be called within a transaction for atomicity.
|
* Should be called within a transaction for atomicity.
|
||||||
*/
|
*/
|
||||||
export function syncNoteTags(noteId: string, tagNames: string[]) {
|
export function syncNoteTags(db: Db, noteId: string, tagNames: string[], userId: number) {
|
||||||
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
|
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
|
||||||
|
|
||||||
for (const name of tagNames) {
|
for (const name of tagNames) {
|
||||||
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get();
|
let tagRow = db
|
||||||
|
.select()
|
||||||
|
.from(tags)
|
||||||
|
.where(and(eq(tags.name, name), eq(tags.userId, userId)))
|
||||||
|
.get();
|
||||||
if (!tagRow) {
|
if (!tagRow) {
|
||||||
const result = db.insert(tags).values({ name }).returning().all();
|
const result = db.insert(tags).values({ name, userId }).returning().all();
|
||||||
tagRow = result[0];
|
tagRow = result[0];
|
||||||
}
|
}
|
||||||
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
|
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createTestDb } from './db/test-helpers.js';
|
||||||
|
import { users, notes, noteVersions } from './db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
|
||||||
|
const OWNER_ID = 1;
|
||||||
|
const NOTE_ID = 'note-history-test';
|
||||||
|
|
||||||
|
function seedUser() {
|
||||||
|
db.insert(users)
|
||||||
|
.values({
|
||||||
|
id: OWNER_ID,
|
||||||
|
email: 'owner@test.com',
|
||||||
|
displayName: 'Owner',
|
||||||
|
role: 'admin',
|
||||||
|
authProvider: 'password',
|
||||||
|
createdAt: new Date()
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedNote(overrides: Partial<typeof notes.$inferInsert> = {}) {
|
||||||
|
const now = new Date();
|
||||||
|
db.insert(notes)
|
||||||
|
.values({
|
||||||
|
id: NOTE_ID,
|
||||||
|
userId: OWNER_ID,
|
||||||
|
title: 'Test Note',
|
||||||
|
content: 'Test content',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
version: 1,
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
seedUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSnapshot', () => {
|
||||||
|
it('should store a snapshot for a note', () => {
|
||||||
|
seedNote();
|
||||||
|
createSnapshot(db, NOTE_ID, {
|
||||||
|
version: 1,
|
||||||
|
title: 'Test Note',
|
||||||
|
content: 'Test content',
|
||||||
|
checklistMode: false,
|
||||||
|
color: 'default'
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].version).toBe(1);
|
||||||
|
expect(rows[0].title).toBe('Test Note');
|
||||||
|
expect(rows[0].content).toBe('Test content');
|
||||||
|
expect(rows[0].checklistMode).toBe(false);
|
||||||
|
expect(rows[0].color).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not duplicate snapshots for the same version (onConflictDoNothing)', () => {
|
||||||
|
seedNote();
|
||||||
|
createSnapshot(db, NOTE_ID, { version: 1, title: 'A', content: '', checklistMode: false, color: 'default' });
|
||||||
|
createSnapshot(db, NOTE_ID, { version: 1, title: 'B', content: '', checklistMode: false, color: 'default' });
|
||||||
|
|
||||||
|
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].title).toBe('A'); // first one wins
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prune to at most 50 snapshots', () => {
|
||||||
|
seedNote();
|
||||||
|
for (let i = 1; i <= 55; i++) {
|
||||||
|
createSnapshot(db, NOTE_ID, {
|
||||||
|
version: i,
|
||||||
|
title: `Version ${i}`,
|
||||||
|
content: '',
|
||||||
|
checklistMode: false,
|
||||||
|
color: 'default'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||||
|
expect(rows).toHaveLength(50);
|
||||||
|
// Oldest (v1–v5) should be pruned; newest (v6–v55) should remain
|
||||||
|
const versions = rows.map((r) => r.version);
|
||||||
|
expect(Math.min(...versions)).toBe(6);
|
||||||
|
expect(Math.max(...versions)).toBe(55);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listVersions', () => {
|
||||||
|
it('should return an empty array when there are no versions', () => {
|
||||||
|
seedNote();
|
||||||
|
expect(listVersions(db, NOTE_ID)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return summaries sorted newest first', () => {
|
||||||
|
seedNote();
|
||||||
|
createSnapshot(db, NOTE_ID, { version: 1, title: 'First', content: 'Hello world', checklistMode: false, color: 'default' });
|
||||||
|
createSnapshot(db, NOTE_ID, { version: 2, title: 'Second', content: 'Updated content here', checklistMode: false, color: 'mint' });
|
||||||
|
|
||||||
|
const summaries = listVersions(db, NOTE_ID);
|
||||||
|
expect(summaries).toHaveLength(2);
|
||||||
|
expect(summaries[0].version).toBe(2); // newest first
|
||||||
|
expect(summaries[1].version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate contentPreview to 80 characters', () => {
|
||||||
|
seedNote();
|
||||||
|
const longContent = 'a'.repeat(200);
|
||||||
|
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: longContent, checklistMode: false, color: 'default' });
|
||||||
|
|
||||||
|
const summaries = listVersions(db, NOTE_ID);
|
||||||
|
expect(summaries[0].contentPreview).toHaveLength(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include correct summary fields', () => {
|
||||||
|
seedNote();
|
||||||
|
createSnapshot(db, NOTE_ID, { version: 3, title: 'My Title', content: 'My content', checklistMode: false, color: 'coral' });
|
||||||
|
|
||||||
|
const summaries = listVersions(db, NOTE_ID);
|
||||||
|
expect(summaries[0]).toMatchObject({
|
||||||
|
noteId: NOTE_ID,
|
||||||
|
version: 3,
|
||||||
|
title: 'My Title',
|
||||||
|
contentPreview: 'My content'
|
||||||
|
});
|
||||||
|
expect(summaries[0].id).toBeTruthy();
|
||||||
|
expect(summaries[0].createdAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVersion', () => {
|
||||||
|
it('should return null for a non-existent version', () => {
|
||||||
|
seedNote();
|
||||||
|
expect(getVersion(db, NOTE_ID, 'non-existent-id')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the full version content', () => {
|
||||||
|
seedNote();
|
||||||
|
createSnapshot(db, NOTE_ID, {
|
||||||
|
version: 2,
|
||||||
|
title: 'Full Title',
|
||||||
|
content: '# Heading\n\nSome content',
|
||||||
|
checklistMode: true,
|
||||||
|
color: 'mint'
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaries = listVersions(db, NOTE_ID);
|
||||||
|
const versionId = summaries[0].id;
|
||||||
|
|
||||||
|
const version = getVersion(db, NOTE_ID, versionId);
|
||||||
|
expect(version).not.toBeNull();
|
||||||
|
expect(version!.version).toBe(2);
|
||||||
|
expect(version!.title).toBe('Full Title');
|
||||||
|
expect(version!.content).toBe('# Heading\n\nSome content');
|
||||||
|
expect(version!.checklistMode).toBe(true);
|
||||||
|
expect(version!.color).toBe('mint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return a version from a different note', () => {
|
||||||
|
const OTHER_NOTE_ID = 'other-note';
|
||||||
|
seedNote();
|
||||||
|
db.insert(notes).values({
|
||||||
|
id: OTHER_NOTE_ID,
|
||||||
|
userId: OWNER_ID,
|
||||||
|
title: 'Other',
|
||||||
|
content: '',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
version: 1
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
createSnapshot(db, OTHER_NOTE_ID, { version: 1, title: 'Other', content: 'Other content', checklistMode: false, color: 'default' });
|
||||||
|
const summaries = listVersions(db, OTHER_NOTE_ID);
|
||||||
|
const versionId = summaries[0].id;
|
||||||
|
|
||||||
|
// Requesting version from wrong noteId returns null
|
||||||
|
expect(getVersion(db, NOTE_ID, versionId)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snapshotCurrentNote', () => {
|
||||||
|
it('should return false for a non-existent note', () => {
|
||||||
|
expect(snapshotCurrentNote(db, 'non-existent')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should snapshot the current note state', () => {
|
||||||
|
seedNote({ title: 'Current Title', content: 'Current content', version: 5 });
|
||||||
|
|
||||||
|
const result = snapshotCurrentNote(db, NOTE_ID);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const rows = db.select().from(noteVersions).where(eq(noteVersions.noteId, NOTE_ID)).all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].version).toBe(5);
|
||||||
|
expect(rows[0].title).toBe('Current Title');
|
||||||
|
expect(rows[0].content).toBe('Current content');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { noteVersions, notes } from './db/schema.js';
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
const MAX_VERSIONS_PER_NOTE = 50;
|
||||||
|
const CONTENT_PREVIEW_LENGTH = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a snapshot of a note's current state.
|
||||||
|
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||||
|
*/
|
||||||
|
export function createSnapshot(
|
||||||
|
db: Db,
|
||||||
|
noteId: string,
|
||||||
|
snapshot: {
|
||||||
|
version: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
checklistMode: boolean;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
db.insert(noteVersions)
|
||||||
|
.values({
|
||||||
|
id: uuidv4(),
|
||||||
|
noteId,
|
||||||
|
version: snapshot.version,
|
||||||
|
title: snapshot.title,
|
||||||
|
content: snapshot.content,
|
||||||
|
checklistMode: snapshot.checklistMode,
|
||||||
|
color: snapshot.color,
|
||||||
|
createdAt: now
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Prune: keep only the most recent MAX_VERSIONS_PER_NOTE snapshots
|
||||||
|
const allVersions = db
|
||||||
|
.select({ id: noteVersions.id })
|
||||||
|
.from(noteVersions)
|
||||||
|
.where(eq(noteVersions.noteId, noteId))
|
||||||
|
.orderBy(desc(noteVersions.version))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (allVersions.length > MAX_VERSIONS_PER_NOTE) {
|
||||||
|
const toDelete = allVersions.slice(MAX_VERSIONS_PER_NOTE).map((v) => v.id);
|
||||||
|
for (const id of toDelete) {
|
||||||
|
db.delete(noteVersions).where(eq(noteVersions.id, id)).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List version summaries for a note, newest first.
|
||||||
|
*/
|
||||||
|
export function listVersions(db: Db, noteId: string): NoteVersionSummary[] {
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(noteVersions)
|
||||||
|
.where(eq(noteVersions.noteId, noteId))
|
||||||
|
.orderBy(desc(noteVersions.version))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
noteId: row.noteId,
|
||||||
|
version: row.version,
|
||||||
|
title: row.title,
|
||||||
|
contentPreview: row.content.slice(0, CONTENT_PREVIEW_LENGTH),
|
||||||
|
createdAt: row.createdAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full content of a specific version.
|
||||||
|
*/
|
||||||
|
export function getVersion(db: Db, noteId: string, versionId: string): NoteVersion | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(noteVersions)
|
||||||
|
.where(and(eq(noteVersions.id, versionId), eq(noteVersions.noteId, noteId)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
noteId: row.noteId,
|
||||||
|
version: row.version,
|
||||||
|
title: row.title,
|
||||||
|
content: row.content,
|
||||||
|
checklistMode: row.checklistMode,
|
||||||
|
color: row.color as NoteColor,
|
||||||
|
createdAt: row.createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the current state of a note (used before a restore).
|
||||||
|
*/
|
||||||
|
export function snapshotCurrentNote(db: Db, noteId: string): boolean {
|
||||||
|
const currentNote = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||||||
|
if (!currentNote) return false;
|
||||||
|
|
||||||
|
createSnapshot(db, noteId, {
|
||||||
|
version: currentNote.version,
|
||||||
|
title: currentNote.title,
|
||||||
|
content: currentNote.content,
|
||||||
|
checklistMode: currentNote.checklistMode,
|
||||||
|
color: currentNote.color
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mergeNotesByVersion } from './notes.js';
|
||||||
|
import type { Note } from '$lib/types/index.js';
|
||||||
|
|
||||||
|
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||||
|
return {
|
||||||
|
id: 'note-1',
|
||||||
|
title: 'Test',
|
||||||
|
content: '- [ ] Item',
|
||||||
|
color: 'default',
|
||||||
|
pinned: false,
|
||||||
|
archived: false,
|
||||||
|
trashed: false,
|
||||||
|
trashedAt: null,
|
||||||
|
checklistMode: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
version: 1,
|
||||||
|
tags: [],
|
||||||
|
attachments: [],
|
||||||
|
collaborators: [],
|
||||||
|
isOwner: true,
|
||||||
|
isShared: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mergeNotesByVersion', () => {
|
||||||
|
it('keeps the local note when it has a higher version than the incoming note', () => {
|
||||||
|
const local = makeNote({ id: 'n1', version: 3, content: '- [x] Checked' });
|
||||||
|
const incoming = makeNote({ id: 'n1', version: 2, content: '- [ ] Unchecked' });
|
||||||
|
|
||||||
|
const result = mergeNotesByVersion([local], [incoming]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].content).toBe('- [x] Checked');
|
||||||
|
expect(result[0].version).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the incoming note when it has a higher or equal version', () => {
|
||||||
|
const local = makeNote({ id: 'n1', version: 2, content: '- [ ] Old' });
|
||||||
|
const incoming = makeNote({ id: 'n1', version: 3, content: '- [x] New' });
|
||||||
|
|
||||||
|
const result = mergeNotesByVersion([local], [incoming]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].content).toBe('- [x] New');
|
||||||
|
expect(result[0].version).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the incoming note when versions are equal', () => {
|
||||||
|
const local = makeNote({ id: 'n1', version: 2, content: 'local' });
|
||||||
|
const incoming = makeNote({ id: 'n1', version: 2, content: 'incoming' });
|
||||||
|
|
||||||
|
const result = mergeNotesByVersion([local], [incoming]);
|
||||||
|
|
||||||
|
expect(result[0].content).toBe('incoming');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles notes not present in the local store', () => {
|
||||||
|
const incoming = makeNote({ id: 'n2', version: 1 });
|
||||||
|
|
||||||
|
const result = mergeNotesByVersion([], [incoming]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('n2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves local-only notes not present in the incoming data', () => {
|
||||||
|
const localOnly = makeNote({ id: 'n1', version: 1, content: 'just created' });
|
||||||
|
const serverNote = makeNote({ id: 'n2', version: 1, content: 'from server' });
|
||||||
|
|
||||||
|
const result = mergeNotesByVersion([localOnly], [serverNote]);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.find((n) => n.id === 'n1')?.content).toBe('just created');
|
||||||
|
expect(result.find((n) => n.id === 'n2')?.content).toBe('from server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves multiple notes correctly during a race condition', () => {
|
||||||
|
const localNotes = [
|
||||||
|
makeNote({ id: 'n1', version: 5, content: '- [x] Just saved' }),
|
||||||
|
makeNote({ id: 'n2', version: 1, content: 'unchanged' })
|
||||||
|
];
|
||||||
|
const incomingNotes = [
|
||||||
|
makeNote({ id: 'n1', version: 4, content: '- [ ] Stale from server' }),
|
||||||
|
makeNote({ id: 'n2', version: 2, content: 'updated on server' })
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = mergeNotesByVersion(localNotes, incomingNotes);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
// n1: local version 5 > incoming version 4 → keep local
|
||||||
|
expect(result[0].content).toBe('- [x] Just saved');
|
||||||
|
// n2: local version 1 < incoming version 2 → use incoming
|
||||||
|
expect(result[1].content).toBe('updated on server');
|
||||||
|
});
|
||||||
|
});
|
||||||
+58
-20
@@ -3,7 +3,6 @@ import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.
|
|||||||
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
||||||
import { showToast } from '$lib/stores/toast.js';
|
import { showToast } from '$lib/stores/toast.js';
|
||||||
import { extractTags } from '$lib/utils/tags.js';
|
import { extractTags } from '$lib/utils/tags.js';
|
||||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
|
||||||
|
|
||||||
export const notes = writable<Note[]>([]);
|
export const notes = writable<Note[]>([]);
|
||||||
export const currentFilter = writable<NoteFilter>('all');
|
export const currentFilter = writable<NoteFilter>('all');
|
||||||
@@ -28,32 +27,25 @@ export const filteredNotes = derived(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function sortNotes(list: Note[], mode: SortMode): Note[] {
|
function sortNotes(list: Note[]): Note[] {
|
||||||
return [...list].sort((a, b) => {
|
return [...list].sort((a, b) => {
|
||||||
if (mode === 'created') {
|
const orderDiff = a.sortOrder - b.sortOrder;
|
||||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
if (orderDiff !== 0) return orderDiff;
|
||||||
}
|
|
||||||
if (mode === 'custom') {
|
|
||||||
return a.sortOrder - b.sortOrder;
|
|
||||||
}
|
|
||||||
// default: updated
|
|
||||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||||
sortNotes($notes.filter((n) => n.pinned), $sortMode)
|
sortNotes($notes.filter((n) => n.pinned))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||||
sortNotes($notes.filter((n) => !n.pinned), $sortMode)
|
sortNotes($notes.filter((n) => !n.pinned))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const allTags = derived(notes, ($notes) => {
|
export const allTags = derived(notes, ($notes) => {
|
||||||
const tagSet = new Set<string>();
|
const tagSet = new Set<string>();
|
||||||
$notes
|
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||||
.filter((n) => !n.archived && !n.trashed)
|
|
||||||
.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
|
||||||
return [...tagSet].sort();
|
return [...tagSet].sort();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,12 +53,35 @@ function isNetworkError(err: unknown): boolean {
|
|||||||
return err instanceof TypeError;
|
return err instanceof TypeError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge incoming notes with current store, keeping the higher-version note
|
||||||
|
* when a conflict exists. This prevents a stale loadNotes response from
|
||||||
|
* overwriting a just-saved note (race between sync interval and updateNote).
|
||||||
|
*/
|
||||||
|
export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] {
|
||||||
|
const currentMap = new Map(current.map((n) => [n.id, n]));
|
||||||
|
const incomingIds = new Set<string>();
|
||||||
|
const merged = incoming.map((incomingNote) => {
|
||||||
|
incomingIds.add(incomingNote.id);
|
||||||
|
const local = currentMap.get(incomingNote.id);
|
||||||
|
if (local && local.version > incomingNote.version) return local;
|
||||||
|
return incomingNote;
|
||||||
|
});
|
||||||
|
// Preserve local-only notes (e.g. just created, not yet in server/IDB response)
|
||||||
|
for (const local of current) {
|
||||||
|
if (!incomingIds.has(local.id)) {
|
||||||
|
merged.push(local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||||
// Load from IDB first for instant display
|
// Load from IDB first for instant display
|
||||||
try {
|
try {
|
||||||
const cached = await getAllNotes();
|
const cached = await getAllNotes();
|
||||||
if (cached.length > 0) {
|
if (cached.length > 0) {
|
||||||
notes.set(cached);
|
notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// IDB unavailable — continue with server fetch
|
// IDB unavailable — continue with server fetch
|
||||||
@@ -79,7 +94,7 @@ export async function loadNotes(filter: NoteFilter = 'all') {
|
|||||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: Note[] = await res.json();
|
const data: Note[] = await res.json();
|
||||||
notes.set(data);
|
notes.update((current) => mergeNotesByVersion(current, data));
|
||||||
currentFilter.set(filter);
|
currentFilter.set(filter);
|
||||||
|
|
||||||
// Update IDB with server state
|
// Update IDB with server state
|
||||||
@@ -150,11 +165,19 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
||||||
|
// Read current version for 3-way merge base on the server
|
||||||
|
let baseVersion: number | undefined;
|
||||||
|
notes.update((list) => {
|
||||||
|
const current = list.find((n) => n.id === id);
|
||||||
|
if (current) baseVersion = current.version;
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/notes/${id}`, {
|
const res = await fetch(`/api/notes/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(updates)
|
body: JSON.stringify({ ...updates, baseVersion })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated: Note = await res.json();
|
const updated: Note = await res.json();
|
||||||
@@ -181,7 +204,7 @@ export async function updateNote(id: string, updates: NoteUpdate): Promise<Note
|
|||||||
);
|
);
|
||||||
if (optimistic) {
|
if (optimistic) {
|
||||||
await putNote(optimistic);
|
await putNote(optimistic);
|
||||||
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
|
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now(), baseVersion });
|
||||||
showToast('Saved offline — will sync when reconnected', 'info');
|
showToast('Saved offline — will sync when reconnected', 'info');
|
||||||
}
|
}
|
||||||
return optimistic;
|
return optimistic;
|
||||||
@@ -235,6 +258,21 @@ export async function togglePin(id: string, currentPinned: boolean): Promise<Not
|
|||||||
return updateNote(id, { pinned: !currentPinned });
|
return updateNote(id, { pinned: !currentPinned });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function leaveNote(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
notes.update((list) => list.filter((n) => n.id !== id));
|
||||||
|
await deleteNoteFromIdb(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
showToast('Failed to leave note', 'error');
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
notes.update((list) =>
|
notes.update((list) =>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||||
|
import type { UserPreferences } from '$lib/types/preferences.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'crumbs-preferences';
|
||||||
|
|
||||||
|
let preferences = $state<UserPreferences>({ ...DEFAULT_PREFERENCES });
|
||||||
|
|
||||||
|
function loadFromLocalStorage(): UserPreferences {
|
||||||
|
if (!browser) return { ...DEFAULT_PREFERENCES };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return { ...DEFAULT_PREFERENCES, ...parsed };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupted localStorage — use defaults
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_PREFERENCES };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToLocalStorage(prefs: UserPreferences) {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
// localStorage full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert server key-value record to typed UserPreferences */
|
||||||
|
function fromServerRecord(record: Record<string, string>): Partial<UserPreferences> {
|
||||||
|
const result: Partial<UserPreferences> = {};
|
||||||
|
for (const [key, value] of Object.entries(record)) {
|
||||||
|
if (key in DEFAULT_PREFERENCES) {
|
||||||
|
const k = key as keyof UserPreferences;
|
||||||
|
if (BOOLEAN_PREF_KEYS.has(k)) {
|
||||||
|
(result as Record<string, unknown>)[k] = value === 'true';
|
||||||
|
} else {
|
||||||
|
(result as Record<string, unknown>)[k] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert UserPreferences to server key-value record */
|
||||||
|
function toServerRecord(prefs: Partial<UserPreferences>): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(prefs)) {
|
||||||
|
result[key] = String(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize preferences — load from localStorage, then sync from server.
|
||||||
|
*/
|
||||||
|
export function initPreferences() {
|
||||||
|
preferences = loadFromLocalStorage();
|
||||||
|
syncPreferencesFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the reactive preferences object.
|
||||||
|
*/
|
||||||
|
export function getPreferences(): UserPreferences {
|
||||||
|
return preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single preference. Saves to localStorage and pushes to server.
|
||||||
|
*/
|
||||||
|
export function updatePreference<K extends keyof UserPreferences>(
|
||||||
|
key: K,
|
||||||
|
value: UserPreferences[K]
|
||||||
|
) {
|
||||||
|
preferences = { ...preferences, [key]: value };
|
||||||
|
saveToLocalStorage(preferences);
|
||||||
|
pushToServer({ [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull preferences from server and merge into local state.
|
||||||
|
*/
|
||||||
|
export async function syncPreferencesFromServer() {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/preferences');
|
||||||
|
if (res.ok) {
|
||||||
|
const serverPrefs: Record<string, string> = await res.json();
|
||||||
|
const parsed = fromServerRecord(serverPrefs);
|
||||||
|
preferences = { ...DEFAULT_PREFERENCES, ...parsed };
|
||||||
|
saveToLocalStorage(preferences);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Offline — local state stands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push preference changes to server */
|
||||||
|
async function pushToServer(partial: Partial<UserPreferences>) {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(toServerRecord(partial))
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Offline — localStorage already updated
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js';
|
||||||
|
|
||||||
|
describe('preferences defaults', () => {
|
||||||
|
it('should have correct default values', () => {
|
||||||
|
expect(DEFAULT_PREFERENCES).toEqual({
|
||||||
|
theme: 'system',
|
||||||
|
defaultNoteMode: 'richtext',
|
||||||
|
defaultNoteColor: 'default',
|
||||||
|
hideFooter: false,
|
||||||
|
sidebarDefaultState: 'open',
|
||||||
|
notifyOnShare: true,
|
||||||
|
notifyOnCollabRemoved: true,
|
||||||
|
notifyOnNoteDeleted: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should identify boolean preference keys', () => {
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('notifyOnCollabRemoved')).toBe(true);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('notifyOnNoteDeleted')).toBe(true);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
|
||||||
|
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user