forked from mirror/crumbs-original
Compare commits
522
Commits
+76
-18
@@ -48,13 +48,20 @@ jobs:
|
||||
needs: bump
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: [bump, ci]
|
||||
permissions:
|
||||
contents: write
|
||||
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:
|
||||
@@ -65,13 +72,6 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -83,16 +83,74 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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:
|
||||
contents: write
|
||||
packages: write
|
||||
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
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -103,7 +161,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, release]
|
||||
needs: [bump, merge]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'claude/**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
@@ -19,8 +19,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -37,8 +35,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -63,13 +59,11 @@ jobs:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -121,5 +115,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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,10 +13,18 @@ jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -25,13 +33,6 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -43,16 +44,71 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -62,7 +118,7 @@ jobs:
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
needs: merge
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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
|
||||
|
||||
- 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
|
||||
@@ -44,4 +44,7 @@ todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Superpowers brainstorm artifacts
|
||||
.superpowers/
|
||||
package-lock.json
|
||||
|
||||
@@ -37,9 +37,41 @@ All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colo
|
||||
| `--success-bg/text` | warm greens | Success states |
|
||||
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
|
||||
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
|
||||
| `--accent-check` | `var(--primary)` (light) / `#c8a44a` (dark) | Checkbox accent color — dimmed gold in dark mode so a long done-list doesn't shout |
|
||||
| `--hover-wash` | `var(--border)` (light) / `#ece3d3` (dark) | Tint source for hover/active washes (`bg-[var(--hover-wash)]/N`) — kept cream in dark mode since `--border`'s mid-grey is invisible at low opacity |
|
||||
|
||||
Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
|
||||
### Dark theme
|
||||
|
||||
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
|
||||
mechanical inversion of light mode — two differences are deliberate:
|
||||
|
||||
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
|
||||
mustard on a dark background.
|
||||
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
|
||||
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
|
||||
|
||||
Note colours have two representations, and they are not interchangeable:
|
||||
|
||||
| Map | Function | Used by | Role |
|
||||
|---|---|---|---|
|
||||
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
|
||||
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
|
||||
|
||||
A dark card sits at low lightness, where a 28px picker circle would read as a grey dot — small
|
||||
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
|
||||
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
|
||||
|
||||
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
|
||||
AA contrast (4.5:1) against both `--text` and `--text-muted` on every dark surface, no two dark card
|
||||
surfaces closer than an RGB distance of 8, no two dark chips closer than an RGB distance of 25, and
|
||||
`getNoteChip()` returning the card colour in light mode but a distinct chip in dark mode.
|
||||
|
||||
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
|
||||
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
|
||||
together.
|
||||
|
||||
### Typography
|
||||
|
||||
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
|
||||
@@ -53,8 +85,8 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
|
||||
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
|
||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Keep at 16-20px size. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
|
||||
- **Checkboxes**: Use `accent-color: var(--accent-check)` globally — resolves to `--primary` in light mode and to a dimmed gold (`#c8a44a`) in dark mode so a long done-list doesn't shout.
|
||||
- **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
|
||||
@@ -146,7 +178,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
|
||||
GitHub Actions with two workflows:
|
||||
|
||||
**CI** (`.github/workflows/ci.yml`) — runs on push to `main`/`claude/**` and PRs to `main`:
|
||||
**CI** (`.github/workflows/ci.yml`) — runs on push to `main` and PRs to `main` (branch work is validated once via the PR event, avoiding duplicate push+PR runs):
|
||||
1. **Lint & Type Check** — `pnpm check`
|
||||
2. **Unit Tests** — `pnpm test:unit`
|
||||
3. **Build** — `pnpm build` (depends on steps 1+2)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
A self-hostable, offline-first note-taking app that respects your privacy. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
|
||||
<p align="center">
|
||||
<img src="website/assets/screenshot-grid.png" alt="Crumbs notes grid view" width="720">
|
||||
</p>
|
||||
|
||||
## Install
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.22.3
|
||||
image: ghcr.io/bretzel-app/crumbs:0.31.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
+108
@@ -410,6 +410,114 @@ Owner can remove anyone. Collaborator can only remove self (leave). Use `userId=
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -100,8 +100,24 @@ Key tables:
|
||||
- `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
|
||||
|
||||
### 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:
|
||||
@@ -135,6 +151,19 @@ 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 surface variant per theme. Picker swatches use `getNoteChip()` instead —
|
||||
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
|
||||
draws from a separate vivid `NOTE_CHIPS_DARK` map.
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
|
||||
+25
-7
@@ -37,6 +37,11 @@
|
||||
- Check/uncheck items with checkboxes
|
||||
- Checked items get strikethrough styling
|
||||
- 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
|
||||
- Upload images via file picker or drag-and-drop
|
||||
@@ -64,10 +69,16 @@
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to note theme
|
||||
|
||||
### Favorite Notes
|
||||
- Favorite important notes to always appear at top
|
||||
- Favorites section separated from other notes
|
||||
- Toggle favorite from note card hover actions
|
||||
### Dark Mode
|
||||
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
|
||||
- Dark mode uses near-neutral warm ink surfaces (not a sepia-tinted parchment) — 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 important notes to always appear at top
|
||||
- Pinned section separated from unpinned notes
|
||||
- Toggle pin from note card hover actions
|
||||
|
||||
### Archive
|
||||
- Archive notes to declutter main view
|
||||
@@ -81,13 +92,20 @@
|
||||
- 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
|
||||
- Collaborator indicator (Users icon) on shared note cards
|
||||
- Hover popover showing collaborator names
|
||||
- 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
|
||||
@@ -138,7 +156,7 @@
|
||||
|
||||
### 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; favorite, reorder notes; upload images
|
||||
- 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
|
||||
|
||||
@@ -237,6 +237,104 @@ paths:
|
||||
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
|
||||
@@ -520,6 +618,47 @@ components:
|
||||
description: API key (crumbs_...)
|
||||
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -1,672 +0,0 @@
|
||||
# Pixelarticons Migration + Favorites Rename — 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:** Replace Lucide icons with pixelarticons via @iconify/svelte across the app (except 4 formatting letterforms), and rename user-facing "pinned" to "favorites" with a heart icon.
|
||||
|
||||
**Architecture:** Each component file gets its Lucide imports replaced with a single `Icon` import from `@iconify/svelte`. The `size` prop becomes `width`, and the icon name becomes a `pixelarticons:name` string. The 4 formatting letterforms (Bold, Italic, Strikethrough, Underline) stay as Lucide. The favorites rename touches stores, components, MCP server, and one e2e test.
|
||||
|
||||
**Tech Stack:** `@iconify/svelte` v5.2.1, `@iconify-json/pixelarticons` v1.2.4 (both already installed), `lucide-svelte` (kept for 4 icons)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
No new files created. All changes are modifications to existing files:
|
||||
|
||||
**Icon migration (16 files):** Replace Lucide imports with `@iconify/svelte` Icon component.
|
||||
**Favorites rename (4 source files + 1 test file):** Rename stores, functions, labels, test IDs.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Favorites Rename — Stores
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/stores/notes.ts:38-44,234-236`
|
||||
|
||||
- [ ] **Step 1: Rename store exports and function**
|
||||
|
||||
In `src/lib/stores/notes.ts`, make these changes:
|
||||
|
||||
```typescript
|
||||
// Line 38-44: Rename stores
|
||||
export const favoriteNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => n.pinned))
|
||||
);
|
||||
|
||||
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned))
|
||||
);
|
||||
|
||||
// Line 234-236: Rename function
|
||||
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
```
|
||||
|
||||
Note: The `pinned` field in updateNote stays — it's the DB column name.
|
||||
|
||||
- [ ] **Step 2: Verify no type errors**
|
||||
|
||||
Run: `pnpm check 2>&1 | head -30`
|
||||
Expected: Errors in files that still reference old names (NoteCard, NotesView) — that's expected, we fix those next.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/stores/notes.ts
|
||||
git commit -m "refactor: rename pinned stores to favorites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Favorites Rename — NoteCard Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteCard.svelte:4,110-118,170-178`
|
||||
|
||||
- [ ] **Step 1: Update import**
|
||||
|
||||
Change line 4:
|
||||
```typescript
|
||||
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update favorite indicator (lines 110-118)**
|
||||
|
||||
Replace the pinned indicator block:
|
||||
```svelte
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unfavorite"
|
||||
data-testid="favorite-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update favorite button (lines 170-178)**
|
||||
|
||||
Replace the pin button block:
|
||||
```svelte
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Favorite"
|
||||
data-testid="favorite-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteCard.svelte
|
||||
git commit -m "refactor: rename pin to favorite in NoteCard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Favorites Rename — NotesView Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NotesView.svelte:5,89-104`
|
||||
|
||||
- [ ] **Step 1: Update imports (line 5)**
|
||||
|
||||
```typescript
|
||||
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template references**
|
||||
|
||||
Replace all `$pinnedNotes` with `$favoriteNotes`, `$unpinnedNotes` with `$unfavoritedNotes`, and label `"Pinned"` with `"Favorites"`:
|
||||
|
||||
Line 89: `{#if $favoriteNotes.length > 0}`
|
||||
Line 91: `<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />`
|
||||
Line 96: `notes={$unfavoritedNotes}`
|
||||
Line 97: `label={$favoriteNotes.length > 0 ? 'Others' : ''}`
|
||||
Line 104: `{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}`
|
||||
|
||||
Note: Keep `dndType="pinned-notes"` unchanged — it's an internal DnD identifier, not user-facing.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NotesView.svelte
|
||||
git commit -m "refactor: rename pinned to favorites in NotesView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Favorites Rename — MCP Server
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/server/mcp/server.ts:241-265`
|
||||
|
||||
- [ ] **Step 1: Rename MCP tool**
|
||||
|
||||
Replace the `pin_note` tool definition (lines 241-265):
|
||||
```typescript
|
||||
server.tool(
|
||||
'favorite_note',
|
||||
'Favorite or unfavorite a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to favorite (true) or unfavorite (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 ? 'favorited' : 'unfavorited'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Note: The `pinned` parameter name stays since it maps to the DB column.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/server/mcp/server.ts
|
||||
git commit -m "refactor: rename pin_note MCP tool to favorite_note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Favorites Rename — E2E Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/e2e/organization.spec.ts:4-15`
|
||||
|
||||
- [ ] **Step 1: Update test scenario**
|
||||
|
||||
Replace lines 4-15:
|
||||
```typescript
|
||||
test('Scenario: Favorited note appears under the Favorites section', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Favorite Me" exists
|
||||
await createNote(page, 'Favorite Me');
|
||||
|
||||
// When the user favorites the note
|
||||
const favCard = noteCard(page, 'Favorite Me');
|
||||
await favCard.hover();
|
||||
await favCard.getByTestId('favorite-btn').first().click({ force: true });
|
||||
|
||||
// Then the "Favorites" section is visible
|
||||
await expect(page.getByText('Favorites')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run check to verify favorites rename is complete**
|
||||
|
||||
Run: `pnpm check 2>&1 | head -20`
|
||||
Expected: PASS (no errors related to old pin names)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/organization.spec.ts
|
||||
git commit -m "test: update pin test to favorites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Icon Migration — Header + SearchBar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/Layout/Header.svelte:2-5`
|
||||
- Modify: `src/lib/components/SearchBar.svelte:4-6`
|
||||
|
||||
- [ ] **Step 1: Migrate Header.svelte**
|
||||
|
||||
Replace imports (lines 2-5):
|
||||
```typescript
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace icon usage in template:
|
||||
- `<Menu class="h-6 w-6 text-[var(--text)]" />` → `<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />`
|
||||
- `<Search class="h-5 w-5 text-[var(--text)]" />` → `<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />`
|
||||
|
||||
- [ ] **Step 2: Migrate SearchBar.svelte**
|
||||
|
||||
Replace imports (lines 4-6):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace icon usage:
|
||||
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<ArrowLeft .../>` → `<Icon icon="pixelarticons:arrow-left" .../>`
|
||||
|
||||
Match existing `class` attributes exactly — just change the component and add the `icon` prop.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/Layout/Header.svelte src/lib/components/SearchBar.svelte
|
||||
git commit -m "feat: migrate Header and SearchBar to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Icon Migration — Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/Layout/Sidebar.svelte:2-4,19-23,47,65,81`
|
||||
|
||||
- [ ] **Step 1: Replace imports and nav items**
|
||||
|
||||
Replace the import (line 4):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace navItems array (lines 19-23):
|
||||
```typescript
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template icon usage**
|
||||
|
||||
Line 47: `<item.icon size={20} />` → `<Icon icon={item.icon} width={20} />`
|
||||
Line 65: `<Tag size={16} />` → `<Icon icon="pixelarticons:label" width={16} />`
|
||||
Line 81: `<Settings size={20} />` → `<Icon icon="pixelarticons:settings-cog" width={20} />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/Layout/Sidebar.svelte
|
||||
git commit -m "feat: migrate Sidebar to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Icon Migration — NoteCard
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteCard.svelte:5-13,117,159,167,177,187,196,206,215`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 5-13:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Remove all individual Lucide imports (Undo2, Trash2, Bookmark, Archive, ArchiveRestore, UserMinus).
|
||||
|
||||
- [ ] **Step 2: Replace all icon usages in template**
|
||||
|
||||
- `<Bookmark class="h-4 w-4 fill-[var(--primary)]" />` (favorite indicator) → `<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />`
|
||||
- `<Bookmark class="h-4 w-4" />` (favorite button) → `<Icon icon="pixelarticons:heart" class="h-4 w-4" />`
|
||||
- `<Undo2 class="h-4 w-4" />` (restore) → `<Icon icon="pixelarticons:undo" class="h-4 w-4" />`
|
||||
- `<Trash2 class="h-4 w-4 text-[var(--destructive)]" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />`
|
||||
- `<Trash2 class="h-4 w-4" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4" />`
|
||||
- `<Archive class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
|
||||
- `<ArchiveRestore class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
|
||||
- `<UserMinus class="h-4 w-4" />` → `<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteCard.svelte
|
||||
git commit -m "feat: migrate NoteCard to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Icon Migration — NoteEditor
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteEditor.svelte:16-24`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 16-24:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace all icon usages**
|
||||
|
||||
Search the file for each Lucide component and replace:
|
||||
- `<Palette .../>` → `<Icon icon="pixelarticons:colors-swatch" .../>`
|
||||
- `<SquareCheck .../>` → `<Icon icon="pixelarticons:checkbox-on" .../>`
|
||||
- `<ImageIcon .../>` → `<Icon icon="pixelarticons:image" .../>`
|
||||
- `<Type .../>` → `<Icon icon="pixelarticons:art-text" .../>`
|
||||
- `<FileCode .../>` → `<Icon icon="pixelarticons:script" .../>`
|
||||
- `<FileText .../>` → `<Icon icon="pixelarticons:file-text" .../>`
|
||||
- `<UserPlus .../>` → `<Icon icon="pixelarticons:user-plus" .../>`
|
||||
- `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
|
||||
- `<History .../>` → `<Icon icon="pixelarticons:timeline" .../>`
|
||||
|
||||
For each, keep the existing `size` prop but rename to `width`. Keep `class` as-is.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteEditor.svelte
|
||||
git commit -m "feat: migrate NoteEditor to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Icon Migration — FormattingToolbar
|
||||
|
||||
This is the largest file. Bold, Italic, Strikethrough, Underline stay as Lucide. Everything else migrates.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/FormattingToolbar.svelte:2-31,131-482`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace the import block (lines 3-31):
|
||||
```typescript
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Underline
|
||||
} from 'lucide-svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace icon usages in template**
|
||||
|
||||
Replace every non-B/I/S/U icon. The pattern: `<IconName size={iconSize} />` → `<Icon icon="pixelarticons:name" width={iconSize} />`. Full mapping:
|
||||
|
||||
- `<Undo2 size={iconSize} />` → `<Icon icon="pixelarticons:undo" width={iconSize} />`
|
||||
- `<Redo2 size={iconSize} />` → `<Icon icon="pixelarticons:redo" width={iconSize} />`
|
||||
- `<Heading size={iconSize} />` → `<Icon icon="pixelarticons:heading" width={iconSize} />`
|
||||
- `<List size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:list" width={iconSize} />`
|
||||
- `<ListOrdered size={iconSize} />` → `<Icon icon="pixelarticons:bulletlist" width={iconSize} />`
|
||||
- `<ListChecks size={iconSize} />` → `<Icon icon="pixelarticons:checklist" width={iconSize} />`
|
||||
- `<Link size={iconSize} />` → `<Icon icon="pixelarticons:link" width={iconSize} />`
|
||||
- `<CornerDownLeft size={16} />` → `<Icon icon="pixelarticons:corner-down-left" width={16} />`
|
||||
- `<ExternalLink size={16} />` → `<Icon icon="pixelarticons:external-link" width={16} />`
|
||||
- `<Trash2 size={16} />` → `<Icon icon="pixelarticons:trash" width={16} />`
|
||||
- `<Table2 size={iconSize} />` (appears 2x) → `<Icon icon="pixelarticons:table" width={iconSize} />`
|
||||
- `<ChevronDown size={chevronSize} />` (appears 4x) → `<Icon icon="pixelarticons:chevron-down" width={chevronSize} />`
|
||||
- `<BetweenHorizontalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-row" width={iconSize} />`
|
||||
- `<BetweenVerticalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-col" width={iconSize} />`
|
||||
- `<Minus size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:minus" width={iconSize} />`
|
||||
- `<RemoveFormatting size={iconSize} />` → `<Icon icon="pixelarticons:remove-box" width={iconSize} />`
|
||||
- `<Code size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
|
||||
- `<TextQuote size={iconSize} />` → `<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />`
|
||||
- `<CodeXml size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
|
||||
- `<AlignLeft size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-left" width={iconSize} />`
|
||||
- `<AlignCenter size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-center" width={iconSize} />`
|
||||
- `<AlignRight size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-right" width={iconSize} />`
|
||||
- `<AlignJustify size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-justify" width={iconSize} />`
|
||||
|
||||
Leave these unchanged: `<Bold size={iconSize} />`, `<Italic size={iconSize} />`, `<Strikethrough size={iconSize} />`, `<Underline size={iconSize} />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/FormattingToolbar.svelte
|
||||
git commit -m "feat: migrate FormattingToolbar to pixelarticons (keep B/I/S/U as Lucide)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Icon Migration — SyncIndicator
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/SyncIndicator.svelte:4-7,54-61`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 4-7:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace icon usages**
|
||||
|
||||
Replace the status icon block (lines 53-61):
|
||||
```svelte
|
||||
{#if displayStatus === 'synced'}
|
||||
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/SyncIndicator.svelte
|
||||
git commit -m "feat: migrate SyncIndicator to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Icon Migration — Remaining Small Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteHistory.svelte:3-5`
|
||||
- Modify: `src/lib/components/ShareDialog.svelte:3-5`
|
||||
- Modify: `src/lib/components/Checklist.svelte:2-5`
|
||||
- Modify: `src/lib/components/CollaboratorPopover.svelte:3`
|
||||
- Modify: `src/lib/components/ImageLightbox.svelte:2`
|
||||
- Modify: `src/lib/components/ImageUpload.svelte:2-4`
|
||||
- Modify: `src/lib/components/NotesView.svelte:8`
|
||||
|
||||
- [ ] **Step 1: Migrate NoteHistory.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<RotateCcw .../>` → `<Icon icon="pixelarticons:undo" .../>`
|
||||
- `<Clock .../>` → `<Icon icon="pixelarticons:clock" .../>`
|
||||
|
||||
- [ ] **Step 2: Migrate ShareDialog.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<UserMinus .../>` → `<Icon icon="pixelarticons:user-minus" .../>`
|
||||
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
|
||||
|
||||
- [ ] **Step 3: Migrate Checklist.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
|
||||
- `<GripVertical .../>` → `<Icon icon="pixelarticons:drag-and-drop" .../>`
|
||||
- `<ChevronRight .../>` → `<Icon icon="pixelarticons:chevron-right" .../>`
|
||||
|
||||
- [ ] **Step 4: Migrate CollaboratorPopover.svelte**
|
||||
|
||||
Replace import:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace: `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
|
||||
|
||||
- [ ] **Step 5: Migrate ImageLightbox.svelte**
|
||||
|
||||
Replace import:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace: `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
|
||||
- [ ] **Step 6: Migrate ImageUpload.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<Star .../>` → `<Icon icon="pixelarticons:pin" .../>`
|
||||
- `<RefreshCw .../>` → `<Icon icon="pixelarticons:reload" .../>`
|
||||
|
||||
- [ ] **Step 7: Migrate NotesView.svelte (Plus icon)**
|
||||
|
||||
Replace import (line 8):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<Plus class="h-4 w-4" />` → `<Icon icon="pixelarticons:plus" class="h-4 w-4" />`
|
||||
- `<Plus class="h-5 w-5" />` → `<Icon icon="pixelarticons:plus" class="h-5 w-5" />`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteHistory.svelte src/lib/components/ShareDialog.svelte src/lib/components/Checklist.svelte src/lib/components/CollaboratorPopover.svelte src/lib/components/ImageLightbox.svelte src/lib/components/ImageUpload.svelte src/lib/components/NotesView.svelte
|
||||
git commit -m "feat: migrate remaining components to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Icon Migration — Settings Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/(app)/settings/+layout.svelte:3`
|
||||
- Modify: `src/routes/(app)/settings/mcp/+page.svelte:2`
|
||||
|
||||
- [ ] **Step 1: Migrate settings layout**
|
||||
|
||||
Replace import (line 3):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace usages:
|
||||
- `<SlidersHorizontal size={16} />` → `<Icon icon="pixelarticons:sliders" width={16} />`
|
||||
- `<User size={16} />` → `<Icon icon="pixelarticons:user" width={16} />`
|
||||
- `<Cpu size={16} />` → `<Icon icon="pixelarticons:cpu" width={16} />`
|
||||
- `<ShieldCheck size={16} />` → `<Icon icon="pixelarticons:shield" width={16} />`
|
||||
- `<Info size={16} />` → `<Icon icon="pixelarticons:info-box" width={16} />`
|
||||
|
||||
- [ ] **Step 2: Migrate MCP settings page**
|
||||
|
||||
Replace import (line 2):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace usages:
|
||||
- `<Key .../>` → `<Icon icon="pixelarticons:lock" .../>`
|
||||
- `<Copy .../>` → `<Icon icon="pixelarticons:copy" .../>`
|
||||
- `<Trash2 .../>` → `<Icon icon="pixelarticons:trash" .../>`
|
||||
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
|
||||
- `<Check .../>` → `<Icon icon="pixelarticons:check" .../>`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/(app)/settings/+layout.svelte src/routes/(app)/settings/mcp/+page.svelte
|
||||
git commit -m "feat: migrate settings pages to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Update docs/FEATURES.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/FEATURES.md:67-70,141`
|
||||
|
||||
- [ ] **Step 1: Rename "Pin Notes" section to "Favorite Notes"**
|
||||
|
||||
Replace lines 67-70:
|
||||
```markdown
|
||||
### Favorite Notes
|
||||
- Favorite important notes to always appear at top
|
||||
- Favorites section separated from other notes
|
||||
- Toggle favorite from note card hover actions
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update MCP tools description**
|
||||
|
||||
Line 141: replace `pin` with `favorite` in the tools list:
|
||||
```markdown
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/FEATURES.md
|
||||
git commit -m "docs: rename pin to favorites in FEATURES.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Verification
|
||||
|
||||
- [ ] **Step 1: Run type check**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: PASS with no errors
|
||||
|
||||
- [ ] **Step 2: Run unit tests**
|
||||
|
||||
Run: `pnpm test:unit`
|
||||
Expected: All tests pass (DB/CRDT tests use `pinned` field which is unchanged)
|
||||
|
||||
- [ ] **Step 3: Run e2e tests**
|
||||
|
||||
Run: `pnpm test:e2e`
|
||||
Expected: All tests pass including the updated organization spec
|
||||
|
||||
- [ ] **Step 4: Update CLAUDE.md icons section**
|
||||
|
||||
Add to the "Visual rules" section in CLAUDE.md after the "Icons" line:
|
||||
|
||||
```markdown
|
||||
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit docs update**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update CLAUDE.md with pixelarticons icon usage"
|
||||
```
|
||||
@@ -0,0 +1,557 @@
|
||||
# Dark Theme Palette Rework 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:** Replace the dark theme's palette with a systematically generated one, and decouple the colour-picker swatch from the note card surface so the twelve colours are distinguishable.
|
||||
|
||||
**Architecture:** Purely a colour-data and CSS-variable change. `NOTE_COLORS_DARK` in `src/lib/utils/colors.ts` gets new values; a new `NOTE_CHIPS_DARK` map plus `getNoteChip()` serves the picker only. The `[data-theme="dark"]` block in `src/app.css` is rewritten. Consumers (`NoteCard`, `NoteEditor`, the public share page) already resolve colour through `getNoteColor()` and need no changes — only `ColorPicker.svelte` switches functions.
|
||||
|
||||
**Tech Stack:** SvelteKit + Svelte 5 runes, Tailwind CSS v4, Vitest (unit), Playwright (e2e), pnpm.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-31-dark-palette-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Light mode must not change. Any diff touching `NOTE_COLORS` (the light map) or `:root` colour values other than the new `--accent-check` is out of scope.
|
||||
- Never hardcode Tailwind colours (`text-gray-500`, `bg-red-600`). Always reference CSS variables. (Project rule, `CLAUDE.md`.)
|
||||
- Run `pnpm check` before every commit — not just tests. (Project rule.)
|
||||
- Run `pnpm build` before `pnpm test:e2e` locally, or e2e tests run against a stale bundle.
|
||||
- Conventional commit prefixes: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`.
|
||||
- Exact dark palette values are given verbatim in each task. Do not re-derive or "improve" them — they were generated to hold WCAG AA against `--text` and `--text-muted` on all twelve surfaces, and hand-tweaking breaks that.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: New dark note-card colours
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/colors.ts:18-31` (the `NOTE_COLORS_DARK` map)
|
||||
- Test: `src/lib/utils/colors.test.ts`
|
||||
- Modify: `tests/e2e/public-sharing.spec.ts` (asserted dark `fog` value)
|
||||
- Modify: `tests/e2e/dark-mode.spec.ts:84` (stale comment)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from earlier tasks.
|
||||
- Produces: `NOTE_COLORS_DARK` with the values below. `getNoteColor(color: NoteColor, isDark: boolean): string` keeps its existing signature. Task 2 relies on `getNoteColor` being unchanged; Task 2's chip values must differ from every value here.
|
||||
|
||||
- [ ] **Step 1: Write the failing invariant tests**
|
||||
|
||||
Add to `src/lib/utils/colors.test.ts`. These assert the *properties* that broke, not the hex strings — a hex-only test would have passed happily on the old broken palette.
|
||||
|
||||
```ts
|
||||
/** WCAG relative luminance. */
|
||||
function relativeLuminance(hex: string): number {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const channels = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(a: string, b: string): number {
|
||||
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
describe('dark note colours', () => {
|
||||
// Must track [data-theme="dark"] in src/app.css.
|
||||
const DARK_TEXT = '#ece3d3';
|
||||
const DARK_TEXT_MUTED = '#b3a695';
|
||||
|
||||
it('keeps body text above the AA threshold on every dark card', () => {
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps muted text above the AA threshold on every dark card', () => {
|
||||
// This is the invariant the old palette broke: muted/struck-through text
|
||||
// became unreadable on the lighter surfaces.
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the twelve surfaces within a narrow lightness band so they read as a family', () => {
|
||||
const luminances = Object.values(NOTE_COLORS_DARK).map((c) => relativeLuminance(c.bg));
|
||||
expect(Math.max(...luminances) - Math.min(...luminances)).toBeLessThan(0.03);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: FAIL. Only the two hex-pinning assertions fail (they still pin the old values). The AA
|
||||
tests are forward guards, not regressions of the old palette — the old palette actually clears
|
||||
both AA thresholds. The distinguishability test is the one that would fail on the old palette
|
||||
(its closest pair, `fog`/`storm`, sits at RGB distance 7.5).
|
||||
|
||||
- [ ] **Step 3: Replace the dark palette**
|
||||
|
||||
In `src/lib/utils/colors.ts`, replace the whole `NOTE_COLORS_DARK` map with these values exactly:
|
||||
|
||||
```ts
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', label: 'Chalk' }
|
||||
};
|
||||
```
|
||||
|
||||
Note for the implementer: `blossom` and `storm` intentionally shift hue relative to their light counterparts (toward pink and true blue). At higher chroma the original hues collided with `coral`/`peach` and `fog`. This is deliberate — see the spec.
|
||||
|
||||
- [ ] **Step 4: Update the two hex assertions that pin the old values**
|
||||
|
||||
In `src/lib/utils/colors.test.ts`, the existing `getNoteColor` tests assert old hexes. Change them:
|
||||
|
||||
```ts
|
||||
it('returns dark color when isDark is true', () => {
|
||||
expect(getNoteColor('default', true)).toBe('#21201f');
|
||||
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
|
||||
});
|
||||
|
||||
it('returns default color for unknown color name', () => {
|
||||
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
||||
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the unit tests to verify they pass**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: PASS, all tests in the file.
|
||||
|
||||
- [ ] **Step 6: Update the e2e test that asserts a dark note colour**
|
||||
|
||||
`tests/e2e/public-sharing.spec.ts` — the "Shared note follows a dark-mode visitor theme" scenario asserts dark `fog`. `#253946` is `rgb(37, 57, 70)`:
|
||||
|
||||
```ts
|
||||
await expect(publicPage.getByTestId('shared-note')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(37, 57, 70)'
|
||||
);
|
||||
```
|
||||
|
||||
In `tests/e2e/dark-mode.spec.ts:84`, the assertion itself is a negative one (`not.toBe` light default) and needs no change, but its comment names the old hex. Update it:
|
||||
|
||||
```ts
|
||||
// Dark default color is #21201f = rgb(33, 32, 31); NOT light default rgb(250, 245, 235)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify types, then the full e2e suites that touch colour**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e public-sharing dark-mode organization`
|
||||
Expected: PASS. (`organization.spec.ts` exercises the picker but asserts no hex, so it should be unaffected — it is in the list to prove that.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts tests/e2e/public-sharing.spec.ts tests/e2e/dark-mode.spec.ts
|
||||
git commit -m "feat(theme): rebuild dark note colours as a consistent family"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Decouple picker swatches from card surfaces
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/colors.ts` (add `NOTE_CHIPS_DARK` and `getNoteChip`)
|
||||
- Modify: `src/lib/components/ColorPicker.svelte:2,19`
|
||||
- Test: `src/lib/utils/colors.test.ts`
|
||||
- Test: `tests/e2e/dark-mode.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `NOTE_COLORS_DARK` and `getNoteColor(color: NoteColor, isDark: boolean): string` from Task 1.
|
||||
- Produces: `NOTE_CHIPS_DARK: Record<NoteColor, string>` and `getNoteChip(color: NoteColor, isDark: boolean): string`. No later task depends on these.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add to `src/lib/utils/colors.test.ts`:
|
||||
|
||||
```ts
|
||||
describe('getNoteChip', () => {
|
||||
it('returns the card colour in light mode, where pastels already are the cards', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
|
||||
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
|
||||
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to default for an unknown colour', () => {
|
||||
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
|
||||
});
|
||||
|
||||
it('has matching keys with the colour maps', () => {
|
||||
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
|
||||
});
|
||||
|
||||
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
|
||||
// The regression this guards: at low chroma coral/peach/blossom and
|
||||
// fog/storm collapse into each other.
|
||||
const entries = Object.entries(NOTE_CHIPS_DARK);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
|
||||
const toRgb = (hex: string) => {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
};
|
||||
const [ra, ga, ba] = toRgb(a);
|
||||
const [rb, gb, bb] = toRgb(b);
|
||||
const distance = Math.hypot(ra - rb, ga - gb, ba - bb);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Extend the import at the top of the file:
|
||||
|
||||
```ts
|
||||
import {
|
||||
getNoteColor,
|
||||
getNoteChip,
|
||||
NOTE_COLORS,
|
||||
NOTE_COLORS_DARK,
|
||||
NOTE_CHIPS_DARK
|
||||
} from '$lib/utils/colors.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: FAIL — `getNoteChip is not a function` / `NOTE_CHIPS_DARK` undefined.
|
||||
|
||||
- [ ] **Step 3: Add the chip map and accessor**
|
||||
|
||||
Append to `src/lib/utils/colors.ts`, after `getNoteColor`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
|
||||
* reads as a grey dot — small patches lose apparent saturation — so the dark
|
||||
* picker uses vivid mid-lightness chips that share the card's hue but not its
|
||||
* lightness. Light mode needs no equivalent: there the pastels are the cards.
|
||||
*/
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c',
|
||||
coral: '#d04f43',
|
||||
peach: '#cd7c42',
|
||||
sand: '#c7b138',
|
||||
mint: '#7bbd4c',
|
||||
sage: '#47b89d',
|
||||
fog: '#609abe',
|
||||
storm: '#547bc0',
|
||||
dusk: '#a965bd',
|
||||
blossom: '#c05d78',
|
||||
clay: '#b5954a',
|
||||
chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false);
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pnpm test:unit colors`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Point the picker at the chips**
|
||||
|
||||
In `src/lib/components/ColorPicker.svelte`, change the import on line 2 and the style on line 19:
|
||||
|
||||
```svelte
|
||||
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
|
||||
```
|
||||
|
||||
```svelte
|
||||
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
|
||||
```
|
||||
|
||||
Leave everything else alone — the 2px `--border-subtle` ring stays, and it's what gives each swatch its boundary regardless of fill.
|
||||
|
||||
- [ ] **Step 6: Add the e2e scenario**
|
||||
|
||||
Append inside the `test.describe.serial('Dark mode', ...)` block in `tests/e2e/dark-mode.spec.ts`:
|
||||
|
||||
```ts
|
||||
test('Scenario: Colour picker swatches are identifiable in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given dark theme is active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// When the user opens the colour picker while editing a note
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Swatch Test');
|
||||
await page.getByTestId('color-picker-toggle').click();
|
||||
await expect(page.getByTestId('color-picker')).toBeVisible();
|
||||
|
||||
// Then a swatch shows its vivid chip rather than the muted card surface
|
||||
await expect(page.getByTestId('color-coral')).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(208, 79, 67)'
|
||||
);
|
||||
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e dark-mode organization`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/colors.ts src/lib/utils/colors.test.ts src/lib/components/ColorPicker.svelte tests/e2e/dark-mode.spec.ts
|
||||
git commit -m "feat(theme): give the dark colour picker vivid identifiable chips"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: New dark CSS variables and checkbox accent
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app.css:6-24` (`:root` — add `--accent-check` only), `src/app.css:26-42` (the `[data-theme="dark"]` block), `src/app.css:44-48` (`accent-color`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing. The `--text`/`--text-muted` values here must match the constants asserted in Task 1's tests (`#ece3d3`, `#b3a695`).
|
||||
- Produces: `--accent-check` custom property, available in both themes.
|
||||
|
||||
- [ ] **Step 1: Add `--accent-check` to `:root`**
|
||||
|
||||
In the `:root` block in `src/app.css`, after `--grid-dot`:
|
||||
|
||||
```css
|
||||
--grid-dot: #1a1a2e;
|
||||
--accent-check: var(--primary);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the dark block**
|
||||
|
||||
Replace the entire `[data-theme="dark"]` block with:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
|
||||
--accent-check: #c8a44a;
|
||||
}
|
||||
```
|
||||
|
||||
Two of these reverse earlier decisions on purpose: `--primary` is no longer shared with light mode (`#C8860A` reads mustard on ink), and `--border` is a warm mid-grey rather than the mirrored cream `#e8dcc8` (a bright 1px line on a dark background reads as glare, not structure).
|
||||
|
||||
- [ ] **Step 3: Point `accent-color` at the new variable**
|
||||
|
||||
In the `html, body` rule:
|
||||
|
||||
```css
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--accent-check);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify light mode is untouched and dark mode renders**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: 0 errors.
|
||||
|
||||
Run: `pnpm build && pnpm test:e2e dark-mode public-sharing`
|
||||
Expected: PASS. Task 1's e2e assertions cover the dark note colours; nothing asserts the chrome variables, so this step is proving no regression rather than proving the change.
|
||||
|
||||
Then look at it: `pnpm preview`, open the app, switch to dark in Settings → Preferences, and check the notes grid, the sidebar, an open editor, and a checklist with done items. Contrast maths can't judge the pixel-grid overlay or the gold hover states.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app.css
|
||||
git commit -m "feat(theme): replace dark surfaces, borders and accents with the warm ink palette"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Sync the browser theme-colour with the new base
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/utils/theme.svelte.ts:4`
|
||||
- Modify: `src/app.html:21`
|
||||
- Test: `src/lib/utils/theme.test.ts:40`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `--bg-base` `#141312` from Task 3. These two hardcoded copies must equal it.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Update the failing test first**
|
||||
|
||||
In `src/lib/utils/theme.test.ts`:
|
||||
|
||||
```ts
|
||||
it('updates meta theme-color for dark mode', () => {
|
||||
applyTheme('dark');
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
expect(meta?.getAttribute('content')).toBe('#141312');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run: `pnpm test:unit theme`
|
||||
Expected: FAIL — received `#1a1715`.
|
||||
|
||||
- [ ] **Step 3: Update both hardcoded copies**
|
||||
|
||||
`src/lib/utils/theme.svelte.ts` line 4:
|
||||
|
||||
```ts
|
||||
const DARK_THEME_COLOR = '#141312';
|
||||
```
|
||||
|
||||
`src/app.html` line 21, inside the FOUC script:
|
||||
|
||||
```js
|
||||
if (m) m.content = '#141312';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `pnpm test:unit theme`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run: `pnpm check && pnpm test:unit`
|
||||
Expected: 0 errors, all unit tests pass.
|
||||
|
||||
```bash
|
||||
git add src/lib/utils/theme.svelte.ts src/app.html src/lib/utils/theme.test.ts
|
||||
git commit -m "fix(theme): sync browser theme-colour with the new dark base"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Document the palette
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (the "Design system: Retro Parchment" section)
|
||||
- Modify: `docs/ARCHITECTURE.md:155-165` (the dark mode section)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 1-4.
|
||||
- Produces: nothing.
|
||||
|
||||
- [ ] **Step 1: Document the dark palette in `CLAUDE.md`**
|
||||
|
||||
The colour table there lists only light values, which is why the dark palette drifted in the first place. After the existing table and the note about `src/lib/utils/colors.ts`, add:
|
||||
|
||||
```markdown
|
||||
### Dark theme
|
||||
|
||||
Dark mode overrides every colour variable in `[data-theme="dark"]` (`src/app.css`). It is not a
|
||||
mechanical inversion of light mode — two differences are deliberate:
|
||||
|
||||
- **`--primary` differs per theme.** `#C8860A` on parchment, `#e0a030` on ink. The light gold reads
|
||||
mustard on a dark background.
|
||||
- **`--border` is a warm mid-grey (`#57514b`), not mirrored cream.** A bright 1px line on a dark
|
||||
background reads as glare rather than structure; the hard offset shadow carries the retro framing.
|
||||
|
||||
Note colours have two representations, and they are not interchangeable:
|
||||
|
||||
| Map | Function | Used by | Role |
|
||||
|---|---|---|---|
|
||||
| `NOTE_COLORS` / `NOTE_COLORS_DARK` | `getNoteColor(color, isDark)` | Cards, editor, share page | Quiet **surface** you read text on |
|
||||
| `NOTE_CHIPS_DARK` | `getNoteChip(color, isDark)` | `ColorPicker` only | Vivid **label** you identify at a glance |
|
||||
|
||||
A dark card sits at ~18% lightness, where a 28px picker circle would read as a grey dot — small
|
||||
patches lose apparent saturation. Never fill a picker swatch with `getNoteColor()`. In light mode
|
||||
`getNoteChip()` returns the pastel card colour, because there the pastels *are* the cards.
|
||||
|
||||
When changing any dark note colour, keep the invariants asserted in `src/lib/utils/colors.test.ts`:
|
||||
AA contrast against both `--text` and `--text-muted` on every surface, a narrow lightness band across
|
||||
the twelve, and no two chips closer than an RGB distance of 25.
|
||||
|
||||
`--bg-base` is duplicated as a literal in `src/app.html` (FOUC script) and
|
||||
`src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`) for the browser theme-colour. Update all three
|
||||
together.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `docs/ARCHITECTURE.md`**
|
||||
|
||||
In the dark mode section, the line describing note colours currently mentions only `getNoteColor()`. Replace it with:
|
||||
|
||||
```markdown
|
||||
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and
|
||||
returns the appropriate surface variant per theme. Picker swatches use `getNoteChip()` instead —
|
||||
in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker
|
||||
draws from a separate vivid `NOTE_CHIPS_DARK` map.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the docs match the code**
|
||||
|
||||
Re-read both edited sections against `src/lib/utils/colors.ts` and `src/app.css`. Every function name, map name, file path and hex value must exist as written. No verification command can catch a wrong hex in prose — check it by eye.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/ARCHITECTURE.md
|
||||
git commit -m "docs: document the dark palette and the card-vs-chip distinction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] `pnpm check` — 0 errors
|
||||
- [ ] `pnpm test:unit` — all pass
|
||||
- [ ] `pnpm build && pnpm test:e2e` — full suite passes
|
||||
- [ ] Manual pass in dark mode: notes grid, sidebar, header, open editor, checklist with done items, colour picker, a public share link, an error state (e.g. a failed login), and light mode unchanged
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -1,182 +0,0 @@
|
||||
# Icon Migration: Lucide → Pixelarticons + "Pinned" → "Favorites" Rename
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Replace Lucide icons with pixelarticons (via `@iconify/svelte`) across the app to match the retro parchment aesthetic. Keep Lucide only for 4 text-formatting letterform icons (Bold, Italic, Strikethrough, Underline). Additionally, rename the user-facing "pinned" concept to "favorites" with a heart icon, while keeping the `pinned` database column unchanged.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current Lucide icons have smooth, modern strokes that clash with the 8-bit retro parchment design system. Pixelarticons are drawn on a strict 24×24 pixel grid with no anti-aliasing — a natural fit for the `Press Start 2P` pixel font and hard-offset shadow aesthetic already in use.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Icon Library
|
||||
|
||||
- **Package:** `@iconify/svelte` (v5.2.1, already installed) + `@iconify-json/pixelarticons` (v1.2.4, already installed)
|
||||
- **Usage:** `<Icon icon="pixelarticons:icon-name" width={size} class="..." />`
|
||||
- **Tree-shaking:** Iconify handles this automatically — only icons used in code are bundled
|
||||
|
||||
### Icon Mapping
|
||||
|
||||
56 icons migrated to pixelarticons, 4 kept as Lucide:
|
||||
|
||||
#### Navigation & UI
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| Menu | `menu` |
|
||||
| Search | `search` |
|
||||
| X / XIcon | `close` |
|
||||
| Plus | `plus` |
|
||||
| Check | `check` |
|
||||
| ArrowLeft | `arrow-left` |
|
||||
| ChevronRight | `chevron-right` |
|
||||
| ChevronDown | `chevron-down` |
|
||||
| Settings | `settings-cog` |
|
||||
| ExternalLink | `external-link` |
|
||||
|
||||
#### Notes & Content
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| StickyNote | `sticky-note` | |
|
||||
| Archive | `archive` | |
|
||||
| ArchiveRestore | `archive` | Context makes it clear |
|
||||
| Trash2 | `trash` | |
|
||||
| Bookmark | `heart` | Renamed to "favorite" |
|
||||
| Clock | `clock` | |
|
||||
| History | `timeline` | |
|
||||
| FileText | `file-text` | |
|
||||
| FileCode | `script` | |
|
||||
| ImageIcon | `image` | |
|
||||
| Palette | `colors-swatch` | |
|
||||
| GripVertical | `drag-and-drop` | |
|
||||
| Star | `pin` | Used in ImageUpload for "featured image" toggle — not related to favorites |
|
||||
| SquareCheck | `checkbox-on` | |
|
||||
| Type | `art-text` | |
|
||||
|
||||
#### Text Formatting (toolbar)
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| **Bold** | **Keep Lucide** | Letterform |
|
||||
| **Italic** | **Keep Lucide** | Letterform |
|
||||
| **Strikethrough** | **Keep Lucide** | Letterform |
|
||||
| **Underline** | **Keep Lucide** | Letterform |
|
||||
| Code | `braces` | |
|
||||
| CodeXml | `braces` | |
|
||||
| Heading | `heading` | |
|
||||
| Link | `link` | |
|
||||
| TextQuote | `quote-text-inline` | |
|
||||
| List | `list` | |
|
||||
| ListOrdered | `bulletlist` | |
|
||||
| ListChecks | `checklist` | |
|
||||
| AlignLeft | `text-align-left` | |
|
||||
| AlignCenter | `text-align-center` | |
|
||||
| AlignRight | `text-align-right` | |
|
||||
| AlignJustify | `text-align-justify` | |
|
||||
| Table2 | `table` | |
|
||||
| Minus | `minus` | |
|
||||
| RemoveFormatting | `remove-box` | |
|
||||
| CornerDownLeft | `corner-down-left` | |
|
||||
| BetweenHorizontalEnd | `add-row` | |
|
||||
| BetweenVerticalEnd | `add-col` | |
|
||||
|
||||
#### Undo/Redo
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| Undo2 | `undo` |
|
||||
| Redo2 | `redo` |
|
||||
| RotateCcw | `undo` |
|
||||
| RefreshCw | `reload` |
|
||||
|
||||
#### Sync Status
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| CloudCheck | `cloud-done` | |
|
||||
| CloudSync | `sync` | |
|
||||
| CloudOff | `cloud` | Color conveys offline state |
|
||||
| CloudAlert | `cloud` | Color conveys error state |
|
||||
|
||||
#### Users & Admin
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| User | `user` |
|
||||
| Users | `users` |
|
||||
| UserPlus | `user-plus` |
|
||||
| UserMinus | `user-minus` |
|
||||
| Cpu | `cpu` |
|
||||
| ShieldCheck | `shield` |
|
||||
| SlidersHorizontal | `sliders` |
|
||||
| Info | `info-box` |
|
||||
| Key | `lock` |
|
||||
| Copy | `copy` |
|
||||
| Tag | `label` |
|
||||
|
||||
### "Pinned" → "Favorites" Rename
|
||||
|
||||
**Scope:** User-facing labels, function names, store names, MCP tool names, test IDs. The database column `pinned` stays unchanged to avoid a migration.
|
||||
|
||||
**Renames:**
|
||||
| Current | New |
|
||||
|---------|-----|
|
||||
| `pinnedNotes` (store) | `favoriteNotes` |
|
||||
| `unpinnedNotes` (store) | `unfavoritedNotes` |
|
||||
| `togglePin()` | `toggleFavorite()` |
|
||||
| `"Pinned"` label in NotesView | `"Favorites"` |
|
||||
| `"Pin"` / `"Unpin"` tooltips in NoteCard | `"Favorite"` / `"Unfavorite"` |
|
||||
| `pin_note` MCP tool | `favorite_note` |
|
||||
| `pin-btn` / `pin-indicator` test IDs | `favorite-btn` / `favorite-indicator` |
|
||||
|
||||
**Not renamed (internal/DB):**
|
||||
- `pinned` column in `notes` and `noteUserState` tables
|
||||
- `pinned` field in TypeScript types (`Note`, `NoteUpdate`, `NoteChange`)
|
||||
- `pinned` references in CRDT sync logic
|
||||
- `pinned` in server-side sync overlay
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Icon migration (16 files)
|
||||
1. `src/lib/components/Layout/Sidebar.svelte`
|
||||
2. `src/lib/components/Layout/Header.svelte`
|
||||
3. `src/lib/components/FormattingToolbar.svelte`
|
||||
4. `src/lib/components/NoteCard.svelte`
|
||||
5. `src/lib/components/NoteEditor.svelte`
|
||||
6. `src/lib/components/NoteHistory.svelte`
|
||||
7. `src/lib/components/SyncIndicator.svelte`
|
||||
8. `src/lib/components/SearchBar.svelte`
|
||||
9. `src/lib/components/ShareDialog.svelte`
|
||||
10. `src/lib/components/Checklist.svelte`
|
||||
11. `src/lib/components/CollaboratorPopover.svelte`
|
||||
12. `src/lib/components/ImageLightbox.svelte`
|
||||
13. `src/lib/components/ImageUpload.svelte`
|
||||
14. `src/lib/components/NotesView.svelte`
|
||||
15. `src/routes/(app)/settings/+layout.svelte`
|
||||
16. `src/routes/(app)/settings/mcp/+page.svelte`
|
||||
|
||||
### Favorites rename (7 files)
|
||||
1. `src/lib/stores/notes.ts` — store names + `toggleFavorite`
|
||||
2. `src/lib/components/NoteCard.svelte` — tooltips, test IDs, icon
|
||||
3. `src/lib/components/NotesView.svelte` — label, store imports
|
||||
4. `src/lib/server/mcp/server.ts` — tool name + description
|
||||
5. `docs/FEATURES.md` — update feature description
|
||||
6. `tests/e2e/organization.spec.ts` — `pin-btn` test ID + `getByText('Pinned')` → `getByText('Favorites')` + scenario names/comments
|
||||
7. Note: `tests/e2e/sharing.spec.ts` references `pinned` as a data field — does NOT need renaming (DB field stays `pinned`)
|
||||
|
||||
### Cleanup
|
||||
- Remove `lucide-svelte` from `package.json` once only 4 icons remain — or keep it as a dependency for those 4. Decision: keep it, the 4 formatting icons are deeply integrated.
|
||||
|
||||
### Documentation
|
||||
- Update `CLAUDE.md` "Icons" section to document pixelarticons usage
|
||||
- Update `docs/FEATURES.md` if it mentions pinning
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `pnpm check` after migration to catch type errors
|
||||
- Run `pnpm test:unit` to verify sync/CRDT tests still pass (they use `pinned` field which is unchanged)
|
||||
- Run `pnpm test:e2e` to verify UI still works — tests referencing `pin-btn`/`pin-indicator` will need updating
|
||||
- Visual verification: icons render correctly at all sizes (12-20px)
|
||||
|
||||
## Rendering Note
|
||||
|
||||
Pixelarticons are designed for the 24×24 grid and render sharpest at multiples of 24px. At the sizes used in this app (12-20px), they will still look good because `@iconify/svelte` renders them as SVG (vector), but the pixel-art crispness is maximized at 24px. Consider using `image-rendering: pixelated` on icon SVGs if sub-24px rendering looks blurry, though SVG scaling typically handles this well.
|
||||
@@ -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,205 @@
|
||||
# Dark Theme Palette Rework — Design Spec
|
||||
|
||||
**Date:** 2026-07-31
|
||||
**Status:** Approved
|
||||
**Supersedes the palette portion of:** `2026-03-20-dark-mode-design.md`
|
||||
|
||||
## Summary
|
||||
|
||||
The dark theme's colours were authored one at a time rather than as a set. The result: twelve note
|
||||
colours that collapse into indistinguishable dark mud, a picker of twelve grey dots, sepia-tinted
|
||||
surfaces, and a near-white editor outline that reads as glare. This spec replaces the dark palette
|
||||
with a systematically generated one, and splits the picker swatch from the card surface — they are
|
||||
different jobs and should not share a value.
|
||||
|
||||
Light mode is unchanged.
|
||||
|
||||
## Problems being fixed
|
||||
|
||||
1. **Note colours are indistinguishable.** `coral #4a2522`, `peach #4a3020` and `clay #302e28` differ
|
||||
by a few percent lightness with almost no chroma. Lightness wanders across the set (17%–19%) with
|
||||
no consistent relationship, so they don't read as a family.
|
||||
2. **Several cards don't separate from the page.** `clay`, `chalk` and `default` sit within a few
|
||||
percent of `--bg-base`, so cards lose their edges.
|
||||
3. **The picker is unreadable.** `ColorPicker.svelte` fills each swatch with the card background. A
|
||||
28px circle at 18% lightness cannot read as a colour — small patches lose apparent saturation, so
|
||||
twelve swatches become twelve grey dots.
|
||||
4. **Surfaces are sepia.** `--bg-base #1a1715` / `--bg-surface #2a2520` are brown-tinted; combined
|
||||
with gold and cream on top, the UI reads dim rather than crisp.
|
||||
5. **The editor frame glares.** `--border` mirrors light mode's near-black ink as near-white cream.
|
||||
The mirror is false: dark-on-light edges absorb, light-on-dark edges emit. At equal contrast the
|
||||
inverted border reads as a glowing rectangle, not as structure.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|---|---|
|
||||
| 1 | Base surfaces | **Warm ink** — pull the brown out, keep a trace of warmth |
|
||||
| 2 | Card lightness | **Deep tinted (~18%)** — colours identifiable, page still reads dark |
|
||||
| 3 | Picker swatches | **Vivid mid-lightness chips**, decoupled from the card colour |
|
||||
| 4 | Chip scope | **Dark mode only** — light mode keeps its pastel swatches |
|
||||
| 5 | Chrome | **Quiet frame** — `--border` becomes warm mid-grey, not cream |
|
||||
|
||||
On (4): in light mode the pastels genuinely *are* the card colours, so they preview correctly there.
|
||||
Only dark mode needs the split.
|
||||
|
||||
## 1. CSS variables (`src/app.css`)
|
||||
|
||||
Replace the `[data-theme="dark"]` block:
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
--accent-check: #c8a44a;
|
||||
}
|
||||
```
|
||||
|
||||
Three of these are notable changes rather than tweaks:
|
||||
|
||||
- **`--primary` is no longer shared across themes.** `#C8860A` was chosen for parchment; on ink it
|
||||
reads mustard. Dark mode gets `#e0a030` (8.17:1 on the page).
|
||||
- **`--border` becomes `#57514b`** instead of cream `#e8dcc8`. Frames the editor without glare; the
|
||||
hard offset shadow still carries the retro structure.
|
||||
- **`--text-muted` lifts `#9a8e7e` → `#b3a695`.** Required for AA on the tinted cards. This is the
|
||||
widest-reaching change in the spec — every muted element in dark mode shifts slightly.
|
||||
|
||||
### Checkbox accent
|
||||
|
||||
`accent-color: var(--primary)` makes a long done-list shout in dark mode. Add a dedicated variable so
|
||||
the checkbox can be dimmed independently of links and the brand:
|
||||
|
||||
```css
|
||||
:root { --accent-check: var(--primary); } /* light: unchanged */
|
||||
html, body { accent-color: var(--accent-check); }
|
||||
```
|
||||
|
||||
## 2. Note card colours (`src/lib/utils/colors.ts`)
|
||||
|
||||
Replace `NOTE_COLORS_DARK`. Every entry keeps its light counterpart's hue, holds lightness
|
||||
near-constant against today's values (measured average change: +4.1 saturation points, −0.18
|
||||
lightness points), and nudges two crowded hues apart (`blossom` and `storm` shift toward pink
|
||||
and true blue respectively) so the twelve read as distinguishable siblings rather than a smudge.
|
||||
The visible improvement in dark mode comes from the base surfaces, the picker chips, the
|
||||
softened border, and the lifted gold — not from the card surfaces, which are close in chroma to
|
||||
the previous dark values.
|
||||
|
||||
```ts
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', label: 'Chalk' }
|
||||
};
|
||||
```
|
||||
|
||||
**Two hues are deliberately nudged apart.** At low chroma, `coral`/`peach`/`blossom` are three
|
||||
barely-different oranges and `fog`/`storm` are two barely-different blues — invisible in light mode,
|
||||
but they collide once chroma rises. So in dark mode `blossom` moves toward pink (H344) and `storm`
|
||||
toward true blue (H218). Their light-mode values are untouched, so the same note is a slightly
|
||||
different hue in each theme. This is an accepted trade: distinguishable beats identical.
|
||||
|
||||
Measured on these values: body text ≥ 9.25:1, muted text ≥ 4.94:1, links ≥ 5.18:1 on every card.
|
||||
All clear WCAG AA.
|
||||
|
||||
## 3. Picker chips (`src/lib/utils/colors.ts`)
|
||||
|
||||
New map, used only by the picker, only in dark mode:
|
||||
|
||||
```ts
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c', coral: '#d04f43', peach: '#cd7c42', sand: '#c7b138',
|
||||
mint: '#7bbd4c', sage: '#47b89d', fog: '#609abe', storm: '#547bc0',
|
||||
dusk: '#a965bd', blossom: '#c05d78', clay: '#b5954a', chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
/** Picker swatch colour: a vivid label, not the card surface. */
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false); // light mode: pastel == card
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
```
|
||||
|
||||
`default` and `chalk` stay deliberately neutral (grey and off-white) — they are the "no colour"
|
||||
slots. Chips are non-text UI, so WCAG's bar is 3:1, not 4:1; every chip clears that comfortably
|
||||
against `--bg-surface`, with the lowest at `coral` 3.95:1 (`storm` 4.02:1, `blossom` 4.10:1). The
|
||||
closest pair (`fog`/`storm`) is separated by an RGB distance of 33, which is comfortably tellable
|
||||
apart at 28px.
|
||||
|
||||
`ColorPicker.svelte` switches from `getNoteColor` to `getNoteChip`. Nothing else uses chips — cards,
|
||||
the editor and the public share page keep calling `getNoteColor`.
|
||||
|
||||
## 4. Theme-colour meta tag
|
||||
|
||||
`#1a1715` is hardcoded in two places that must stay in sync with `--bg-base`:
|
||||
|
||||
- `src/app.html` (the FOUC script, `m.content = '#1a1715'`)
|
||||
- `src/lib/utils/theme.svelte.ts` (`DARK_THEME_COLOR`)
|
||||
|
||||
Both become `#141312`.
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/app.css` | Rewrite `[data-theme="dark"]`; add `--accent-check` to `:root`; point `accent-color` at it |
|
||||
| `src/lib/utils/colors.ts` | New `NOTE_COLORS_DARK`; add `NOTE_CHIPS_DARK` + `getNoteChip()` |
|
||||
| `src/lib/components/ColorPicker.svelte` | Use `getNoteChip()` instead of `getNoteColor()` |
|
||||
| `src/app.html` | Theme-colour `#1a1715` → `#141312` |
|
||||
| `src/lib/utils/theme.svelte.ts` | `DARK_THEME_COLOR` → `#141312` |
|
||||
| `src/lib/utils/colors.test.ts` | Update dark `default` assertions; add `getNoteChip` coverage |
|
||||
| `src/lib/utils/theme.test.ts` | Update theme-colour assertion |
|
||||
| `tests/e2e/dark-mode.spec.ts` | Update asserted dark default `rgb(42,37,32)` → `rgb(33,32,31)` |
|
||||
| `tests/e2e/public-sharing.spec.ts` | Update asserted dark `fog` `rgb(34,46,58)` → `rgb(37,57,70)` |
|
||||
| `CLAUDE.md` | Document the dark palette and the card-vs-chip distinction in the design system section |
|
||||
| `docs/ARCHITECTURE.md` | Update the dark mode section for `getNoteChip` |
|
||||
|
||||
Nothing needs to change in `NoteCard`, `NoteEditor`, or the share page — they already resolve colour
|
||||
through `getNoteColor`, so they inherit the new palette.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (`colors.test.ts`)** — the palette is data, so test its invariants rather than its hex values:
|
||||
|
||||
- `getNoteChip(c, false)` equals `getNoteColor(c, false)` for all twelve (light mode is unchanged)
|
||||
- `getNoteChip(c, true)` differs from `getNoteColor(c, true)` for all twelve (chip ≠ surface)
|
||||
- unknown colour falls back to `default` in both maps
|
||||
- every dark card holds ≥ 4.5:1 against `--text-muted #b3a695` — this is the invariant that broke
|
||||
before, so it gets asserted rather than eyeballed
|
||||
- no two dark chips are within an RGB distance of 25 (guards the crowded-hue regression)
|
||||
|
||||
**E2E (`dark-mode.spec.ts`)** — extend the existing "note cards use dark colors" scenario to assert a
|
||||
picker swatch is *not* the card background, which is the bug in user-visible terms.
|
||||
|
||||
**Manual** — the pixel-grid overlay and gold hover states want a real look on device; contrast maths
|
||||
can't judge those.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Light mode, including its picker
|
||||
- The 12-colour set itself (no colours added, removed, or renamed)
|
||||
- Per-note custom colours
|
||||
- `prefers-contrast` / high-contrast variants
|
||||
@@ -0,0 +1,129 @@
|
||||
# Note Linking — Design Spec
|
||||
|
||||
**Issue**: [#74 — feat: link to other crumbs (notes) inside the app](https://github.com/bretzel-app/crumbs/issues/74)
|
||||
**Date**: 2026-08-03
|
||||
|
||||
## Overview
|
||||
|
||||
Let a note reference another note by ID, so clicking it opens the target note in the editor. Reuses the existing "insert link" toolbar entry point (Ctrl+K) rather than introducing a new `[[wikilink]]`-style typing syntax. The displayed link text always tracks the target note's current title — it's a live reference, not frozen text — and the target note gets a "Referenced by" backlinks section.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Link creation UX | Extend the existing link toolbar dropdown with a note-search mode | Matches the issue reporter's own proposed solution; reuses the Link dropdown, `/api/search`, and toolbar infra as-is instead of building a new inline autocomplete |
|
||||
| Click behavior | Opens the target note in the editor overlay, same as clicking a note card | Matches how every other note-opening interaction already works in the app |
|
||||
| Link label | Always the target note's live title | A stale link label after a rename would be confusing; this requires modeling the link as an atomic node rather than a mark (see Architecture) |
|
||||
| Broken/inaccessible links | Grey out, inert, content untouched | Nothing is silently rewritten; if the note is restored from trash the link works again automatically |
|
||||
| Backlinks | Included in v1 | Requested explicitly; reuses the same extract-and-sync pattern tags already use, so the incremental cost is a reverse query, not new infrastructure |
|
||||
| Public share page | Note-links never resolve, even if the target is also shared | Simplest, most conservative option — zero chance of leaking the existence of a private note to an anonymous visitor |
|
||||
| Link picker scope | Owned notes + notes shared with you | Same visibility scope the main notes list already uses; `searchNotes()` already implements this |
|
||||
| Removing an existing link | Hover-reveal a small × on the chip | Click already navigates instantly, so it can't also open an edit/remove popover the way external links do; a hover-revealed remove button solves this without touching the click behavior. Repointing a link is just remove + re-insert — no separate "edit target" UI |
|
||||
|
||||
## Architecture
|
||||
|
||||
**Data model** — a new self-referential join table, mirroring `noteTags`/`tags`:
|
||||
|
||||
```typescript
|
||||
export const noteLinks = sqliteTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: text('source_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
targetNoteId: text('target_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_links_source_idx').on(table.sourceNoteId),
|
||||
index('note_links_target_idx').on(table.targetNoteId)
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
**Markdown representation** — a note-link round-trips through raw markdown as a normal-looking link with a custom URI scheme:
|
||||
|
||||
```
|
||||
[Grocery List](crumb-note://<note-id>)
|
||||
```
|
||||
|
||||
This keeps the existing raw-markdown-mode toggle showing something readable, and keeps exported/copied content sensible outside the app. The title baked into the markdown text is only ever a fallback for external tools — inside the app, parsing recognizes the `crumb-note://` scheme and rebuilds the live node, discarding whatever static text was there.
|
||||
|
||||
**Editor node** — a new tiptap **node** (not a mark) named `NoteLink`, with a single attribute `{ noteId: string }`. Unlike a mark, a node fully owns its own rendering, which is what makes "always show the live title" possible — a mark decorates existing typed text and has no clean way to swap that text later.
|
||||
|
||||
## Components
|
||||
|
||||
### Client
|
||||
|
||||
| Component | Change |
|
||||
|---|---|
|
||||
| `src/lib/components/tiptap/NoteLink.ts` (new) | Inline atomic tiptap node extension. `NodeView` looks up the target's title via `getNote(id)` from `src/lib/sync/idb.ts` — **not** the reactive `notes` writable store, which only holds whatever the current filter (all/archived/trashed) last loaded and would wrongly grey out a valid note just because it's outside the current view. IndexedDB is kept complete for all accessible notes by the existing `/api/sync` incremental sync, independent of which filter is on screen, so it's the correct source of truth here — and it works offline too. Renders a chip (note icon + title), greys out and disables click if the note isn't found. Click opens the note editor overlay for that ID. A small × button is layered on the chip using the app's existing hover-action pattern (`max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100` — always visible on touch, hover-revealed on desktop) that deletes the node from the document without navigating; there's no separate "change target" action, since deleting and re-inserting is simple enough that a dedicated repoint UI isn't worth building. |
|
||||
| `src/lib/components/TiptapEditor.svelte` | Register the `NoteLink` node extension alongside the existing ones. |
|
||||
| `src/lib/components/FormattingToolbar.svelte` | The existing link dropdown gets a note-search results list below the URL input, live-filtered by whatever's typed there (debounced, via `/api/search`, excluding the currently-open note) — no heuristic needed to guess whether the typed text is a URL or a note search; both affordances are just available side by side. Applying the URL input still applies the normal external `Link` mark; picking a search result instead inserts a `NoteLink` node at the cursor (replacing any selection). |
|
||||
| `src/lib/components/NoteEditor.svelte` | New "Referenced by" section, shown when `note.backlinks` is non-empty — a small chip list near where tags render, each chip opens that referencing note. |
|
||||
|
||||
### Server
|
||||
|
||||
| Function | Location | Description |
|
||||
|---|---|---|
|
||||
| `extractNoteLinks(content): string[]` | `src/lib/utils/note-links.ts` (new) | Regex-extracts `crumb-note://<id>` references from markdown content, same code-block/URL exclusions `extractTags` already applies. |
|
||||
| `syncNoteLinks(db, noteId, targetIds, userId)` | `src/lib/server/note-links.ts` (new) | Mirrors `syncNoteTags`: clears existing rows for the note, re-inserts the current set, within the same save transaction. |
|
||||
| `fetchBacklinksForNote(db, noteId, userId)` | `src/lib/server/note-links.ts` (new) | Reverse query on `noteLinks.targetNoteId`, joined through the same access rules as everything else (owner or collaborator on the *source* note), returning `{id, title}` pairs. |
|
||||
|
||||
`getNote()` in `notes-service.ts` calls `fetchBacklinksForNote` and attaches the result as `backlinks`. This is **not** added to `hydrateNotes()` (which also backs list views) — computing backlinks for every note in a list load would be wasted work for something only the open editor displays.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Create** — pick a note in the toolbar's link dropdown → a `NoteLink` node is inserted at the cursor.
|
||||
2. **Save** — content round-trips to markdown as usual; the note-save transaction additionally runs `extractNoteLinks` + `syncNoteLinks`, alongside the existing tag sync.
|
||||
3. **Reopen** — markdown parses back into the doc; a custom parse rule recognizes `crumb-note://` and produces a `NoteLink` node; its `NodeView` looks up the live title via IndexedDB (`getNote(id)`), not the filter-scoped reactive store (see Components).
|
||||
4. **Click** — opens the target note in the editor overlay.
|
||||
5. **Rename** — no action needed anywhere: the title is looked up live at render time, so the next render already shows the new title.
|
||||
6. **Trash / permanent delete / no access** — all three are treated identically: the note simply isn't found in IndexedDB (or, server-side, fails the backlink query's access check), so the link renders inert. The referencing note's content is never rewritten.
|
||||
7. **Public share page** — doesn't use the tiptap editor; content is rendered server-side via `markdown-it`. That render path strips `crumb-note://` links down to plain, non-clickable text, regardless of whether the target is also shared.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Malformed `crumb-note://` reference** during markdown parse falls back to plain text rather than crashing the parse.
|
||||
- **Search race conditions** in the toolbar's note-picker are debounced and guarded against stale results overwriting newer ones, same pattern the existing search feature (`tests/e2e/search.spec.ts`) already covers.
|
||||
- **Self-links** — the currently-open note is filtered out of its own picker results.
|
||||
- **Circular references** (A ↔ B) are a non-issue — clicking only ever opens one note at a time, nothing recurses.
|
||||
- **Cross-user link privacy** — the picker only offers notes you can already access, so under normal use a link can only point at something you had legitimate access to when you created it. Hand-editing raw markdown with a guessed note ID is the only way around that, and note IDs are unguessable UUIDs — the same "if you have the ID, you were meant to have it" trust model the app already applies to session/share tokens. Accepted as a low-severity edge case rather than adding extra access-control plumbing for it.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (Vitest)**
|
||||
- `extractNoteLinks` — extracts IDs, ignores code blocks/URLs, dedupes, handles empty content
|
||||
- `syncNoteLinks` — clears + re-inserts correctly
|
||||
- `fetchBacklinksForNote` — returns only notes the requesting user can access
|
||||
- Markdown round-trip — a `NoteLink` node serializes to `[title](crumb-note://id)` and parses back to the same node with the right `noteId`
|
||||
|
||||
**E2E (Playwright)**
|
||||
- Linking to a note from the toolbar renders a live chip; clicking it opens that note
|
||||
- Renaming the linked note updates the displayed link text next time it's viewed
|
||||
- A link to a trashed/deleted/inaccessible note renders inert and doesn't navigate
|
||||
- The linked-to note shows a "Referenced by" backlink to the note that links to it
|
||||
- A public share page never renders a note-link as clickable, even if the target is also shared
|
||||
- The note-picker excludes the currently-open note from its own search results
|
||||
- Hovering a note-link chip reveals a × button that removes it from the document without navigating; the × is always visible (not hover-gated) on touch devices
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Inline `[[wikilink]]`/`@mention` autocomplete while typing (toolbar-only for now; can be added later without touching the data model)
|
||||
- Note graph / visual map view
|
||||
- Ranking or sorting backlinks beyond simple list order
|
||||
- Cross-user access-control tightening for hand-crafted `crumb-note://` references (see Error Handling)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Link toolbar dropdown supports searching and picking another note, in addition to pasting a URL
|
||||
- [ ] Picking a note inserts a live `NoteLink` node showing the target's current title
|
||||
- [ ] Clicking a note-link opens the target note in the editor overlay
|
||||
- [ ] Renaming a note updates its link's displayed text everywhere, without editing referencing notes' content
|
||||
- [ ] Trashed, deleted, or inaccessible link targets render greyed-out and inert
|
||||
- [ ] Note editor shows a "Referenced by" section listing notes that link to it
|
||||
- [ ] Public share page never renders a note-link as clickable
|
||||
- [ ] Note-links round-trip correctly through raw markdown mode as `[title](crumb-note://id)`
|
||||
- [ ] The link picker excludes the current note and includes both owned and shared-with-you notes
|
||||
- [ ] Hovering a note-link chip reveals a × that removes it without navigating; always visible on touch
|
||||
@@ -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,9 @@
|
||||
CREATE TABLE `note_links` (
|
||||
`source_note_id` text NOT NULL,
|
||||
`target_note_id` text NOT NULL,
|
||||
FOREIGN KEY (`source_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`target_note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_links_source_idx` ON `note_links` (`source_note_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_links_target_idx` ON `note_links` (`target_note_id`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,20 @@
|
||||
"when": 1773150888301,
|
||||
"tag": "0002_deep_black_widow",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1774037379518,
|
||||
"tag": "0003_uneven_tomorrow_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1785824271578,
|
||||
"tag": "0004_violet_nicolaos",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+47
-43
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.22.3",
|
||||
"version": "0.32.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
"homepage": "https://crumbs.bretzel.app",
|
||||
@@ -33,64 +34,64 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/pixelarticons": "^1.2.4",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-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-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@tiptap/core": "^3.29.2",
|
||||
"@tiptap/extension-link": "^3.29.2",
|
||||
"@tiptap/extension-placeholder": "^3.29.2",
|
||||
"@tiptap/extension-table": "^3.29.2",
|
||||
"@tiptap/extension-table-cell": "^3.29.2",
|
||||
"@tiptap/extension-table-header": "^3.29.2",
|
||||
"@tiptap/extension-table-row": "^3.29.2",
|
||||
"@tiptap/extension-task-item": "^3.29.2",
|
||||
"@tiptap/extension-task-list": "^3.29.2",
|
||||
"@tiptap/extension-text-align": "^3.29.2",
|
||||
"@tiptap/extension-underline": "^3.29.2",
|
||||
"@tiptap/pm": "^3.29.2",
|
||||
"@tiptap/starter-kit": "^3.29.2",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"node-diff3": "^3.2.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"markdown-it": "^14.3.0",
|
||||
"nanoid": "^5.1.16",
|
||||
"node-diff3": "^3.2.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"svelte-dnd-action": "^0.9.77",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"uuid": "^11.1.1",
|
||||
"zod": "^4.4.3",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"svelte": "^5.56.8",
|
||||
"svelte-check": "^4.7.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-window": "^7.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.2.7",
|
||||
"workbox-window": "^7.4.1",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -101,8 +102,11 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0",
|
||||
"hono": ">=4.12.7"
|
||||
"cookie": ">=0.7.0 <1",
|
||||
"hono": ">=4.12.7",
|
||||
"devalue": ">=5.8.1",
|
||||
"path-to-regexp@8": ">=8.4.0",
|
||||
"fast-uri": ">=3.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2352
-2083
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
+91
-4
@@ -20,11 +20,45 @@
|
||||
--success-text: #3a5a2e;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #1a1a2e;
|
||||
--accent-check: var(--primary);
|
||||
/* Tint source for hover/active washes. Light mode reuses the ink border, so
|
||||
light styling is byte-identical; dark mode keeps the cream that --border
|
||||
used to supply, because a mid-grey wash is invisible on a dark surface. */
|
||||
--hover-wash: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #141312;
|
||||
--bg-surface: #1e1c1b;
|
||||
--primary: #e0a030;
|
||||
--primary-hover: #f2b855;
|
||||
--text: #ece3d3;
|
||||
--text-muted: #b3a695;
|
||||
--border: #57514b;
|
||||
--border-subtle: #302d2b;
|
||||
--destructive: #e2705c;
|
||||
--error-bg: #3a2320;
|
||||
--error-border: #5c3a34;
|
||||
--error-text: #f0a898;
|
||||
--success-bg: #1f2e1f;
|
||||
--success-text: #8fc97a;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #ece3d3;
|
||||
/* Dimmed so a long done-list of gold checkboxes doesn't shout. */
|
||||
--accent-check: #c8a44a;
|
||||
--hover-wash: #ece3d3;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--primary);
|
||||
accent-color: var(--accent-check);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
body.editor-scroll-locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -33,7 +67,7 @@ body::before {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
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;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -49,6 +83,11 @@ body::before {
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Prose overrides for parchment theme */
|
||||
.prose {
|
||||
--tw-prose-body: var(--text);
|
||||
@@ -116,12 +155,20 @@ body::before {
|
||||
.tiptap ul[data-type="taskList"] li,
|
||||
.prose ul.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
@@ -132,6 +179,7 @@ body::before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Shared checkbox styles */
|
||||
@@ -139,6 +187,7 @@ body::before {
|
||||
.prose ul.task-list .task-checkbox {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
margin-top: 0.2em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -176,3 +225,41 @@ body::before {
|
||||
.prose ul.task-list li p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Checklist link styles */
|
||||
.checklist-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.checklist-link:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
[data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 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
@@ -15,7 +15,9 @@ declare global {
|
||||
} | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
interface PageState {
|
||||
noteEditorOpen?: boolean;
|
||||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -2,8 +2,27 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- interactive-widget=resizes-content: on Android the keyboard shrinks the layout viewport via
|
||||
the native inset path, so bottom-anchored UI (editor toolbar) lands exactly on the keyboard —
|
||||
Chromium's visualViewport.height can under-report by the nav-bar height. iOS ignores this
|
||||
attribute and relies on the visualViewport tracking in NoteEditor instead. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<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 = '#141312';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
|
||||
+3
-1
@@ -8,7 +8,9 @@ const PUBLIC_PATHS = [
|
||||
'/setup',
|
||||
'/api/auth/login',
|
||||
'/api/auth/setup',
|
||||
'/api/auth/oauth'
|
||||
'/api/auth/oauth',
|
||||
'/s/',
|
||||
'/api/shared'
|
||||
];
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
@@ -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 => ({});
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
import { type ChecklistItem, generateId, parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml } from '$lib/utils/checklist.js';
|
||||
import LinkPopover from './LinkPopover.svelte';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
@@ -20,64 +20,106 @@
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
let linkPopover = $state<{ url: string; anchor: DOMRect } | null>(null);
|
||||
|
||||
// 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 doneItems = $derived(items.filter((i) => i.checked));
|
||||
let doneCount = $derived(doneItems.length);
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
interface DoneSectionGroup {
|
||||
parentLabel: ChecklistItem | null;
|
||||
children: ChecklistItem[];
|
||||
}
|
||||
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
||||
return { id: generateId(), text: line, checked: false };
|
||||
});
|
||||
}
|
||||
let doneSectionGroups = $derived.by(() => {
|
||||
const groups: DoneSectionGroup[] = [];
|
||||
const childrenByParent = new Map<string, ChecklistItem[]>();
|
||||
const topLevelDone: ChecklistItem[] = [];
|
||||
|
||||
function serializeChecklist(list: ChecklistItem[]): string {
|
||||
return list
|
||||
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
}
|
||||
for (const item of doneItems) {
|
||||
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 emitChange() {
|
||||
onChange(serializeChecklist(items));
|
||||
const serialized = serializeChecklist(items);
|
||||
lastEmitted = serialized;
|
||||
onChange(serialized);
|
||||
}
|
||||
|
||||
function toggleItem(id: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.checked = !item.checked;
|
||||
emitChange();
|
||||
}
|
||||
items = toggleItemWithCascade(id, items);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function updateText(id: string, text: string) {
|
||||
function updateText(id: string, innerHTML: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = text;
|
||||
item.text = unlinkifyHtml(innerHTML);
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
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 = [...items];
|
||||
emitChange();
|
||||
const newId = newItem.id;
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[afterIndex + 1]?.focus();
|
||||
document.querySelector<HTMLElement>(`[data-item-id="${newId}"]`)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
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);
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
@@ -93,34 +135,179 @@
|
||||
e.preventDefault();
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
const inputs = document.querySelectorAll<HTMLElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLElement>('[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();
|
||||
}
|
||||
}
|
||||
|
||||
// Svelte action: sets innerHTML on mount and syncs from external changes only.
|
||||
// While the user is typing (element is focused), we don't re-render — that would
|
||||
// destroy the cursor. Linkification happens on blur via the onblur handler.
|
||||
function editableContent(node: HTMLElement, text: string) {
|
||||
node.innerHTML = linkifyText(text);
|
||||
return {
|
||||
update(newText: string) {
|
||||
if (document.activeElement !== node) {
|
||||
node.innerHTML = linkifyText(newText);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleLinkClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
linkPopover = { url: anchor.href, anchor: anchor.getBoundingClientRect() };
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData?.getData('text/plain') ?? '').replace(/[\r\n]+/g, ' ');
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(text));
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
(e.target as HTMLElement).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
// --- 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>>) {
|
||||
// Merge reordered active items with done items
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
const reorderedActive = recomputeParentIds(e.detail.items);
|
||||
items = [...reorderedActive, ...items.filter((i) => i.checked)];
|
||||
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>
|
||||
|
||||
<div class="space-y-0.5" data-testid="checklist">
|
||||
<section
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-0.5"
|
||||
>
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<Icon icon="pixelarticons:drag-and-drop" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<div class="group flex items-center gap-2 py-1.5 outline-none {item.parentId ? 'pl-8' : ''}"
|
||||
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)]" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -129,22 +316,37 @@
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
contenteditable="true"
|
||||
use:editableContent={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLElement).innerHTML)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
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"
|
||||
onclick={handleLinkClick}
|
||||
onpaste={handlePaste}
|
||||
onblur={(e) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
if (el.isConnected && !el.contains(document.activeElement)) {
|
||||
el.innerHTML = linkifyText(item.text);
|
||||
}
|
||||
});
|
||||
}}
|
||||
class="flex-1 min-w-0 bg-transparent text-sm outline-none break-words {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
data-placeholder="List item"
|
||||
aria-placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
data-item-id={item.id}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
></div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -155,7 +357,7 @@
|
||||
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
|
||||
<Plus class="h-4 w-4" />
|
||||
Add item
|
||||
</button>
|
||||
|
||||
@@ -166,34 +368,47 @@
|
||||
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-toggle-done"
|
||||
>
|
||||
<Icon icon="pixelarticons:chevron-right" class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
{doneCount} done
|
||||
</button>
|
||||
|
||||
{#if doneExpanded}
|
||||
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
|
||||
{#each doneItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 space-y-0.5" data-testid="checklist-done-section">
|
||||
{#each doneSectionGroups as group}
|
||||
{#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)] break-words min-w-0">{@html linkifyText(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
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if linkPopover}
|
||||
<LinkPopover url={linkPopover.url} anchor={linkPopover.anchor} onClose={() => (linkPopover = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
interface Props {
|
||||
collaborators: Collaborator[];
|
||||
@@ -42,7 +42,7 @@
|
||||
onclick={handleClick}
|
||||
data-testid="collaborator-indicator"
|
||||
>
|
||||
<Icon icon="pixelarticons:users" class="h-4 w-4" />
|
||||
<Users class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if showPopover}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
|
||||
import { COLOR_OPTIONS, getNoteChip } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
@@ -12,11 +13,10 @@
|
||||
|
||||
<div class="flex flex-wrap gap-1" data-testid="color-picker">
|
||||
{#each COLOR_OPTIONS as option}
|
||||
{@const colors = NOTE_COLORS[option.value]}
|
||||
<button
|
||||
onclick={() => onSelect(option.value)}
|
||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
|
||||
style="background-color: {colors.bg}"
|
||||
style="background-color: {getNoteChip(option.value, getIsDarkMode())}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
></button>
|
||||
|
||||
@@ -1,45 +1,140 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextQuote,
|
||||
CodeXml,
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Underline
|
||||
Code,
|
||||
Underline,
|
||||
Link,
|
||||
CornerDownLeft,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown,
|
||||
Table2,
|
||||
BetweenHorizontalEnd,
|
||||
BetweenVerticalEnd,
|
||||
RemoveFormatting
|
||||
} from 'lucide-svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
tick?: number;
|
||||
editor: Editor | undefined;
|
||||
currentNoteId?: string | null;
|
||||
}
|
||||
|
||||
let { editor, tick }: Props = $props();
|
||||
let { editor, tick, currentNoteId = null }: Props = $props();
|
||||
|
||||
function canUndo() { void tick; return editor?.can().chain().focus().undo().run() ?? false; }
|
||||
function canRedo() { void tick; return editor?.can().chain().focus().redo().run() ?? false; }
|
||||
// A stale `editor` reference can still point at an instance mid-teardown (e.g. while
|
||||
// NoteEditor swaps in a fresh Editor across a markdown-mode toggle); calling its
|
||||
// commands after `destroy()` throws, so every accessor here must check isDestroyed too.
|
||||
function canUndo() { void tick; return (!editor?.isDestroyed && editor?.can().chain().focus().undo().run()) ?? false; }
|
||||
function canRedo() { void tick; return (!editor?.isDestroyed && 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) ?? {}; }
|
||||
function isActive(nameOrAttrs: any, attrs?: Record<string, any>): boolean { void tick; return (!editor?.isDestroyed && editor?.isActive(nameOrAttrs, attrs)) ?? false; }
|
||||
function getAttrs(type: string) { void tick; return (!editor?.isDestroyed && editor?.getAttributes(type)) || {}; }
|
||||
|
||||
let openDropdown: string | null = $state(null);
|
||||
let dropdownAnchorEl: HTMLElement | null = $state(null);
|
||||
let linkUrl: string = $state('');
|
||||
let linkInput: HTMLInputElement | undefined = $state();
|
||||
let ignorePositionInvalidationUntil = 0;
|
||||
|
||||
function toggleDropdown(name: string) {
|
||||
let noteSearchResults: { id: string; title: string }[] = $state([]);
|
||||
let noteSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function searchNotesForLink(query: string) {
|
||||
const requestId = ++noteSearchRequestId;
|
||||
if (!query.trim()) {
|
||||
noteSearchResults = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (requestId !== noteSearchRequestId) return;
|
||||
if (!res.ok) return;
|
||||
const results: { id: string; title: string }[] = await res.json();
|
||||
noteSearchResults = results.filter((n) => n.id !== currentNoteId);
|
||||
} catch {
|
||||
// Search failed silently
|
||||
}
|
||||
}
|
||||
|
||||
let noteSearchRequestId = 0;
|
||||
|
||||
function handleLinkInput() {
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchTimer = setTimeout(() => searchNotesForLink(linkUrl), 200);
|
||||
}
|
||||
|
||||
function insertNoteLink(noteId: string) {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().insertContent({ type: 'noteLink', attrs: { noteId } }).run();
|
||||
noteSearchResults = [];
|
||||
closeDropdowns();
|
||||
}
|
||||
|
||||
const dropdownGap = 4;
|
||||
const viewportMargin = 10;
|
||||
|
||||
function toggleDropdown(name: string, anchorEl: HTMLElement) {
|
||||
if (openDropdown === name) {
|
||||
openDropdown = null;
|
||||
closeDropdowns();
|
||||
} else {
|
||||
ignorePositionInvalidationUntil = performance.now() + 600;
|
||||
openDropdown = name;
|
||||
dropdownAnchorEl = anchorEl;
|
||||
if (name === 'link') {
|
||||
linkUrl = editor?.getAttributes('link').href ?? '';
|
||||
// Focus input after Svelte renders the popover
|
||||
requestAnimationFrame(() => linkInput?.focus());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDropdownClick(name: string, event: MouseEvent) {
|
||||
toggleDropdown(name, event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
function preventToolbarMouseFocus(event: MouseEvent) {
|
||||
if (event.button === 0) event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCommandClick(command: () => void) {
|
||||
command();
|
||||
}
|
||||
|
||||
function closeDropdowns() {
|
||||
openDropdown = null;
|
||||
dropdownAnchorEl = null;
|
||||
clearTimeout(noteSearchTimer);
|
||||
noteSearchResults = [];
|
||||
}
|
||||
|
||||
function closeDropdownsAfterPositionChange() {
|
||||
if (!openDropdown || performance.now() < ignorePositionInvalidationUntil) return;
|
||||
closeDropdowns();
|
||||
}
|
||||
|
||||
function getDropdownStyle(rect: DOMRect, width: number): string {
|
||||
const left = Math.max(viewportMargin, Math.min(rect.left, window.innerWidth - width - viewportMargin));
|
||||
// CSS `bottom` on position:fixed and getBoundingClientRect() both use layout-viewport
|
||||
// coordinates, so the reference height must be innerHeight — visualViewport.height is
|
||||
// smaller while the keyboard is open and would push the dropdown behind the keyboard.
|
||||
const bottom = window.innerHeight - rect.top + dropdownGap;
|
||||
return `bottom: ${bottom}px; left: ${left}px;`;
|
||||
}
|
||||
|
||||
function applyLink() {
|
||||
@@ -66,15 +161,15 @@
|
||||
const chevronSize = 14;
|
||||
|
||||
function btnClass(active: boolean = false, disabled: boolean = false): string {
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownBtnClass(active: boolean = false): string {
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 ${active ? 'text-[var(--primary)] bg-[var(--hover-wash)]/10' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownItemClass(active: boolean = false): string {
|
||||
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
|
||||
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/5 ${active ? 'bg-[var(--hover-wash)]/5' : ''}`;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
@@ -90,39 +185,56 @@
|
||||
closeDropdowns();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!openDropdown || !window.visualViewport) return;
|
||||
|
||||
window.visualViewport.addEventListener('resize', closeDropdownsAfterPositionChange);
|
||||
window.visualViewport.addEventListener('scroll', closeDropdownsAfterPositionChange);
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener('resize', closeDropdownsAfterPositionChange);
|
||||
window.visualViewport?.removeEventListener('scroll', closeDropdownsAfterPositionChange);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
<svelte:window onscroll={closeDropdownsAfterPositionChange} onresize={closeDropdownsAfterPositionChange} />
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
class="flex overflow-x-auto md:flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1 scrollbar-hide"
|
||||
style="scrollbar-width: none; -ms-overflow-style: none;"
|
||||
data-testid="formatting-toolbar"
|
||||
onscroll={closeDropdownsAfterPositionChange}
|
||||
>
|
||||
<!-- Undo/Redo -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().undo().run())}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo"
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" width={iconSize} />
|
||||
<Undo2 size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().redo().run())}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo"
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Icon icon="pixelarticons:redo" width={iconSize} />
|
||||
<Redo2 size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBold().run())}
|
||||
class={btnClass(isActive('bold'))}
|
||||
title="Bold (Ctrl+B)"
|
||||
data-testid="format-bold"
|
||||
@@ -130,7 +242,8 @@
|
||||
<Bold size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleItalic().run())}
|
||||
class={btnClass(isActive('italic'))}
|
||||
title="Italic (Ctrl+I)"
|
||||
data-testid="format-italic"
|
||||
@@ -138,7 +251,8 @@
|
||||
<Italic size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleStrike().run())}
|
||||
class={btnClass(isActive('strike'))}
|
||||
title="Strikethrough (Ctrl+Shift+X)"
|
||||
data-testid="format-strikethrough"
|
||||
@@ -146,7 +260,8 @@
|
||||
<Strikethrough size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleUnderline().run())}
|
||||
class={btnClass(isActive('underline'))}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
@@ -157,304 +272,376 @@
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<div data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('heading', e)}
|
||||
class={dropdownBtnClass(isActive('heading'))}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
<Icon icon="pixelarticons:heading" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
||||
data-testid="format-h1"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||
<span class="font-semibold">Heading 1</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
||||
data-testid="format-h2"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||
<span>Heading 2</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
||||
data-testid="format-h3"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
||||
<span>Heading 3</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- List dropdown -->
|
||||
<div class="relative" data-dropdown="list">
|
||||
<div data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('list', e)}
|
||||
class={dropdownBtnClass(isActive('bulletList') || isActive('orderedList') || isActive('taskList'))}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:list" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:list" width={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:bulletlist" width={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleTaskList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
data-testid="format-task-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:checklist" width={iconSize} />
|
||||
<span>Task list</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Link popover -->
|
||||
<div class="relative" data-dropdown="link">
|
||||
<div data-dropdown="link">
|
||||
<button
|
||||
onclick={() => toggleDropdown('link')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('link', e)}
|
||||
class={btnClass(isActive('link'))}
|
||||
title="Insert link (Ctrl+K)"
|
||||
data-testid="format-link"
|
||||
>
|
||||
<Icon icon="pixelarticons:link" width={iconSize} />
|
||||
<Link size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'link'}
|
||||
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
<form
|
||||
class="flex items-center gap-1"
|
||||
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
|
||||
>
|
||||
<input
|
||||
bind:this={linkInput}
|
||||
bind:value={linkUrl}
|
||||
type="url"
|
||||
placeholder="Paste a link..."
|
||||
class="w-44 bg-transparent px-1.5 py-1 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="format-link-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
|
||||
title="Apply link"
|
||||
data-testid="format-link-apply"
|
||||
>
|
||||
<Icon icon="pixelarticons:corner-down-left" width={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
<Icon icon="pixelarticons:external-link" width={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" width={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Table dropdown -->
|
||||
<div class="relative" data-dropdown="table">
|
||||
<div data-dropdown="table">
|
||||
<button
|
||||
onclick={() => toggleDropdown('table')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('table', e)}
|
||||
class={dropdownBtnClass(isActive('table'))}
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
>
|
||||
<Icon icon="pixelarticons:table" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<Table2 size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'table'}
|
||||
<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
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-insert"
|
||||
>
|
||||
<Icon icon="pixelarticons:table" width={iconSize} />
|
||||
<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"
|
||||
>
|
||||
<Icon icon="pixelarticons:add-row" width={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-col"
|
||||
>
|
||||
<Icon icon="pixelarticons:add-col" width={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-row"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-col"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-table-delete"
|
||||
>
|
||||
<Icon icon="pixelarticons:remove-box" width={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Alignment dropdown -->
|
||||
<div class="relative" data-dropdown="align">
|
||||
<div data-dropdown="align">
|
||||
<button
|
||||
onclick={() => toggleDropdown('align')}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={(e) => handleDropdownClick('align', e)}
|
||||
class={dropdownBtnClass(isActive({ textAlign: 'center' }) || isActive({ textAlign: 'right' }) || isActive({ textAlign: 'justify' }))}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
>
|
||||
{#if isActive({ textAlign: 'center' })}
|
||||
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
|
||||
<AlignCenter size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'right' })}
|
||||
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
|
||||
<AlignRight size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'justify' })}
|
||||
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
|
||||
<AlignJustify size={iconSize} />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
|
||||
<AlignLeft size={iconSize} />
|
||||
{/if}
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<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
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
|
||||
<span>Justify</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</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()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCode().run())}
|
||||
class={btnClass(isActive('code'))}
|
||||
title="Inline code"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Icon icon="pixelarticons:braces" width={iconSize} />
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleBlockquote().run())}
|
||||
class={btnClass(isActive('blockquote'))}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().toggleCodeBlock().run())}
|
||||
class={btnClass(isActive('codeBlock'))}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<Icon icon="pixelarticons:braces" width={iconSize} />
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => handleCommandClick(() => editor?.chain().focus().setHorizontalRule().run())}
|
||||
class={btnClass()}
|
||||
title="Divider"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<Minus size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Render dropdown menus in a fixed portal so they aren't clipped by overflow-x-auto -->
|
||||
{#if openDropdown && dropdownAnchorEl}
|
||||
{@const rect = dropdownAnchorEl.getBoundingClientRect()}
|
||||
|
||||
{#if openDropdown === 'heading'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 170)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 1 }))}
|
||||
data-testid="format-h1"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||
<span class="font-semibold">Heading 1</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 2 }))}
|
||||
data-testid="format-h2"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||
<span>Heading 2</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('heading', { level: 3 }))}
|
||||
data-testid="format-h3"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
||||
<span>Heading 3</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if openDropdown === 'list'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 190)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
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>
|
||||
{:else if openDropdown === 'link'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[200px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5"
|
||||
style={getDropdownStyle(rect, 220)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<form
|
||||
class="flex items-center gap-1"
|
||||
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
|
||||
>
|
||||
<input
|
||||
bind:this={linkInput}
|
||||
bind:value={linkUrl}
|
||||
oninput={handleLinkInput}
|
||||
type="url"
|
||||
placeholder="Paste a link..."
|
||||
class="w-44 bg-transparent px-1.5 py-1 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="format-link-input"
|
||||
/>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
type="submit"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)]"
|
||||
title="Apply link"
|
||||
data-testid="format-link-apply"
|
||||
>
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!getAttrs('link').href}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!isActive('link')}
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--hover-wash)]/5 hover:text-[var(--destructive)] disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-link-remove"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
{#if noteSearchResults.length > 0}
|
||||
<div class="mt-1 max-h-40 overflow-y-auto border-t border-[var(--border-subtle)] pt-1">
|
||||
{#each noteSearchResults as result (result.id)}
|
||||
<button
|
||||
type="button"
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => insertNoteLink(result.id)}
|
||||
class="block w-full truncate rounded-sm px-1.5 py-1 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="format-link-note-result"
|
||||
>
|
||||
{result.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if openDropdown === 'table'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 210)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-insert-table"
|
||||
>
|
||||
<Table2 size={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
{#if isActive('table')}
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().addRowAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-add-row"
|
||||
>
|
||||
<BetweenHorizontalEnd size={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().addColumnAfter().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-add-column"
|
||||
>
|
||||
<BetweenVerticalEnd size={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().deleteRow().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-delete-row"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().deleteColumn().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-delete-column"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().deleteTable().run(); closeDropdowns(); }}
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-delete-table"
|
||||
>
|
||||
<RemoveFormatting size={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if openDropdown === 'align'}
|
||||
<div
|
||||
class="fixed z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1"
|
||||
style={getDropdownStyle(rect, 180)}
|
||||
data-dropdown="true"
|
||||
>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
onmousedown={preventToolbarMouseFocus}
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<AlignJustify size={iconSize} />
|
||||
<span>Justify</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[data-testid="formatting-toolbar"]::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
@@ -46,7 +46,7 @@
|
||||
aria-label="Close lightbox"
|
||||
data-testid="lightbox-close"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-6 w-6" />
|
||||
<XIcon class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||
@@ -131,7 +133,7 @@
|
||||
</button>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<Icon icon="pixelarticons:reload" class="h-3 w-3 text-white" />
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
</div>
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
@@ -140,7 +142,7 @@
|
||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||
data-testid="toggle-featured"
|
||||
>
|
||||
<Icon icon="pixelarticons:pin" class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
@@ -149,7 +151,7 @@
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-3 w-3" />
|
||||
<XIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle: () => void;
|
||||
@@ -20,10 +21,10 @@
|
||||
{:else}
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />
|
||||
<Menu class="h-6 w-6 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -39,10 +40,10 @@
|
||||
<!-- Mobile search icon -->
|
||||
<button
|
||||
onclick={() => (mobileSearchOpen = true)}
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 lg:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />
|
||||
<Search class="h-5 w-5 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -17,9 +17,9 @@
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
|
||||
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
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)]'}"
|
||||
>
|
||||
<Icon icon={item.icon} width={20} />
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
@@ -62,7 +62,7 @@
|
||||
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)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:label" width={16} />
|
||||
<Tag size={16} />
|
||||
#{tag}
|
||||
</a>
|
||||
</li>
|
||||
@@ -78,7 +78,7 @@
|
||||
onclick={closeMobile}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<Icon icon="pixelarticons:settings-cog" width={20} />
|
||||
<Settings size={20} />
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
anchor: DOMRect;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { url, anchor, onClose }: Props = $props();
|
||||
|
||||
let popoverEl: HTMLDivElement | undefined = $state();
|
||||
let top = $state(0);
|
||||
let left = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
if (!popoverEl) return;
|
||||
const rect = popoverEl.getBoundingClientRect();
|
||||
|
||||
// Position below the anchor link, or above if near viewport bottom
|
||||
const spaceBelow = window.innerHeight - anchor.bottom;
|
||||
if (spaceBelow >= rect.height + 8) {
|
||||
top = anchor.bottom + 4;
|
||||
} else {
|
||||
top = anchor.top - rect.height - 4;
|
||||
}
|
||||
|
||||
left = anchor.left;
|
||||
// Clamp to viewport
|
||||
if (left + rect.width > window.innerWidth - 8) {
|
||||
left = window.innerWidth - rect.width - 8;
|
||||
}
|
||||
if (left < 8) left = 8;
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (popoverEl && !popoverEl.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function openLink() {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} onscroll={onClose} onresize={onClose} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={popoverEl}
|
||||
class="fixed z-50 flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-1.5 shadow-[var(--card-shadow)]"
|
||||
style="top: {top}px; left: {left}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
data-testid="link-popover"
|
||||
>
|
||||
<button
|
||||
onclick={openLink}
|
||||
class="flex items-center gap-1 text-xs text-[var(--primary)] hover:text-[var(--primary-hover)]"
|
||||
data-testid="link-popover-open"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,15 +1,24 @@
|
||||
<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 { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import CollaboratorPopover from './CollaboratorPopover.svelte';
|
||||
import SharingIndicator from './SharingIndicator.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { linkifyText } from '$lib/utils/checklist.js';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
indented: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -21,8 +30,7 @@
|
||||
let { note, onEdit, fullHeight = false }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[note.color];
|
||||
cardStyle = `background-color: ${colors.bg}`;
|
||||
cardStyle = `background-color: ${getNoteColor(note.color, getIsDarkMode())}`;
|
||||
});
|
||||
|
||||
let cardStyle = $state('');
|
||||
@@ -33,10 +41,14 @@
|
||||
|
||||
const checklistItems = $derived<ChecklistItem[]>(
|
||||
note.checklistMode
|
||||
? note.content.split('\n').filter(l => l.trim()).map(line => ({
|
||||
text: line.replace(/^- \[[ x]\] /, ''),
|
||||
checked: line.startsWith('- [x] ')
|
||||
}))
|
||||
? note.content.split('\n').filter(l => l.trim()).map(line => {
|
||||
const indented = line.startsWith(' - [');
|
||||
return {
|
||||
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
|
||||
checked: /^ {0,2}- \[x\] /.test(line),
|
||||
indented
|
||||
};
|
||||
})
|
||||
: []
|
||||
);
|
||||
|
||||
@@ -57,7 +69,7 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<article
|
||||
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)] {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}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
@@ -96,20 +108,21 @@
|
||||
|
||||
<!-- Status indicators (top-right) -->
|
||||
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
|
||||
{#if note.isShared && note.collaborators}
|
||||
<CollaboratorPopover
|
||||
collaborators={note.collaborators}
|
||||
ownerName={note.isOwner ? 'You' : undefined}
|
||||
{#if note.shareToken || (note.isShared && note.collaborators)}
|
||||
<SharingIndicator
|
||||
shareToken={note.shareToken}
|
||||
collaborators={note.collaborators ?? []}
|
||||
isOwner={note.isOwner ?? true}
|
||||
/>
|
||||
{/if}
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unfavorite"
|
||||
data-testid="favorite-indicator"
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Unpin"}
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -119,9 +132,13 @@
|
||||
{/if}
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview"
|
||||
onclick={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
|
||||
onkeydown={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-start 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
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
@@ -129,7 +146,7 @@
|
||||
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"
|
||||
/>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if checklistItems.length > 8}
|
||||
@@ -147,67 +164,67 @@
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Restore"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Restore"}
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" class="h-4 w-4" />
|
||||
<Undo2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete forever"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Delete forever"}
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />
|
||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||
</button>
|
||||
{:else}
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Favorite"
|
||||
data-testid="favorite-btn"
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Pin"}
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:heart" class="h-4 w-4" />
|
||||
<Bookmark class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $currentFilter === 'archived'}
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Unarchive"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Unarchive"}
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
|
||||
<ArchiveRestore class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => archiveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Archive"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
|
||||
<Archive class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if note.isShared && !note.isOwner}
|
||||
<button
|
||||
onclick={stop(() => leaveNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Leave note"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Leave note"}
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Trash"}
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" class="h-4 w-4" />
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script module lang="ts">
|
||||
let bodyScrollLockCount = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { pushState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
@@ -7,21 +13,40 @@
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import NoteHistory from './NoteHistory.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { updateNote, createNote, trashNote, archiveNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.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 { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { Note, NoteColor, Attachment, Collaborator, NoteBacklink } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
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 ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
isNew?: boolean;
|
||||
initialChecklistMode?: boolean;
|
||||
onClose: () => void;
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
const { note, isNew = false, onClose }: Props = $props();
|
||||
const { note, isNew = false, initialChecklistMode = false, onClose, onOpenNote }: Props = $props();
|
||||
const prefs = getPreferences();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -31,7 +56,7 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let color = $state<NoteColor>(note?.color ?? prefs.defaultNoteColor);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let checklistMode = $state(note?.checklistMode ?? initialChecklistMode);
|
||||
let showColorPicker = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -46,30 +71,212 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
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);
|
||||
});
|
||||
|
||||
// Safety net for note-link/backlink navigation: NotesView.svelte remounts this
|
||||
// component (rather than reusing the instance) whenever the user switches to a
|
||||
// different note while the editor is open. That teardown skips the debounced
|
||||
// autosave timer's cleanup (it only clears the timer, it doesn't flush) and
|
||||
// beforeunload (which never fires for an in-SPA unmount), so a very recent edit
|
||||
// could otherwise be silently dropped.
|
||||
onDestroy(() => {
|
||||
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||
performSave();
|
||||
}
|
||||
});
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let backlinksList = $state<NoteBacklink[]>(note?.backlinks ?? []);
|
||||
|
||||
let viewportHeight = $state('100dvh');
|
||||
let viewportTop = $state('0px');
|
||||
|
||||
// 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(() => {
|
||||
function updateViewport() {
|
||||
if (window.visualViewport) {
|
||||
viewportHeight = `${window.visualViewport.height}px`;
|
||||
viewportTop = `${window.visualViewport.offsetTop}px`;
|
||||
} else {
|
||||
viewportHeight = `${window.innerHeight}px`;
|
||||
viewportTop = '0px';
|
||||
}
|
||||
}
|
||||
|
||||
updateViewport();
|
||||
const handleVisualViewportChange = () => {
|
||||
requestAnimationFrame(updateViewport);
|
||||
};
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleVisualViewportChange);
|
||||
window.visualViewport.addEventListener('scroll', handleVisualViewportChange);
|
||||
} else {
|
||||
window.addEventListener('resize', handleVisualViewportChange);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleVisualViewportChange);
|
||||
window.visualViewport.removeEventListener('scroll', handleVisualViewportChange);
|
||||
} else {
|
||||
window.removeEventListener('resize', handleVisualViewportChange);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Lock body scroll while editor is open. Multiple editor instances can overlap during
|
||||
// the ghost-click grace period, so keep the class until the last instance unmounts.
|
||||
$effect(() => {
|
||||
bodyScrollLockCount += 1;
|
||||
document.body.classList.add('editor-scroll-locked');
|
||||
return () => {
|
||||
bodyScrollLockCount = Math.max(0, bodyScrollLockCount - 1);
|
||||
if (bodyScrollLockCount === 0) {
|
||||
document.body.classList.remove('editor-scroll-locked');
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
let showOverflowMenu = $state(false);
|
||||
let desktopOverflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let mobileOverflowBtnEl: HTMLButtonElement | undefined = $state();
|
||||
let overflowAnchorEl: 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 (overflowAnchorEl?.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;
|
||||
// Update the note in the store
|
||||
if (noteId) {
|
||||
notes.update((list) =>
|
||||
list.map((n) =>
|
||||
@@ -81,6 +288,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -120,20 +340,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Guarded against post-teardown writes: since NotesView.svelte now remounts this
|
||||
// component on every note-switch (rather than reusing the instance), a fetch that
|
||||
// resolves after teardown would otherwise write to $state on an already-destroyed
|
||||
// instance racing the newly-mounted one.
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}/attachments`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: Attachment[]) => {
|
||||
if (destroyed) return;
|
||||
if (data.length > 0) attachmentsList = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch backlinks for existing notes if not pre-populated (e.g. loaded from IDB) —
|
||||
// note.backlinks is only ever attached server-side by getNote(), which nothing
|
||||
// else in the client calls when opening a note (the bulk notes store and
|
||||
// IndexedDB never carry it), so this mirrors the attachments effect above to
|
||||
// fill it in on open. Same post-teardown guard as above.
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.backlinks || note.backlinks.length === 0)) {
|
||||
let destroyed = false;
|
||||
fetch(`/api/notes/${noteId}`)
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data: Note | null) => {
|
||||
if (destroyed) return;
|
||||
if (data?.backlinks && data.backlinks.length > 0) backlinksList = data.backlinks;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -145,9 +401,14 @@
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
const updated = attachmentsList.map((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) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
@@ -160,7 +421,11 @@
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
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) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
@@ -168,15 +433,14 @@
|
||||
|
||||
let bgStyle = $state('');
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[color];
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||
});
|
||||
|
||||
// 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.
|
||||
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
|
||||
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
|
||||
// pass through to NoteCards underneath (ghost click).
|
||||
let pointerdownOnOverlay = false;
|
||||
let closing = $state(false);
|
||||
|
||||
function handleOverlayPointerdown(e: PointerEvent) {
|
||||
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||
@@ -187,35 +451,63 @@
|
||||
pointerdownOnOverlay = false;
|
||||
}
|
||||
|
||||
// Close on browser back — Android back button / iOS back swipe. The editor is
|
||||
// fullscreen on mobile with no backdrop to tap, so back must dismiss it instead of
|
||||
// leaving the page. On mount, push a shallow-routing history entry; navigating back
|
||||
// pops it and closes the editor. UI closes pop the entry themselves via
|
||||
// history.back() so no stale entry is left behind.
|
||||
let historyEntryPushed = false;
|
||||
|
||||
onMount(() => {
|
||||
// Reuse an existing entry (e.g. back-navigating to a note restored from the
|
||||
// URL hash) instead of stacking a second one.
|
||||
if (!page.state.noteEditorOpen) {
|
||||
pushState('', { noteEditorOpen: true });
|
||||
}
|
||||
historyEntryPushed = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (page.state.noteEditorOpen || !historyEntryPushed || closing) return;
|
||||
historyEntryPushed = false;
|
||||
saveAndClose();
|
||||
});
|
||||
|
||||
function dismissOverlay() {
|
||||
closing = true;
|
||||
if (historyEntryPushed) {
|
||||
historyEntryPushed = false;
|
||||
history.back();
|
||||
}
|
||||
setTimeout(() => onClose(), 400);
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
clearTimeout(autoSaveTimer);
|
||||
// Wait for any in-flight auto-save to finish
|
||||
if (savingPromise) await savingPromise;
|
||||
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
// Final save if auto-save hasn't caught up yet
|
||||
if (hasUnsavedChanges()) {
|
||||
await performSave();
|
||||
}
|
||||
onClose();
|
||||
dismissOverlay();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
clearTimeout(autoSaveTimer);
|
||||
if (savingPromise) await savingPromise;
|
||||
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||
if (!title.trim()) title = 'Untitled';
|
||||
await performSave();
|
||||
return noteId;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
@@ -243,41 +535,94 @@
|
||||
requestAnimationFrame(() => textareaEl?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await archiveNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrash() {
|
||||
showOverflowMenu = false;
|
||||
if (noteId) {
|
||||
await trashNote(noteId);
|
||||
dismissOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
class="fixed left-0 w-full z-40 {closing ? '' : 'flex md:items-start justify-center md:overflow-y-auto md:bg-black/50 md:pt-20 md:pb-10 animate-[fade-in_150ms_ease-out]'}"
|
||||
style="top: {viewportTop}; height: {viewportHeight};"
|
||||
onpointerdown={handleOverlayPointerdown}
|
||||
onpointerup={handleOverlayPointerup}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
{#if !closing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
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]"
|
||||
class="relative flex h-full w-full flex-col md:overflow-hidden border-0 md:h-auto md:max-w-xl md:mx-4 lg:max-w-2xl md:rounded-sm md:border md:border-[var(--border)] md:shadow-[var(--card-shadow)] animate-[slide-up_150ms_ease-out] md:animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
data-testid="note-editor"
|
||||
>
|
||||
<!-- Title -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<!-- Gap filler to hide iOS Safari keyboard animation/viewport lag -->
|
||||
<div class="absolute top-full left-0 w-full h-[50vh] md:hidden" style={bgStyle}></div>
|
||||
<!-- Header (Mobile: Back + Title, Desktop: just Title) -->
|
||||
<div class="flex items-center gap-2 border-b md:border-b-0 border-[var(--border-subtle)] px-2 py-2 md:px-4 md:pt-4 md:pb-0 shrink-0 touch-none">
|
||||
<!-- Mobile Back Button -->
|
||||
<button
|
||||
onclick={saveAndClose}
|
||||
class="md:hidden rounded-sm p-2 text-[var(--text)] hover:bg-[var(--hover-wash)]/10 flex items-center shrink-0"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft class="h-6 w-6" />
|
||||
</button>
|
||||
<!-- Title -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="flex-1 min-w-0 bg-transparent px-2 md:px-0 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (textareaEl) textareaEl.focus();
|
||||
else if (tiptapEditor) tiptapEditor.commands.focus('start');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Mobile Overflow Trigger -->
|
||||
<button
|
||||
bind:this={mobileOverflowBtnEl}
|
||||
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = mobileOverflowBtnEl; }}
|
||||
class="md:hidden rounded-sm p-2 hover:bg-[var(--hover-wash)]/10 text-[var(--text)] shrink-0"
|
||||
data-testid="mobile-overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain md:max-h-[60vh] md:flex-none">
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
@@ -287,7 +632,7 @@
|
||||
bind:this={textareaEl}
|
||||
placeholder="Add a crumb..."
|
||||
bind:value={content}
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-base md:text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
rows="12"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
@@ -297,6 +642,7 @@
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
{onOpenNote}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
@@ -304,7 +650,7 @@
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#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 shrink-0">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
attachments={attachmentsList}
|
||||
@@ -316,16 +662,35 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
<!-- Backlinks -->
|
||||
{#if backlinksList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2 shrink-0">
|
||||
<p class="mb-1 text-xs font-medium text-[var(--text-muted)]">Referenced by</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each backlinksList as backlink (backlink.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onOpenNote(backlink.id)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs text-[var(--text)] hover:border-[var(--primary)]"
|
||||
data-testid="backlink-chip"
|
||||
>
|
||||
{backlink.title || 'Untitled'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div class="shrink-0 touch-pan-x" style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} currentNoteId={noteId} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar (Desktop only) -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
|
||||
class="hidden md:flex shrink-0 items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2 touch-none"
|
||||
style={toolbarInteractive ? '' : 'pointer-events: none'}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -333,11 +698,11 @@
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Background color"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Background color"}
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<Icon icon="pixelarticons:colors-swatch" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
{#if showColorPicker}
|
||||
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
|
||||
@@ -346,87 +711,216 @@
|
||||
{/if}
|
||||
</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}
|
||||
<Icon icon="pixelarticons:art-text" class="h-5 w-5" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:checkbox-on" class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Image attachments"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Attachments"}
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<Icon icon="pixelarticons:image" class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<Icon icon="pixelarticons:file-text" class="h-5 w-5" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:script" class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||
<!-- Share button -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={toggleShareDialog}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Share note"
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Share"}
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if isShared}
|
||||
<Icon icon="pixelarticons:users" class="h-5 w-5 text-[var(--primary)]" />
|
||||
{#if hasPublicLink}
|
||||
<Globe class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if isShared}
|
||||
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:user-plus" class="h-5 w-5" />
|
||||
<UserPlus class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||
<Icon icon="pixelarticons:users" class="h-5 w-5" />
|
||||
<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}
|
||||
|
||||
<!-- History button (only for saved notes) -->
|
||||
<!-- Archive button (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
onclick={handleArchive}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"Archive"}
|
||||
data-testid="archive-note-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:timeline" class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
<Archive class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow menu trigger -->
|
||||
<button
|
||||
bind:this={desktopOverflowBtnEl}
|
||||
onclick={() => { showOverflowMenu = !showOverflowMenu; overflowAnchorEl = desktopOverflowBtnEl; }}
|
||||
class="rounded-sm p-2 hover:bg-[var(--hover-wash)]/10"
|
||||
use:tooltip={"More"}
|
||||
data-testid="overflow-menu-btn"
|
||||
>
|
||||
<EllipsisVertical class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={saveAndClose}
|
||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
class="hidden md:block rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overflow menu dropdown (rendered outside the card to escape overflow-hidden) -->
|
||||
{#if showOverflowMenu && overflowAnchorEl}
|
||||
{@const rect = overflowAnchorEl.getBoundingClientRect()}
|
||||
{@const isMobileTrigger = overflowAnchorEl === mobileOverflowBtnEl}
|
||||
<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={isMobileTrigger ? `top: ${rect.bottom + 4}px; right: ${window.innerWidth - rect.right}px;` : `bottom: ${window.innerHeight - rect.top + 4}px; right: ${window.innerWidth - rect.right}px;`}
|
||||
data-testid="overflow-menu"
|
||||
>
|
||||
<!-- Mobile-only actions -->
|
||||
{#if isMobileTrigger}
|
||||
<!-- Color picker -->
|
||||
<div class="px-3 py-2 w-56 whitespace-normal">
|
||||
<span class="mb-2 block text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider">Color</span>
|
||||
<ColorPicker selected={color} onSelect={(c) => { handleColorSelect(c); showOverflowMenu = false; }} />
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<button
|
||||
onclick={() => { toggleImageUpload(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
<ImageIcon class="h-4 w-4 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
Attachments
|
||||
</button>
|
||||
|
||||
<!-- Share -->
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={() => { toggleShareDialog(); showOverflowMenu = false; }}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
{#if hasPublicLink}
|
||||
<Globe class="h-4 w-4 text-[var(--primary)]" />
|
||||
{:else if isShared}
|
||||
<Users class="h-4 w-4 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<UserPlus class="h-4 w-4" />
|
||||
{/if}
|
||||
Share
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<div class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)]">
|
||||
<Users class="h-4 w-4" />
|
||||
Shared note
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Archive -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
<Archive class="h-4 w-4" />
|
||||
Archive
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="my-1 border-t border-[var(--border-subtle)]"></div>
|
||||
{/if}
|
||||
<!-- 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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/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(--hover-wash)]/10"
|
||||
data-testid="uncheck-all-btn"
|
||||
>
|
||||
<ListX class="h-4 w-4" />
|
||||
Uncheck all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showShareDialog && noteId}
|
||||
@@ -434,8 +928,10 @@
|
||||
noteId={noteId}
|
||||
collaborators={collaboratorsList}
|
||||
{ownerName}
|
||||
shareToken={currentShareToken}
|
||||
onClose={() => (showShareDialog = false)}
|
||||
onUpdate={handleCollaboratorsUpdate}
|
||||
onShareUpdate={handleShareUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
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 {
|
||||
@@ -109,16 +111,16 @@
|
||||
<!-- 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">
|
||||
<Icon icon="pixelarticons:clock" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<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"
|
||||
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
|
||||
title="Close history"
|
||||
data-testid="close-history-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<X class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +140,7 @@
|
||||
{#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)]' : ''}"
|
||||
class="w-full px-3 py-3 text-left hover:bg-[var(--hover-wash)]/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"
|
||||
>
|
||||
@@ -188,12 +190,12 @@
|
||||
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"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
|
||||
<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"
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--hover-wash)]/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -205,7 +207,7 @@
|
||||
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"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
Restore this version
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import { tooltip } from '$lib/utils/tooltip.js';
|
||||
import { getNote as getIdbNote } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
@@ -16,6 +20,7 @@
|
||||
|
||||
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(() => {
|
||||
@@ -24,15 +29,33 @@
|
||||
loadNotes(filter);
|
||||
});
|
||||
|
||||
// Use SvelteKit's replaceState (not the raw history API) so the router keeps
|
||||
// ownership of the entry — NoteEditor's close-on-back relies on popstate being
|
||||
// handled by SvelteKit.
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
replaceState(`#${note.id}`, {});
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
newNoteChecklist = false;
|
||||
replaceState(location.pathname, {});
|
||||
}
|
||||
|
||||
// Reads from the local IndexedDB cache rather than fetching over the network —
|
||||
// this keeps note-link navigation instant and offline-safe (matching how the
|
||||
// rest of the app opens notes), and backlinks are filled in progressively by
|
||||
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
|
||||
// the same pattern already used there for attachments.
|
||||
let openNoteRequestId = 0;
|
||||
|
||||
async function openNoteById(noteId: string) {
|
||||
const requestId = ++openNoteRequestId;
|
||||
const note = await getIdbNote(noteId);
|
||||
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
|
||||
if (note) openEditor(note);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
@@ -55,14 +78,24 @@
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<div class="mx-auto mb-6 hidden max-w-xl md:block">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
<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)}
|
||||
@@ -70,7 +103,7 @@
|
||||
aria-label="Add a crumb"
|
||||
data-testid="new-note-fab"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" class="h-5 w-5" />
|
||||
<Plus class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -86,22 +119,22 @@
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
|
||||
{/if}
|
||||
|
||||
{#if $favoriteNotes.length > 0}
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unfavoritedNotes}
|
||||
label={$favoriteNotes.length > 0 ? 'Others' : ''}
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
@@ -119,9 +152,17 @@
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
<NoteEditor note={null} isNew={true} initialChecklistMode={newNoteChecklist} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
|
||||
while the editor is already open) instead of reusing the same instance
|
||||
with a new `note` prop — a persisting instance's own back-navigation
|
||||
effect misreads `openEditor`'s replaceState call as "browser back was
|
||||
pressed" and closes the editor, and its once-only local state never
|
||||
resyncs to the new note otherwise. -->
|
||||
{#key editingNote.id}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { searchQuery, searchResults } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
@@ -10,41 +13,64 @@
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let originalNotes: Note[] = [];
|
||||
let isSearching = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let searchController: AbortController | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (onClose) inputEl?.focus();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
searchController?.abort();
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) {
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (!trimmedQuery) {
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSearching) {
|
||||
notes.subscribe((n) => (originalNotes = n))();
|
||||
isSearching = true;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const results = await res.json();
|
||||
notes.set(results);
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (res.ok) {
|
||||
const results: Note[] = await res.json();
|
||||
if (searchController !== controller || query.trim() !== trimmedQuery) return;
|
||||
// Commit the query alongside its results so the view switches to the
|
||||
// (possibly empty) result set only once it's ready — this avoids
|
||||
// flashing an empty state over the current notes while the request is
|
||||
// in flight, and keeps searchQuery consistent with searchResults on
|
||||
// a failed request (neither updates).
|
||||
searchResults.set(results);
|
||||
searchQuery.set(trimmedQuery);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
} finally {
|
||||
if (searchController === controller) searchController = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchController?.abort();
|
||||
searchController = undefined;
|
||||
query = '';
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
searchQuery.set('');
|
||||
searchResults.set([]);
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -57,10 +83,10 @@
|
||||
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
|
||||
{#if onClose}
|
||||
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
|
||||
<Icon icon="pixelarticons:arrow-left" class="h-5 w-5" />
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:search" class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
<Search class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
@@ -73,7 +99,7 @@
|
||||
/>
|
||||
{#if query}
|
||||
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
|
||||
<Icon icon="pixelarticons:close" class="h-5 w-5" />
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
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;
|
||||
@@ -13,17 +20,65 @@
|
||||
noteId: string;
|
||||
collaborators: Collaborator[];
|
||||
ownerName: string;
|
||||
shareToken?: string;
|
||||
onClose: () => void;
|
||||
onUpdate: (collaborators: Collaborator[]) => void;
|
||||
onShareUpdate: (token: string | null) => void;
|
||||
}
|
||||
|
||||
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
|
||||
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()) {
|
||||
@@ -36,7 +91,6 @@
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const users: UserResult[] = await res.json();
|
||||
// Filter out existing collaborators
|
||||
const collabIds = new Set(collaborators.map((c) => c.userId));
|
||||
searchResults = users.filter((u) => !collabIds.has(u.id));
|
||||
}
|
||||
@@ -114,17 +168,71 @@
|
||||
<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"
|
||||
class="rounded-sm p-1 hover:bg-[var(--hover-wash)]/10"
|
||||
title="Close"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4" />
|
||||
<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">
|
||||
<Icon icon="pixelarticons:search" class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<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..."
|
||||
@@ -169,7 +277,7 @@
|
||||
</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">
|
||||
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--hover-wash)]/10" data-testid="share-collaborator">
|
||||
<span class="text-sm text-[var(--text)]">
|
||||
{collab.displayName || collab.email}
|
||||
</span>
|
||||
@@ -179,7 +287,7 @@
|
||||
title="Remove"
|
||||
data-testid="remove-collaborator-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -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,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
|
||||
|
||||
let status: SyncStatus = $state(getSyncStatus());
|
||||
let displayStatus: SyncStatus = $state(getSyncStatus());
|
||||
@@ -48,12 +51,12 @@
|
||||
data-testid="sync-indicator"
|
||||
>
|
||||
{#if displayStatus === 'synced'}
|
||||
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
|
||||
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
<CloudSync class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
<CloudOff class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
|
||||
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -13,58 +13,134 @@
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { NoteLink } from './tiptap/NoteLink.js';
|
||||
import { getAllNotes } from '$lib/sync/idb.js';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onUpdate: (markdown: string) => void;
|
||||
onEditor?: (editor: Editor) => void;
|
||||
onTransaction?: () => void;
|
||||
onOpenNote?: (noteId: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { content, onUpdate, onEditor, onTransaction, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
let { content, onUpdate, onEditor, onTransaction, onOpenNote, placeholder = 'Add a crumb...' }: Props = $props();
|
||||
|
||||
let element: HTMLDivElement | undefined = $state();
|
||||
let editor: Editor | undefined = $state();
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Trigger Svelte reactivity for active state checks
|
||||
editor = editor;
|
||||
onTransaction?.();
|
||||
}
|
||||
});
|
||||
const titleIndexRef = { index: new Map<string, string>() };
|
||||
|
||||
// Expose editor on DOM element for e2e testing
|
||||
if (element) {
|
||||
(element as any).__tiptapEditor = editor;
|
||||
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof Element) {
|
||||
const checkbox = target.matches('input[type="checkbox"]')
|
||||
? target
|
||||
: target.closest('label')?.querySelector('input[type="checkbox"]');
|
||||
return checkbox !== null && checkbox !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
function handleWrapperPointerDown(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch' && isTaskCheckboxTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWrapperClick(event: MouseEvent) {
|
||||
if (isTaskCheckboxTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onEditor?.(editor);
|
||||
editor?.commands.focus();
|
||||
}
|
||||
|
||||
function handleWrapperChangeCapture(event: Event) {
|
||||
if (!isTouchDevice() || !(event.target instanceof HTMLInputElement)) return;
|
||||
if (event.target.type !== 'checkbox' || !editor) return;
|
||||
|
||||
const taskItem = event.target.closest('li[data-checked]');
|
||||
if (!taskItem) return;
|
||||
|
||||
const taskItemPosition = editor.view.posAtDOM(taskItem, 0) - 1;
|
||||
const taskItemNode = editor.state.doc.nodeAt(taskItemPosition);
|
||||
if (taskItemNode?.type.name !== 'taskItem') return;
|
||||
|
||||
event.stopPropagation();
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setNodeMarkup(taskItemPosition, undefined, {
|
||||
...taskItemNode.attrs,
|
||||
checked: event.target.checked
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let destroyed = false;
|
||||
let editorInstance: Editor | undefined;
|
||||
|
||||
(async () => {
|
||||
const allNotes = await getAllNotes();
|
||||
if (destroyed) return;
|
||||
titleIndexRef.index = new Map(allNotes.filter((n) => !n.trashed).map((n) => [n.id, n.title]));
|
||||
|
||||
editorInstance = new Editor({
|
||||
element: element!,
|
||||
extensions: [
|
||||
StarterKit.configure({ link: false, underline: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Underline,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: false }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
NoteLink.configure({ titleIndexRef, onOpenNote: onOpenNote ?? (() => {}) }),
|
||||
Markdown
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
if (destroyed) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onUpdate((e.storage as Record<string, any>).markdown.getMarkdown());
|
||||
},
|
||||
onTransaction: () => {
|
||||
// Removing this component's DOM node (e.g. a {#key}-driven remount
|
||||
// in NotesView.svelte when switching to a different note) fires a
|
||||
// native blur on the still-focused contenteditable, which
|
||||
// ProseMirror's blur plugin turns into one last transaction —
|
||||
// synchronously, from inside Svelte's own render/effect pass that's
|
||||
// doing the removal. Mutating $state right there crashes with
|
||||
// state_unsafe_mutation; the `destroyed` check alone isn't enough
|
||||
// since this can fire before onMount's own cleanup runs. Defer to a
|
||||
// microtask so the write lands after Svelte's current pass finishes.
|
||||
queueMicrotask(() => {
|
||||
if (destroyed) return;
|
||||
editor = editorInstance;
|
||||
onTransaction?.();
|
||||
});
|
||||
}
|
||||
});
|
||||
editor = editorInstance;
|
||||
|
||||
if (element && !destroyed) {
|
||||
(element as any).__tiptapEditor = editorInstance;
|
||||
}
|
||||
|
||||
onEditor?.(editorInstance);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
destroyed = true;
|
||||
editorInstance?.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -72,12 +148,14 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
class="tiptap-wrapper prose md:prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
tabindex="0"
|
||||
onclick={() => editor?.commands.focus()}
|
||||
onpointerdown={handleWrapperPointerDown}
|
||||
onclick={handleWrapperClick}
|
||||
onchangecapture={handleWrapperChangeCapture}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
export interface NoteLinkOptions {
|
||||
titleIndexRef: { index: Map<string, string> };
|
||||
onOpenNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const NoteLink = Node.create<NoteLinkOptions>({
|
||||
name: 'noteLink',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
titleIndexRef: { index: new Map<string, string>() },
|
||||
onOpenNote: () => {}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
noteId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-note-id'),
|
||||
renderHTML: (attributes) => ({ 'data-note-id': attributes.noteId })
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a[href^="crumb-note://"]',
|
||||
priority: 100,
|
||||
getAttrs: (element) => {
|
||||
const href = (element as HTMLElement).getAttribute('href') ?? '';
|
||||
const noteId = parseNoteLinkHref(href);
|
||||
return noteId ? { noteId } : false;
|
||||
}
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['a', mergeAttributes(HTMLAttributes, { href: `crumb-note://${HTMLAttributes['data-note-id']}` })];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize: (state: { write: (s: string) => void }, node: { attrs: { noteId: string } }) => {
|
||||
state.write(serializeNoteLinkMarkdown(node.attrs.noteId, this.options.titleIndexRef.index));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, editor, getPos }) => {
|
||||
const noteId = node.attrs.noteId as string;
|
||||
const title = this.options.titleIndexRef.index.get(noteId);
|
||||
|
||||
const dom = document.createElement('span');
|
||||
dom.setAttribute('data-note-id', noteId);
|
||||
dom.className =
|
||||
'group relative inline-flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-1.5 py-0.5 text-sm';
|
||||
|
||||
const label = document.createElement('span');
|
||||
if (title !== undefined) {
|
||||
label.textContent = title;
|
||||
label.className = 'cursor-pointer text-[var(--primary)] hover:text-[var(--primary-hover)]';
|
||||
label.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.options.onOpenNote(noteId);
|
||||
});
|
||||
} else {
|
||||
label.textContent = 'Note not found';
|
||||
label.className = 'text-[var(--text-muted)] opacity-60';
|
||||
}
|
||||
dom.appendChild(label);
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = '×';
|
||||
removeButton.setAttribute('aria-label', 'Remove note link');
|
||||
removeButton.className =
|
||||
'max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100 text-[var(--text-muted)] hover:text-[var(--destructive)]';
|
||||
removeButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
editor.chain().focus().deleteRange({ from: pos, to: pos + node.nodeSize }).run();
|
||||
});
|
||||
dom.appendChild(removeButton);
|
||||
|
||||
return { dom };
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseNoteLinkHref, serializeNoteLinkMarkdown } from './note-link-markdown.js';
|
||||
|
||||
describe('parseNoteLinkHref', () => {
|
||||
it('should extract the note ID from a crumb-note:// href', () => {
|
||||
expect(parseNoteLinkHref('crumb-note://abc-123')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('should return null for a non-crumb-note href', () => {
|
||||
expect(parseNoteLinkHref('https://example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for an empty string', () => {
|
||||
expect(parseNoteLinkHref('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeNoteLinkMarkdown', () => {
|
||||
it('should produce a markdown link with the title from the index', () => {
|
||||
const titleIndex = new Map([['abc-123', 'Grocery List']]);
|
||||
expect(serializeNoteLinkMarkdown('abc-123', titleIndex)).toBe('[Grocery List](crumb-note://abc-123)');
|
||||
});
|
||||
|
||||
it('should fall back to Untitled when the ID is not in the index', () => {
|
||||
const titleIndex = new Map<string, string>();
|
||||
expect(serializeNoteLinkMarkdown('missing-id', titleIndex)).toBe('[Untitled](crumb-note://missing-id)');
|
||||
});
|
||||
|
||||
it('should escape markdown-special characters in the title', () => {
|
||||
const titleIndex = new Map([['id-1', 'Buy [milk] today']]);
|
||||
expect(serializeNoteLinkMarkdown('id-1', titleIndex)).toBe('[Buy \\[milk\\] today](crumb-note://id-1)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const NOTE_LINK_SCHEME = 'crumb-note://';
|
||||
|
||||
/** Extracts the note ID from a crumb-note:// href, or null if it's not that scheme. */
|
||||
export function parseNoteLinkHref(href: string): string | null {
|
||||
if (!href.startsWith(NOTE_LINK_SCHEME)) return null;
|
||||
return href.slice(NOTE_LINK_SCHEME.length) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes markdown-special characters the same way a plain link's visible
|
||||
* text is escaped, so a title containing `[`, `]`, `*`, etc. can't corrupt
|
||||
* the surrounding markdown syntax.
|
||||
*/
|
||||
function escapeMarkdownText(text: string): string {
|
||||
return text.replace(/[[\]\\*_`]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Produces the `[title](crumb-note://id)` markdown representation of a note-link. */
|
||||
export function serializeNoteLinkMarkdown(noteId: string, titleIndex: Map<string, string>): string {
|
||||
const title = titleIndex.get(noteId) ?? 'Untitled';
|
||||
return `[${escapeMarkdownText(title)}](${NOTE_LINK_SCHEME}${noteId})`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
@@ -16,16 +16,16 @@ beforeEach(() => {
|
||||
|
||||
describe('Auth - Password Hashing', () => {
|
||||
it('should hash and verify a password correctly', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
expect(hash).not.toBe(password);
|
||||
expect(await argon2.verify(hash, password)).toBe(true);
|
||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
||||
expect(await argon2.verify(hash, randomUUID())).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for the same password', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const password = randomUUID();
|
||||
const hash1 = await argon2.hash(password);
|
||||
const hash2 = await argon2.hash(password);
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
|
||||
|
||||
describe('Auth - User Setup', () => {
|
||||
it('should create a user with hashed password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const password = randomUUID();
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
|
||||
+47
-18
@@ -227,24 +227,53 @@ export function getUser(userId: number): User | null {
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number): Promise<void> {
|
||||
// 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 attachments WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(userId);
|
||||
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
db.delete(users).where(eq(users.id, userId)).run();
|
||||
// Atomic cascade: all-or-nothing. Also clear rows from other users that
|
||||
// reference the deleted user's notes (e.g. sync_log, note_user_state,
|
||||
// note_collaborators) so foreign-key checks don't block the final delete.
|
||||
const tx = sqlite.transaction((uid: number) => {
|
||||
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_user_state WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM sync_log WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM attachments WHERE user_id = ? OR note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid, uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_versions WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(uid);
|
||||
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(uid);
|
||||
sqlite.prepare('DELETE FROM users WHERE id = ?').run(uid);
|
||||
});
|
||||
tx(userId);
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
|
||||
@@ -124,6 +124,13 @@ sqlite.exec(`
|
||||
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,
|
||||
@@ -219,6 +226,13 @@ sqlite.exec(`
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX IF NOT EXISTS note_links_target_idx ON note_links(target_note_id);
|
||||
`);
|
||||
|
||||
// Replace old tags unique index with compound index including user_id
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteLinks, users } from './schema.js';
|
||||
|
||||
describe('noteLinks schema', () => {
|
||||
it('should store a source/target link row', () => {
|
||||
const { db } = createTestDb();
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source-note', targetNoteId: 'target-note' }]);
|
||||
});
|
||||
|
||||
it('should cascade-delete link rows when the source note is deleted', () => {
|
||||
const { db, sqlite } = createTestDb();
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
db.insert(users)
|
||||
.values({ email: 'a@test.com', displayName: 'A', role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
db.insert(notes)
|
||||
.values([
|
||||
{ id: 'source-note', userId: 1, title: 'Source', content: '', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'target-note', userId: 1, title: 'Target', content: '', createdAt: new Date(), updatedAt: new Date() }
|
||||
])
|
||||
.run();
|
||||
db.insert(noteLinks).values({ sourceNoteId: 'source-note', targetNoteId: 'target-note' }).run();
|
||||
|
||||
db.delete(notes).where(eq(notes.id, 'source-note')).run();
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,22 @@ export const noteTags = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const noteLinks = sqliteTable(
|
||||
'note_links',
|
||||
{
|
||||
sourceNoteId: text('source_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
targetNoteId: text('target_note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_links_source_idx').on(table.sourceNoteId),
|
||||
index('note_links_target_idx').on(table.targetNoteId)
|
||||
]
|
||||
);
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
@@ -190,6 +206,15 @@ export const userPreferences = sqliteTable(
|
||||
(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',
|
||||
{
|
||||
|
||||
@@ -68,6 +68,13 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
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 TABLE note_links (
|
||||
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
target_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX note_links_source_idx ON note_links(source_note_id);
|
||||
CREATE INDEX note_links_target_idx ON note_links(target_note_id);
|
||||
|
||||
CREATE TABLE note_collaborators (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
@@ -137,6 +144,13 @@ export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
@@ -29,6 +32,134 @@ const NOTE_COLORS = [
|
||||
'chalk'
|
||||
] as const;
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the manual attachment upload limit
|
||||
const MAX_IMAGE_REDIRECTS = 5;
|
||||
|
||||
// Blocks loopback, unspecified, link-local (incl. cloud metadata 169.254.169.254),
|
||||
// RFC1918/ULA private ranges, 6to4/IPv4-mapped tunnels, and multicast/broadcast so
|
||||
// upload_image can't be used to reach internal network services.
|
||||
function isPrivateOrReservedIp(address: string): boolean {
|
||||
if (address.includes(':')) {
|
||||
const normalized = address.toLowerCase();
|
||||
if (normalized === '::' || normalized === '::1') return true;
|
||||
if (normalized.startsWith('fe80:')) return true;
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||
if (normalized.startsWith('2002:')) return true; // 6to4 tunnel, can embed a private IPv4
|
||||
if (normalized.startsWith('::ffff:')) return isPrivateOrReservedIp(normalized.split(':').pop()!);
|
||||
// Legacy IPv4-compatible form, e.g. "::1.2.3.4" (distinct from "::ffff:1.2.3.4")
|
||||
const ipv4Compatible = normalized.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (ipv4Compatible) return isPrivateOrReservedIp(ipv4Compatible[1]);
|
||||
return false;
|
||||
}
|
||||
const parts = address.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return true;
|
||||
const [a, b] = parts;
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
if (a >= 224) return true; // multicast (224.0.0.0/4) and broadcast/reserved (240.0.0.0/4, 255.255.255.255)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves every A/AAAA record for a hostname and rejects if ANY is private/reserved
|
||||
// (not just the first), so a validated hostname can't have a private record hiding
|
||||
// behind a public one in the same DNS answer.
|
||||
async function resolveValidatedAddress(hostname: string): Promise<{ address: string; family: number }> {
|
||||
const records = await lookup(hostname, { all: true });
|
||||
if (records.length === 0) throw new Error('DNS lookup returned no addresses');
|
||||
for (const record of records) {
|
||||
if (isPrivateOrReservedIp(record.address)) {
|
||||
throw new Error('URL resolves to a private or reserved address');
|
||||
}
|
||||
}
|
||||
return records[0];
|
||||
}
|
||||
|
||||
interface PinnedResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: { get(name: string): string | null };
|
||||
arrayBuffer(): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
// Issues the request against the exact address validated by resolveValidatedAddress,
|
||||
// bypassing a second DNS resolution at connect time — otherwise a rebinding attacker
|
||||
// could serve a public address to the validator and a private one to the real connect.
|
||||
function requestPinnedAddress(url: URL, address: string, family: number): Promise<PinnedResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const req = requestFn(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
servername: url.hostname,
|
||||
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
lookup: (_hostname: string, _options: unknown, callback: (err: null, address: string, family: number) => void) => {
|
||||
callback(null, address, family);
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_IMAGE_BYTES) {
|
||||
req.destroy(new Error('Image too large (max 10MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const status = res.statusCode ?? 0;
|
||||
resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: res.statusMessage ?? '',
|
||||
headers: {
|
||||
get: (name: string) => {
|
||||
const value = res.headers[name.toLowerCase()];
|
||||
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
||||
}
|
||||
},
|
||||
arrayBuffer: async () =>
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer
|
||||
});
|
||||
});
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Fetches an attacker-supplied URL for the MCP upload_image tool, re-validating the
|
||||
// target on every redirect hop so a public URL can't SSRF via a 3xx to an internal address.
|
||||
async function fetchPublicImage(imageUrl: string): Promise<PinnedResponse> {
|
||||
let currentUrl = new URL(imageUrl);
|
||||
for (let hop = 0; hop <= MAX_IMAGE_REDIRECTS; hop++) {
|
||||
if (currentUrl.protocol !== 'http:' && currentUrl.protocol !== 'https:') {
|
||||
throw new Error('Only http/https URLs are allowed');
|
||||
}
|
||||
const { address, family } = await resolveValidatedAddress(currentUrl.hostname);
|
||||
const response = await requestPinnedAddress(currentUrl, address, family);
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) return response;
|
||||
currentUrl = new URL(location, currentUrl);
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
|
||||
export function createMcpServer(userId: number): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'crumbs',
|
||||
@@ -239,8 +370,8 @@ export function createMcpServer(userId: number): McpServer {
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'favorite_note',
|
||||
'Favorite or unfavorite a note',
|
||||
'pin_note',
|
||||
'Pin or unpin a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||
@@ -257,7 +388,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${result.title}" ${pinned ? 'favorited' : 'unfavorited'}`
|
||||
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -302,7 +433,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
const response = await fetchPublicImage(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
content: [
|
||||
@@ -315,8 +446,30 @@ export function createMcpServer(userId: number): McpServer {
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'URL did not return an image' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (contentLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.byteLength > MAX_IMAGE_BYTES) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Image too large (max 10MB)' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteLinks, noteCollaborators, users } from './db/schema.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.js';
|
||||
|
||||
let db: Db;
|
||||
const OWNER_ID = 1;
|
||||
const OTHER_USER_ID = 2;
|
||||
|
||||
function seedUser(id: number, email: string) {
|
||||
db.insert(users)
|
||||
.values({ id, email, displayName: email, role: 'user', authProvider: 'password', createdAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
function seedNote(id: string, userId: number, title = '') {
|
||||
db.insert(notes)
|
||||
.values({ id, userId, title, content: '', createdAt: new Date(), updatedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
seedUser(OWNER_ID, 'owner@test.com');
|
||||
seedUser(OTHER_USER_ID, 'other@test.com');
|
||||
});
|
||||
|
||||
describe('syncNoteLinks', () => {
|
||||
it('should create link rows for each target', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1', 'target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove old links and add new ones on re-sync', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
seedNote('target-2', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', ['target-2']);
|
||||
|
||||
const rows = db.select().from(noteLinks).all();
|
||||
expect(rows).toEqual([{ sourceNoteId: 'source', targetNoteId: 'target-2' }]);
|
||||
});
|
||||
|
||||
it('should handle an empty target list (removes all links)', () => {
|
||||
seedNote('source', OWNER_ID);
|
||||
seedNote('target-1', OWNER_ID);
|
||||
|
||||
syncNoteLinks(db, 'source', ['target-1']);
|
||||
syncNoteLinks(db, 'source', []);
|
||||
|
||||
expect(db.select().from(noteLinks).all()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchBacklinksForNote', () => {
|
||||
it('should return notes that link to the given note', () => {
|
||||
seedNote('source', OWNER_ID, 'Source Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
const backlinks = fetchBacklinksForNote(db, 'target', OWNER_ID);
|
||||
expect(backlinks).toEqual([{ id: 'source', title: 'Source Note' }]);
|
||||
});
|
||||
|
||||
it('should return an empty array when nothing links to the note', () => {
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should exclude a backlink from a source note the requesting user cannot access', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Other Users Note');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include a backlink from a source note shared with the requesting user', () => {
|
||||
seedNote('source', OTHER_USER_ID, 'Shared Source');
|
||||
seedNote('target', OWNER_ID, 'Target Note');
|
||||
syncNoteLinks(db, 'source', ['target']);
|
||||
db.insert(noteCollaborators)
|
||||
.values({ noteId: 'source', userId: OWNER_ID, addedBy: OTHER_USER_ID, addedAt: new Date() })
|
||||
.run();
|
||||
|
||||
expect(fetchBacklinksForNote(db, 'target', OWNER_ID)).toEqual([{ id: 'source', title: 'Shared Source' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteLinks, notes } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
|
||||
/**
|
||||
* Sync link rows for a note: removes old associations, inserts the current set.
|
||||
* Mirrors syncNoteTags — called right after a note's content is saved.
|
||||
*/
|
||||
export function syncNoteLinks(db: Db, noteId: string, targetIds: string[]): void {
|
||||
db.delete(noteLinks).where(eq(noteLinks.sourceNoteId, noteId)).run();
|
||||
|
||||
for (const targetId of targetIds) {
|
||||
db.insert(noteLinks).values({ sourceNoteId: noteId, targetNoteId: targetId }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes that link to the given note, scoped to what the requesting user
|
||||
* can access — this both respects normal sharing boundaries and, as a side
|
||||
* effect, prevents an unrelated note from appearing in someone's backlinks
|
||||
* just because it happens to reference a note ID it was never shared to see.
|
||||
*/
|
||||
export function fetchBacklinksForNote(
|
||||
db: Db,
|
||||
noteId: string,
|
||||
userId: number
|
||||
): { id: string; title: string }[] {
|
||||
const linkRows = db
|
||||
.select({ sourceNoteId: noteLinks.sourceNoteId })
|
||||
.from(noteLinks)
|
||||
.where(eq(noteLinks.targetNoteId, noteId))
|
||||
.all();
|
||||
|
||||
if (linkRows.length === 0) return [];
|
||||
|
||||
const sourceIds = linkRows.map((r) => r.sourceNoteId);
|
||||
const accessibleIds = sourceIds.filter((id) => canAccessNote(db, id, userId).canAccess);
|
||||
if (accessibleIds.length === 0) return [];
|
||||
|
||||
const sourceNotes = db
|
||||
.select({ id: notes.id, title: notes.title })
|
||||
.from(notes)
|
||||
.where(inArray(notes.id, accessibleIds))
|
||||
.all();
|
||||
|
||||
return sourceNotes;
|
||||
}
|
||||
@@ -263,6 +263,46 @@ describe('updateNote', () => {
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
expect(updated!.tags).toContain('newtag');
|
||||
});
|
||||
|
||||
it('should merge concurrent content edits using the snapshot created by the first update', () => {
|
||||
seedNote('n1', {
|
||||
title: 'Shopping',
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
shareNote('n1');
|
||||
|
||||
const ownerUpdate = updateNote(db, OWNER_ID, 'n1', {
|
||||
content: '- [ ] Milk\n- [x] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
expect(ownerUpdate!.version).toBe(2);
|
||||
|
||||
const collabUpdate = updateNote(db, COLLAB_ID, 'n1', {
|
||||
content: '- [x] Milk\n- [ ] Bread',
|
||||
baseVersion: 1
|
||||
});
|
||||
|
||||
expect(collabUpdate!.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep the incoming edit when a metadata-only bump left no base snapshot', () => {
|
||||
seedNote('n1', { title: 'Note', content: 'hello', version: 1 });
|
||||
|
||||
// A metadata-only change bumps the version but creates no snapshot.
|
||||
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
|
||||
// A later concurrent content edit based on the pre-bump version therefore has
|
||||
// no base to merge against — the incoming edit must be preserved, not dropped.
|
||||
const edited = updateNote(db, OWNER_ID, 'n1', { content: 'hello world', baseVersion: 1 });
|
||||
|
||||
expect(edited!.content).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteNote', () => {
|
||||
@@ -334,3 +374,45 @@ describe('reorderNotes', () => {
|
||||
expect(collabView!.sortOrder).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note links', () => {
|
||||
it('should sync note-links when a note is created with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should sync note-links when a note is updated with a reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, { title: 'Source', content: '' });
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: `See [Target](crumb-note://${target.id})` });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([{ id: source.id, title: 'Source' }]);
|
||||
});
|
||||
|
||||
it('should remove a backlink when the referencing note is edited to drop the reference', () => {
|
||||
const target = createNote(db, OWNER_ID, { title: 'Target' });
|
||||
const source = createNote(db, OWNER_ID, {
|
||||
title: 'Source',
|
||||
content: `See [Target](crumb-note://${target.id})`
|
||||
});
|
||||
|
||||
updateNote(db, OWNER_ID, source.id, { content: 'No longer references anything' });
|
||||
|
||||
const reopened = getNote(db, OWNER_ID, target.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty backlinks array for a note nothing links to', () => {
|
||||
const note = createNote(db, OWNER_ID, { title: 'Lonely note' });
|
||||
const reopened = getNote(db, OWNER_ID, note.id);
|
||||
expect(reopened?.backlinks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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 { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { extractNoteLinks } from '$lib/utils/note-links.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { syncNoteLinks, fetchBacklinksForNote } from './note-links.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 { createSnapshot, getBaseContent } from './versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface NoteRow {
|
||||
@@ -33,6 +36,7 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||
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) ?? [];
|
||||
@@ -44,7 +48,8 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||
attachments: attachmentMap.get(note.id) ?? [],
|
||||
collaborators,
|
||||
isOwner,
|
||||
isShared
|
||||
isShared,
|
||||
shareToken: shareMap.get(note.id) ?? undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -152,7 +157,10 @@ export function getNote(db: Db, userId: number, id: string) {
|
||||
}
|
||||
|
||||
const result = hydrateNotes(db, [effectiveNote], userId);
|
||||
return result[0] ?? null;
|
||||
const hydrated = result[0];
|
||||
if (!hydrated) return null;
|
||||
|
||||
return { ...hydrated, backlinks: fetchBacklinksForNote(db, id, userId) };
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
@@ -194,6 +202,9 @@ export function createNote(db: Db, userId: number, input: CreateNoteInput) {
|
||||
});
|
||||
syncNoteTags(db, id, extractedTags, userId);
|
||||
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
|
||||
@@ -235,30 +246,18 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) {
|
||||
// 3-way merge to preserve concurrent edits from other users
|
||||
if (input.content !== existing.content) {
|
||||
const baseSnapshot = input.baseVersion !== undefined
|
||||
? 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()
|
||||
: db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, id))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
// Preserve concurrent edits at line/block granularity when the client
|
||||
// saved from an older note version.
|
||||
const hasConcurrentEdit = input.baseVersion !== undefined
|
||||
&& input.baseVersion < existing.version;
|
||||
|
||||
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;
|
||||
}
|
||||
sharedUpdates.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(db, id, input.baseVersion),
|
||||
incomingContent: input.content,
|
||||
currentContent: existing.content
|
||||
})
|
||||
: input.content;
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
@@ -276,6 +275,22 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(sharedUpdates.content !== undefined && sharedUpdates.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
|
||||
});
|
||||
}
|
||||
|
||||
if (hasSharedUpdates || isOwner) {
|
||||
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
|
||||
}
|
||||
@@ -319,26 +334,11 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
const content = `${updated.title} ${updated.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(db, id, extractedTags, existing.userId);
|
||||
const extractedLinks = extractNoteLinks(content);
|
||||
syncNoteLinks(db, id, extractedLinks);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
|
||||
|
||||
let db: Db;
|
||||
|
||||
@@ -44,6 +44,32 @@ beforeEach(() => {
|
||||
seedUser();
|
||||
});
|
||||
|
||||
describe('getBaseContent', () => {
|
||||
it('returns null when the base version is unknown', () => {
|
||||
seedNote();
|
||||
expect(getBaseContent(db, NOTE_ID, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the snapshot content at or before the base version', () => {
|
||||
seedNote();
|
||||
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: 'v1', checklistMode: false, color: 'default' });
|
||||
createSnapshot(db, NOTE_ID, { version: 3, title: 'T', content: 'v3', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBe('v1');
|
||||
expect(getBaseContent(db, NOTE_ID, 3)).toBe('v3');
|
||||
});
|
||||
|
||||
it('returns null instead of a newer snapshot when none exists at or before the base version', () => {
|
||||
seedNote();
|
||||
// Only a snapshot NEWER than the requested base exists (e.g. the real base was
|
||||
// pruned). Using it as the merge base would treat unseen lines as deletions,
|
||||
// so getBaseContent must return null rather than fall back to it.
|
||||
createSnapshot(db, NOTE_ID, { version: 5, title: 'T', content: 'v5', checklistMode: false, color: 'default' });
|
||||
|
||||
expect(getBaseContent(db, NOTE_ID, 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnapshot', () => {
|
||||
it('should store a snapshot for a note', () => {
|
||||
seedNote();
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { noteVersions, notes } from './db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, lte } 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;
|
||||
|
||||
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
export type SnapshotDb = Db | Transaction;
|
||||
|
||||
/**
|
||||
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
|
||||
* at or before the client's base version. Returns null when the base version is
|
||||
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
|
||||
* snapshot as the base — a base newer than what the client actually saw makes the
|
||||
* merge treat other users' lines as deletions and silently drop them.
|
||||
*/
|
||||
export function getBaseContent(
|
||||
db: SnapshotDb,
|
||||
noteId: string,
|
||||
baseVersion: number | undefined
|
||||
): string | null {
|
||||
if (baseVersion === undefined) return null;
|
||||
|
||||
const snapshot = db
|
||||
.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
return snapshot?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
db: SnapshotDb,
|
||||
noteId: string,
|
||||
snapshot: {
|
||||
version: number;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeNotesByVersion, reconcileSearchResults } 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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileSearchResults', () => {
|
||||
it('replaces a stale result with the canonical note', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical]);
|
||||
});
|
||||
|
||||
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
|
||||
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
|
||||
|
||||
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
|
||||
|
||||
expect(result).toEqual([serverHit]);
|
||||
});
|
||||
|
||||
it('drops a result that is trashed in the canonical store', () => {
|
||||
const trashed = makeNote({ id: 'n1', trashed: true });
|
||||
|
||||
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves result order while reconciling a mix of cases', () => {
|
||||
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
|
||||
const trashed = makeNote({ id: 'n2', trashed: true });
|
||||
const absent = makeNote({ id: 'n3', title: 'Absent' });
|
||||
|
||||
const result = reconcileSearchResults(
|
||||
[canonical, trashed],
|
||||
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
|
||||
);
|
||||
|
||||
expect(result).toEqual([canonical, absent]);
|
||||
});
|
||||
|
||||
it('returns an empty array when there are no results', () => {
|
||||
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
+64
-8
@@ -8,11 +8,41 @@ export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
export const selectedTag = writable<string | null>(null);
|
||||
export const notesLoaded = writable(false);
|
||||
export const searchQuery = writable<string>('');
|
||||
export const searchResults = writable<Note[]>([]);
|
||||
|
||||
/**
|
||||
* Reconcile search results against the canonical notes store so local edits and
|
||||
* trashing propagate into the active result set without re-querying the server.
|
||||
*
|
||||
* A result absent from the canonical store is kept as-is — it's a server hit not
|
||||
* yet loaded locally (e.g. created on another device), and dropping it would make
|
||||
* valid matches disappear mid-session. Notes removed locally (deleted or left) are
|
||||
* pruned explicitly at their call sites; trashed notes are dropped here.
|
||||
*/
|
||||
export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] {
|
||||
const notesMap = new Map(canonicalNotes.map((note) => [note.id, note]));
|
||||
return results.flatMap((result) => {
|
||||
const canonical = notesMap.get(result.id);
|
||||
if (!canonical) return [result];
|
||||
return canonical.trashed ? [] : [canonical];
|
||||
});
|
||||
}
|
||||
|
||||
// Keep searchResults in sync with edits/trashing in the notes store. These search
|
||||
// stores (like the others in this module) are client-only state; the subscription
|
||||
// lives for the app's lifetime and is intentionally never unsubscribed.
|
||||
notes.subscribe(($notes) => {
|
||||
searchResults.update(($results) => {
|
||||
if ($results.length === 0) return $results;
|
||||
return reconcileSearchResults($notes, $results);
|
||||
});
|
||||
});
|
||||
|
||||
export const filteredNotes = derived(
|
||||
[notes, selectedTag, currentFilter],
|
||||
([$notes, $selectedTag, $filter]) => {
|
||||
let result = $notes;
|
||||
[notes, selectedTag, currentFilter, searchQuery, searchResults],
|
||||
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
|
||||
let result = $searchQuery.trim() ? $searchResults : $notes;
|
||||
if ($filter === 'all') {
|
||||
result = result.filter((n) => !n.trashed && !n.archived);
|
||||
} else if ($filter === 'archived') {
|
||||
@@ -35,11 +65,11 @@ function sortNotes(list: Note[]): Note[] {
|
||||
});
|
||||
}
|
||||
|
||||
export const favoriteNotes = derived(filteredNotes, ($notes) =>
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => n.pinned))
|
||||
);
|
||||
|
||||
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned))
|
||||
);
|
||||
|
||||
@@ -53,12 +83,35 @@ function isNetworkError(err: unknown): boolean {
|
||||
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') {
|
||||
// Load from IDB first for instant display
|
||||
try {
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.set(cached);
|
||||
notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached);
|
||||
}
|
||||
} catch {
|
||||
// IDB unavailable — continue with server fetch
|
||||
@@ -71,7 +124,7 @@ export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data: Note[] = await res.json();
|
||||
notes.set(data);
|
||||
notes.update((current) => mergeNotesByVersion(current, data));
|
||||
currentFilter.set(filter);
|
||||
|
||||
// Update IDB with server state
|
||||
@@ -195,6 +248,7 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
@@ -202,6 +256,7 @@ export async function deleteNote(id: string): Promise<boolean> {
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
@@ -231,7 +286,7 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { archived: false });
|
||||
}
|
||||
|
||||
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
@@ -240,6 +295,7 @@ export async function leaveNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
searchResults.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.j
|
||||
describe('preferences defaults', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(DEFAULT_PREFERENCES).toEqual({
|
||||
theme: 'system',
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from '../server/db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
|
||||
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } from '../server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from '../server/db/schema.js';
|
||||
@@ -289,6 +289,64 @@ describe('processSyncPush — shared notes', () => {
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should create a base snapshot during sync so later queued edits can merge', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: t1,
|
||||
createdAt: t1,
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
version: 1
|
||||
});
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 1000,
|
||||
data: { content: '- [ ] Milk\n- [x] Bread' },
|
||||
baseVersion: 1
|
||||
}], OWNER_ID);
|
||||
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1.getTime() + 2000,
|
||||
data: { content: '- [x] Milk\n- [ ] Bread' },
|
||||
baseVersion: 1
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should retain at most 50 snapshots created by sync updates', async () => {
|
||||
const createdAt = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: createdAt,
|
||||
createdAt,
|
||||
content: 'version 1',
|
||||
version: 1
|
||||
});
|
||||
|
||||
for (let version = 2; version <= 52; version++) {
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: createdAt.getTime() + version,
|
||||
data: { content: `version ${version}` },
|
||||
baseVersion: version - 1
|
||||
}], OWNER_ID);
|
||||
}
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(50);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(false);
|
||||
expect(snapshots.some((snapshot) => snapshot.version === 51)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
// Create note without sharing with user 3
|
||||
|
||||
+30
-38
@@ -1,37 +1,12 @@
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
|
||||
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Find the base content for 3-way merge from version snapshots.
|
||||
* Returns the content from the snapshot at or before the given version,
|
||||
* or the current note content if no snapshot is found.
|
||||
*/
|
||||
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
|
||||
if (baseVersion !== undefined) {
|
||||
const snapshot = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (snapshot) return snapshot.content;
|
||||
}
|
||||
// Fallback: get the most recent snapshot before the current version
|
||||
const latest = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, noteId))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
return latest?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
@@ -69,16 +44,17 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
// Content: 3-way merge to preserve concurrent edits from other users
|
||||
// Content: merge only when the client saved from an older note version.
|
||||
if (change.data.content !== undefined && change.data.content !== note.content) {
|
||||
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
|
||||
if (baseContent !== null && baseContent !== note.content) {
|
||||
// 3-way merge: base, incoming (local), server current (remote)
|
||||
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
|
||||
} else {
|
||||
// No base or base matches server — take incoming content
|
||||
sharedData.content = change.data.content;
|
||||
}
|
||||
const hasConcurrentEdit = change.baseVersion !== undefined
|
||||
&& change.baseVersion < note.version;
|
||||
sharedData.content = hasConcurrentEdit
|
||||
? mergeContentUpdate({
|
||||
baseContent: getBaseContent(tx, change.noteId, change.baseVersion),
|
||||
incomingContent: change.data.content,
|
||||
currentContent: note.content
|
||||
})
|
||||
: change.data.content;
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
@@ -92,6 +68,22 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
|
||||
const hasActualContentChange =
|
||||
(change.data.title !== undefined && change.data.title !== note.title) ||
|
||||
(sharedData.content !== undefined && sharedData.content !== note.content) ||
|
||||
(change.data.color !== undefined && change.data.color !== note.color) ||
|
||||
(change.data.checklistMode !== undefined && change.data.checklistMode !== note.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(tx, change.noteId, {
|
||||
version: note.version,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
checklistMode: note.checklistMode,
|
||||
color: note.color
|
||||
});
|
||||
}
|
||||
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface Note {
|
||||
collaborators?: Collaborator[];
|
||||
isOwner?: boolean;
|
||||
isShared?: boolean;
|
||||
shareToken?: string;
|
||||
backlinks?: NoteBacklink[];
|
||||
}
|
||||
|
||||
export interface NoteBacklink {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
@@ -118,5 +125,31 @@ export interface NoteVersion {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface NoteShare {
|
||||
noteId: string;
|
||||
token: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
export interface PublicAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
featured: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SharedNoteData {
|
||||
title: string;
|
||||
content: string;
|
||||
checklistMode: boolean;
|
||||
color: NoteColor;
|
||||
attachments: PublicAttachment[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type { UserPreferences } from './preferences.js';
|
||||
export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NoteColor } from './index.js';
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
defaultNoteMode: 'richtext' | 'markdown';
|
||||
defaultNoteColor: NoteColor;
|
||||
hideFooter: boolean;
|
||||
@@ -11,6 +12,7 @@ export interface UserPreferences {
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
theme: 'system',
|
||||
defaultNoteMode: 'richtext',
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, linkifyText, unlinkifyHtml, type ChecklistItem } from '$lib/utils/checklist.js';
|
||||
|
||||
describe('parseChecklist', () => {
|
||||
it('parses flat checklist items', () => {
|
||||
const items = parseChecklist('- [ ] Milk\n- [x] Eggs');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ text: 'Milk', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Eggs', checked: true, parentId: null });
|
||||
});
|
||||
|
||||
it('parses nested children with 2-space prefix', () => {
|
||||
const items = parseChecklist('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]).toMatchObject({ text: 'Groceries', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Milk', checked: false, parentId: items[0].id });
|
||||
expect(items[2]).toMatchObject({ text: 'Eggs', checked: true, parentId: items[0].id });
|
||||
});
|
||||
|
||||
it('treats orphaned children as top-level', () => {
|
||||
const items = parseChecklist(' - [ ] Orphan\n- [ ] Parent');
|
||||
expect(items[0]).toMatchObject({ text: 'Orphan', parentId: null });
|
||||
});
|
||||
|
||||
it('returns one empty item for blank content', () => {
|
||||
const items = parseChecklist('');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ text: '', checked: false, parentId: null });
|
||||
});
|
||||
|
||||
it('handles mixed top-level and nested items', () => {
|
||||
const content = '- [ ] A\n - [ ] A1\n - [ ] A2\n- [ ] B\n - [x] B1';
|
||||
const items = parseChecklist(content);
|
||||
expect(items).toHaveLength(5);
|
||||
expect(items[1].parentId).toBe(items[0].id);
|
||||
expect(items[2].parentId).toBe(items[0].id);
|
||||
expect(items[4].parentId).toBe(items[3].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeChecklist', () => {
|
||||
it('serializes flat items', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Milk', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Eggs', checked: true, parentId: null }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Milk\n- [x] Eggs');
|
||||
});
|
||||
|
||||
it('serializes nested items with 2-space prefix', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Groceries', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Milk', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Eggs', checked: true, parentId: '1' }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
});
|
||||
|
||||
it('groups children under parents regardless of array order', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '3', text: 'B', checked: false, parentId: null },
|
||||
{ id: '2', text: 'A1', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = serializeChecklist(items);
|
||||
expect(result).toBe('- [ ] A\n - [ ] A1\n- [ ] B');
|
||||
});
|
||||
|
||||
it('round-trips parse then serialize', () => {
|
||||
const original = '- [ ] Groceries\n - [ ] Milk\n - [x] Eggs\n- [ ] Clean';
|
||||
const items = parseChecklist(original);
|
||||
expect(serializeChecklist(items)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleItemWithCascade', () => {
|
||||
it('checking a parent checks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a parent unchecks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: true, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: true, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: true, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => !i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('checking a child does not affect parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result.find((i) => i.id === '1')!.checked).toBe(false);
|
||||
expect(result.find((i) => i.id === '2')!.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a child with deleted parent orphans it', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '2', text: 'Orphan', checked: true, parentId: 'deleted-id' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result[0].checked).toBe(false);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('unchecking a child repositions it after its parent and siblings', () => {
|
||||
// Simulates: parent + 2 children, one child checked (at end of array as done item)
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '3', text: 'Child B', checked: false, parentId: '1' },
|
||||
{ id: '4', text: 'Other', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: true, parentId: '1' } // done, at end
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
// Child A should be repositioned after Child B (last sibling), not stay at end
|
||||
const ids = result.map(i => i.id);
|
||||
expect(ids).toEqual(['1', '3', '2', '4']);
|
||||
expect(result.find(i => i.id === '2')!.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('indentItem', () => {
|
||||
it('sets parentId to nearest preceding top-level item', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
});
|
||||
|
||||
it('no-op if already a child', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result).toEqual(items);
|
||||
});
|
||||
|
||||
it('no-op if first item (no parent above)', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('1', items, active);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('reparents children to grandparent when indenting a parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '2' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '2' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outdentItem', () => {
|
||||
it('sets parentId to null', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('no-op if already top-level', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('1', items);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('adopts consecutive siblings below with same parentId', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('stops adoption at first non-sibling', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkifyText', () => {
|
||||
it('converts https URLs to anchor tags', () => {
|
||||
expect(linkifyText('visit https://example.com today')).toBe(
|
||||
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a> today'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts http URLs to anchor tags', () => {
|
||||
expect(linkifyText('go to http://example.com')).toBe(
|
||||
'go to <a href="http://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">http://example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('converts bare www URLs with https href', () => {
|
||||
expect(linkifyText('check www.example.com')).toBe(
|
||||
'check <a href="https://www.example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">www.example.com</a>'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple URLs in one string', () => {
|
||||
const result = linkifyText('see https://a.com and https://b.com');
|
||||
expect(result).toContain('href="https://a.com"');
|
||||
expect(result).toContain('href="https://b.com"');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged when no URLs present', () => {
|
||||
expect(linkifyText('just some text')).toBe('just some text');
|
||||
});
|
||||
|
||||
it('HTML-escapes user content to prevent XSS', () => {
|
||||
expect(linkifyText('<script>alert("xss")</script>')).toBe(
|
||||
'<script>alert("xss")</script>'
|
||||
);
|
||||
});
|
||||
|
||||
it('HTML-escapes text around URLs', () => {
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('<b>bold</b>');
|
||||
expect(linkifyText('<b>bold</b> https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at start of string', () => {
|
||||
expect(linkifyText('https://example.com is great')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('handles URL at end of string', () => {
|
||||
expect(linkifyText('visit https://example.com')).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('does not linkify partial matches without protocol or www', () => {
|
||||
expect(linkifyText('example.com is not linked')).toBe('example.com is not linked');
|
||||
});
|
||||
|
||||
it('handles URLs with paths and query strings', () => {
|
||||
const url = 'https://example.com/path?q=1&b=2';
|
||||
const result = linkifyText(url);
|
||||
expect(result).toContain('href="https://example.com/path?q=1&b=2"');
|
||||
});
|
||||
|
||||
it('strips trailing punctuation from URLs', () => {
|
||||
expect(linkifyText('see https://example.com.')).toBe(
|
||||
'see <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="checklist-link">https://example.com</a>.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(linkifyText('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkifyHtml', () => {
|
||||
it('strips anchor tags and returns text content', () => {
|
||||
expect(unlinkifyHtml('visit <a href="https://example.com">https://example.com</a> today'))
|
||||
.toBe('visit https://example.com today');
|
||||
});
|
||||
|
||||
it('returns plain text unchanged', () => {
|
||||
expect(unlinkifyHtml('just text')).toBe('just text');
|
||||
});
|
||||
|
||||
it('handles multiple anchor tags', () => {
|
||||
expect(unlinkifyHtml('<a href="https://a.com">https://a.com</a> and <a href="https://b.com">https://b.com</a>'))
|
||||
.toBe('https://a.com and https://b.com');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(unlinkifyHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles div and br tags from contenteditable', () => {
|
||||
expect(unlinkifyHtml('line1<br>line2')).toBe('line1line2');
|
||||
});
|
||||
|
||||
it('decodes HTML entities after stripping tags', () => {
|
||||
expect(unlinkifyHtml('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
expect(unlinkifyHtml('<b>bold</b>')).toBe('<b>bold</b>');
|
||||
expect(unlinkifyHtml('say "hello"')).toBe('say "hello"');
|
||||
});
|
||||
|
||||
it('converts from contenteditable to regular spaces', () => {
|
||||
expect(unlinkifyHtml('hello world')).toBe('hello world');
|
||||
expect(unlinkifyHtml('a b')).toBe('a b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function isChild(item: ChecklistItem): boolean {
|
||||
return item.parentId !== null;
|
||||
}
|
||||
|
||||
export function getChildren(parentId: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
return items.filter((i) => i.parentId === parentId);
|
||||
}
|
||||
|
||||
export function getParent(item: ChecklistItem, items: ChecklistItem[]): ChecklistItem | undefined {
|
||||
return item.parentId ? items.find((i) => i.id === item.parentId) : undefined;
|
||||
}
|
||||
|
||||
export function toggleItemWithCascade(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const target = items.find((i) => i.id === id)!;
|
||||
const newChecked = !target.checked;
|
||||
|
||||
let result = items.map((item) => {
|
||||
if (item.id === id) return { ...item, checked: newChecked };
|
||||
if (item.parentId === id) return { ...item, checked: newChecked };
|
||||
return item;
|
||||
});
|
||||
|
||||
// When unchecking a child: reposition it after its parent/siblings in the array
|
||||
// (otherwise it stays at the end of the array where done items live)
|
||||
if (!newChecked && target.parentId) {
|
||||
const parentExists = result.some((i) => i.id === target.parentId);
|
||||
if (!parentExists) {
|
||||
// Parent deleted — orphan it
|
||||
result = result.map((i) => (i.id === id ? { ...i, parentId: null } : i));
|
||||
} else {
|
||||
// Remove the item from its current position
|
||||
const uncheckedItem = result.find((i) => i.id === id)!;
|
||||
result = result.filter((i) => i.id !== id);
|
||||
// Find the last sibling or the parent, insert after it
|
||||
let insertAfter = -1;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
if (result[i].id === target.parentId || result[i].parentId === target.parentId) {
|
||||
insertAfter = i;
|
||||
}
|
||||
}
|
||||
if (insertAfter >= 0) {
|
||||
result.splice(insertAfter + 1, 0, uncheckedItem);
|
||||
} else {
|
||||
result.push(uncheckedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function indentItem(id: string, items: ChecklistItem[], activeItems: ChecklistItem[]): ChecklistItem[] {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || item.parentId !== null) return items;
|
||||
|
||||
const activeIndex = activeItems.findIndex((i) => i.id === id);
|
||||
let newParentId: string | null = null;
|
||||
for (let i = activeIndex - 1; i >= 0; i--) {
|
||||
if (activeItems[i].parentId === null) {
|
||||
newParentId = activeItems[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!newParentId) return items;
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: newParentId };
|
||||
if (i.parentId === id) return { ...i, parentId: newParentId };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function outdentItem(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
const item = items[index];
|
||||
if (!item || item.parentId === null) return items;
|
||||
|
||||
const originalParentId = item.parentId;
|
||||
|
||||
const adoptees: string[] = [];
|
||||
for (let i = index + 1; i < items.length; i++) {
|
||||
if (items[i].parentId === originalParentId) {
|
||||
adoptees.push(items[i].id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: null };
|
||||
if (adoptees.includes(i.id)) return { ...i, parentId: id };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false, parentId: null }];
|
||||
|
||||
let lastTopLevelId: string | null = null;
|
||||
return text.split('\n').map((line) => {
|
||||
if (line.startsWith(' - [x] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: true, parentId: lastTopLevelId };
|
||||
}
|
||||
if (line.startsWith(' - [ ] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: false, parentId: lastTopLevelId };
|
||||
}
|
||||
const item: ChecklistItem = { id: generateId(), text: '', checked: false, parentId: null };
|
||||
if (line.startsWith('- [x] ')) {
|
||||
item.text = line.slice(6);
|
||||
item.checked = true;
|
||||
} else if (line.startsWith('- [ ] ')) {
|
||||
item.text = line.slice(6);
|
||||
} else {
|
||||
item.text = line;
|
||||
}
|
||||
lastTopLevelId = item.id;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeChecklist(items: ChecklistItem[]): string {
|
||||
const topLevel = items.filter((i) => i.parentId === null);
|
||||
const childMap = new Map<string, ChecklistItem[]>();
|
||||
for (const item of items) {
|
||||
if (item.parentId) {
|
||||
const siblings = childMap.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childMap.set(item.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const parent of topLevel) {
|
||||
lines.push(`- [${parent.checked ? 'x' : ' '}] ${parent.text}`);
|
||||
const children = childMap.get(parent.id) ?? [];
|
||||
for (const child of children) {
|
||||
lines.push(` - [${child.checked ? 'x' : ' '}] ${child.text}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?)'"}\]]/g;
|
||||
|
||||
export function linkifyText(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
const matches: { index: number; match: string; href: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
const regex = new RegExp(URL_REGEX.source, 'g');
|
||||
|
||||
while ((m = regex.exec(text)) !== null) {
|
||||
const match = m[0];
|
||||
const href = match.startsWith('www.') ? `https://${match}` : match;
|
||||
matches.push({ index: m.index, match, href });
|
||||
}
|
||||
|
||||
if (matches.length === 0) return escapeHtml(text);
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
for (const { index, match, href } of matches) {
|
||||
result += escapeHtml(text.slice(lastIndex, index));
|
||||
result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="checklist-link">${escapeHtml(match)}</a>`;
|
||||
lastIndex = index + match.length;
|
||||
}
|
||||
result += escapeHtml(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function unlinkifyHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getNoteColor,
|
||||
getNoteChip,
|
||||
NOTE_COLORS,
|
||||
NOTE_COLORS_DARK,
|
||||
NOTE_CHIPS_DARK
|
||||
} from '$lib/utils/colors.js';
|
||||
import type { NoteColor } from '$lib/types/index.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('#21201f');
|
||||
expect(getNoteColor('coral', true)).toBe('#3f1f1c');
|
||||
});
|
||||
|
||||
it('returns default color for unknown color name', () => {
|
||||
expect(getNoteColor('unknown' as any, false)).toBe('#faf5eb');
|
||||
expect(getNoteColor('unknown' as any, true)).toBe('#21201f');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
function toRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
/** WCAG relative luminance. */
|
||||
function relativeLuminance(hex: string): number {
|
||||
const channels = toRgb(hex).map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
}
|
||||
|
||||
function contrastRatio(a: string, b: string): number {
|
||||
const [l1, l2] = [relativeLuminance(a), relativeLuminance(b)];
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
function rgbDistance(a: string, b: string): number {
|
||||
const [ra, ga, ba] = toRgb(a);
|
||||
const [rb, gb, bb] = toRgb(b);
|
||||
return Math.hypot(ra - rb, ga - gb, ba - bb);
|
||||
}
|
||||
|
||||
describe('dark note colours', () => {
|
||||
// Must track [data-theme="dark"] in src/app.css.
|
||||
const DARK_TEXT = '#ece3d3';
|
||||
const DARK_TEXT_MUTED = '#b3a695';
|
||||
|
||||
it('keeps body text above the AA threshold on every dark card', () => {
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps muted text above the AA threshold on every dark card', () => {
|
||||
// The shipped dark theme failed this at 3.77:1 with its previous muted
|
||||
// colour (#9a8e7e). This guard exists so a future palette edit cannot
|
||||
// reintroduce it — it does not discriminate the old surfaces, which were
|
||||
// darker and so scored higher against the lifted muted colour.
|
||||
for (const [name, { bg }] of Object.entries(NOTE_COLORS_DARK)) {
|
||||
expect(contrastRatio(bg, DARK_TEXT_MUTED), `${name} (${bg})`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every pair of dark surfaces distinguishable', () => {
|
||||
// The old palette failed this: fog and storm sat 7.55 apart, so the twelve
|
||||
// colours read as one dark smudge. Threshold 8 clears that while leaving
|
||||
// headroom above the current closest pair, coral/peach at 12.0.
|
||||
const entries = Object.entries(NOTE_COLORS_DARK);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
|
||||
const distance = rgbDistance(a.bg, b.bg);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(8);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNoteChip', () => {
|
||||
it('returns the card colour in light mode, where pastels already are the cards', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS) as NoteColor[]) {
|
||||
expect(getNoteChip(color, false)).toBe(getNoteColor(color, false));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a distinct vivid chip in dark mode, not the card surface', () => {
|
||||
for (const color of Object.keys(NOTE_COLORS_DARK) as NoteColor[]) {
|
||||
expect(getNoteChip(color, true)).not.toBe(getNoteColor(color, true));
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to default for an unknown colour', () => {
|
||||
expect(getNoteChip('unknown' as any, true)).toBe(NOTE_CHIPS_DARK.default);
|
||||
});
|
||||
|
||||
it('has matching keys with the colour maps', () => {
|
||||
expect(Object.keys(NOTE_CHIPS_DARK).sort()).toEqual(Object.keys(NOTE_COLORS_DARK).sort());
|
||||
});
|
||||
|
||||
it('keeps every pair of chips far enough apart to tell apart at 28px', () => {
|
||||
// The regression this guards: at low chroma coral/peach/blossom and
|
||||
// fog/storm collapse into each other.
|
||||
const entries = Object.entries(NOTE_CHIPS_DARK);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const [[nameA, a], [nameB, b]] = [entries[i], entries[j]];
|
||||
const distance = rgbDistance(a, b);
|
||||
expect(distance, `${nameA} vs ${nameB}`).toBeGreaterThan(25);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every chip lighter than every card, so chips read as vivid dots', () => {
|
||||
// A chip set to card-lightness values could still pass the pairwise
|
||||
// distance test above (25 apart) while being just as dark as the cards,
|
||||
// reintroducing the "twelve grey dots" bug this palette fixed. Measured:
|
||||
// the lightest card (mint) has luminance 0.0392; the darkest chip (coral)
|
||||
// has luminance 0.1941 — almost 5x higher, comfortable headroom either
|
||||
// side of the 0.1 threshold below.
|
||||
const cardLuminances = Object.values(NOTE_COLORS_DARK).map(({ bg }) => relativeLuminance(bg));
|
||||
const maxCardLuminance = Math.max(...cardLuminances);
|
||||
expect(maxCardLuminance).toBeLessThan(0.1);
|
||||
|
||||
for (const [name, chip] of Object.entries(NOTE_CHIPS_DARK)) {
|
||||
expect(relativeLuminance(chip), name).toBeGreaterThan(0.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,53 @@ export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
|
||||
chalk: { bg: '#efeff1', label: 'Chalk' }
|
||||
};
|
||||
|
||||
export const NOTE_COLORS_DARK: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#21201f', label: 'Default' },
|
||||
coral: { bg: '#3f1f1c', label: 'Coral' },
|
||||
peach: { bg: '#3f2b1c', label: 'Peach' },
|
||||
sand: { bg: '#3c371b', label: 'Sand' },
|
||||
mint: { bg: '#2a3d1d', label: 'Mint' },
|
||||
sage: { bg: '#1f3d36', label: 'Sage' },
|
||||
fog: { bg: '#253946', label: 'Fog' },
|
||||
storm: { bg: '#1d283d', label: 'Storm' },
|
||||
dusk: { bg: '#3d2843', label: 'Dusk' },
|
||||
blossom: { bg: '#3d2129', label: 'Blossom' },
|
||||
clay: { bg: '#363126', label: 'Clay' },
|
||||
chalk: { bg: '#303036', 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
|
||||
}));
|
||||
|
||||
/**
|
||||
* Picker swatches are labels, not surfaces. A 28px circle at card lightness
|
||||
* reads as a grey dot — small patches lose apparent saturation — so the dark
|
||||
* picker uses vivid mid-lightness chips that share the card's hue but not its
|
||||
* lightness. Light mode needs no equivalent: there the pastels are the cards.
|
||||
*/
|
||||
export const NOTE_CHIPS_DARK: Record<NoteColor, string> = {
|
||||
default: '#8d857c',
|
||||
coral: '#d04f43',
|
||||
peach: '#cd7c42',
|
||||
sand: '#c7b138',
|
||||
mint: '#7bbd4c',
|
||||
sage: '#47b89d',
|
||||
fog: '#609abe',
|
||||
storm: '#547bc0',
|
||||
dusk: '#a965bd',
|
||||
blossom: '#c05d78',
|
||||
clay: '#b5954a',
|
||||
chalk: '#c3c3c8'
|
||||
};
|
||||
|
||||
export function getNoteChip(color: NoteColor, isDark: boolean): string {
|
||||
if (!isDark) return getNoteColor(color, false);
|
||||
return NOTE_CHIPS_DARK[color] ?? NOTE_CHIPS_DARK.default;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
|
||||
import { mergeContent, mergeContentUpdate } from './content-merge.js';
|
||||
|
||||
describe('mergeContent (3-way)', () => {
|
||||
it('should return remote when local is unchanged', () => {
|
||||
@@ -89,17 +89,56 @@ describe('mergeContent (3-way)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeContentTwoWay', () => {
|
||||
it('should return remote when both are identical', () => {
|
||||
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
|
||||
describe('mergeContentUpdate', () => {
|
||||
it('should merge an incoming update against current content when a base is available', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: '- [ ] Milk\n- [ ] Bread',
|
||||
incomingContent: '- [x] Milk\n- [ ] Bread',
|
||||
currentContent: '- [ ] Milk\n- [x] Bread'
|
||||
});
|
||||
|
||||
expect(result).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should attempt merge when contents differ', () => {
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
it('should keep the incoming edit when no base snapshot is available', () => {
|
||||
// Without a common ancestor a 3-way merge is impossible; dropping the
|
||||
// incoming edit would silently lose the user's changes.
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: '- [x] Milk',
|
||||
currentContent: '- [ ] Bread'
|
||||
});
|
||||
|
||||
const result = mergeContentTwoWay(local, remote);
|
||||
// Without a base, conflict resolution prefers remote
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
expect(result).toBe('- [x] Milk');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit equals it', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: null,
|
||||
incomingContent: 'same',
|
||||
currentContent: 'same'
|
||||
});
|
||||
|
||||
expect(result).toBe('same');
|
||||
});
|
||||
|
||||
it('should keep current content when the incoming edit matches the base (no local change)', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'base',
|
||||
currentContent: 'server changed'
|
||||
});
|
||||
|
||||
expect(result).toBe('server changed');
|
||||
});
|
||||
|
||||
it('should take the incoming edit when the server is unchanged from the base', () => {
|
||||
const result = mergeContentUpdate({
|
||||
baseContent: 'base',
|
||||
incomingContent: 'incoming change',
|
||||
currentContent: 'base'
|
||||
});
|
||||
|
||||
expect(result).toBe('incoming change');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,11 +78,28 @@ export function mergeContent(base: string, local: string, remote: string): strin
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-way merge fallback when no base is available.
|
||||
* Uses an empty string as the base, so all lines from both sides appear
|
||||
* as additions. For conflicts (same region), prefers remote.
|
||||
* Merge an incoming content snapshot into the current server content.
|
||||
*
|
||||
* Uses a 3-way merge when a base (common ancestor) is available. Without a base —
|
||||
* e.g. the base snapshot was pruned, or the version was bumped by a metadata-only
|
||||
* change that created no snapshot — a 3-way merge is impossible, so keep the
|
||||
* incoming edit rather than silently discarding the user's changes.
|
||||
*/
|
||||
export function mergeContentTwoWay(local: string, remote: string): string {
|
||||
if (local === remote) return remote;
|
||||
return mergeContent('', local, remote);
|
||||
export function mergeContentUpdate(input: {
|
||||
baseContent: string | null;
|
||||
incomingContent: string;
|
||||
currentContent: string;
|
||||
}): string {
|
||||
const { baseContent, incomingContent, currentContent } = input;
|
||||
|
||||
if (incomingContent === currentContent) return currentContent;
|
||||
if (baseContent === incomingContent) return currentContent;
|
||||
if (baseContent === currentContent) return incomingContent;
|
||||
|
||||
if (baseContent === null) {
|
||||
console.warn('[content-merge] no base snapshot for concurrent edit; keeping incoming content');
|
||||
return incomingContent;
|
||||
}
|
||||
|
||||
return mergeContent(baseContent, incomingContent, currentContent);
|
||||
}
|
||||
|
||||
@@ -85,3 +85,16 @@ describe('stripMarkdown', () => {
|
||||
expect(stripMarkdown('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderMarkdown note-link handling', () => {
|
||||
it('should render a crumb-note:// link as plain text, not a clickable anchor', () => {
|
||||
const html = renderMarkdown('See [Grocery List](crumb-note://abc-123) for details');
|
||||
expect(html).not.toContain('<a');
|
||||
expect(html).toContain('Grocery List');
|
||||
});
|
||||
|
||||
it('should still render a normal external link as a real anchor', () => {
|
||||
const html = renderMarkdown('See [Example](https://example.com) for details');
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,25 @@ md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
}
|
||||
});
|
||||
|
||||
md.core.ruler.after('task-lists', 'note-links', (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type !== 'inline' || !token.children) continue;
|
||||
|
||||
const children = token.children;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type !== 'link_open') continue;
|
||||
const href = children[i].attrGet('href') ?? '';
|
||||
if (!href.startsWith('crumb-note://')) continue;
|
||||
|
||||
let closeIndex = i + 1;
|
||||
while (closeIndex < children.length && children[closeIndex].type !== 'link_close') closeIndex++;
|
||||
children.splice(closeIndex, 1);
|
||||
children.splice(i, 1);
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return md.render(content);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractNoteLinks } from './note-links.js';
|
||||
|
||||
describe('extractNoteLinks', () => {
|
||||
it('should extract a single note-link reference', () => {
|
||||
expect(extractNoteLinks('See [Grocery List](crumb-note://abc-123)')).toEqual(['abc-123']);
|
||||
});
|
||||
|
||||
it('should extract multiple distinct references', () => {
|
||||
const content = '[A](crumb-note://id-1) and [B](crumb-note://id-2)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1', 'id-2']);
|
||||
});
|
||||
|
||||
it('should deduplicate repeated references', () => {
|
||||
const content = '[A](crumb-note://id-1) again: [A again](crumb-note://id-1)';
|
||||
expect(extractNoteLinks(content)).toEqual(['id-1']);
|
||||
});
|
||||
|
||||
it('should return an empty array for content with no note-links', () => {
|
||||
expect(extractNoteLinks('Just [a normal link](https://example.com)')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array for empty content', () => {
|
||||
expect(extractNoteLinks('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside a code block', () => {
|
||||
const content = '```\n[A](crumb-note://id-1)\n```';
|
||||
expect(extractNoteLinks(content)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match crumb-note:// occurring inside inline code', () => {
|
||||
expect(extractNoteLinks('Use `crumb-note://id-1` as an example')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Extract crumb-note://<id> references from markdown content.
|
||||
* Mirrors extractTags' code-block/inline-code exclusion so a reference
|
||||
* mentioned as an example inside a code sample isn't treated as a real link.
|
||||
*/
|
||||
export function extractNoteLinks(content: string): string[] {
|
||||
if (!content) return [];
|
||||
|
||||
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
|
||||
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
|
||||
|
||||
const matches = withoutInlineCode.match(/crumb-note:\/\/([\w-]+)/g);
|
||||
if (!matches) return [];
|
||||
|
||||
const ids = matches.map((m) => m.replace('crumb-note://', ''));
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const LIGHT_THEME_COLOR = '#f0e6d3';
|
||||
const DARK_THEME_COLOR = '#141312';
|
||||
|
||||
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') {
|
||||
const prefersDark =
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
resolved = prefersDark ? '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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
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('#141312');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Custom tooltip action — drop-in replacement for title attributes.
|
||||
* Usage: <button use:tooltip={"Label"}>
|
||||
* Or with options: <button use:tooltip={{ text: "Label", position: "bottom" }}>
|
||||
*/
|
||||
|
||||
export type TooltipPosition = 'top' | 'bottom';
|
||||
|
||||
interface TooltipOptions {
|
||||
text: string;
|
||||
position?: TooltipPosition;
|
||||
}
|
||||
|
||||
function parseOptions(value: string | TooltipOptions): { text: string; position: TooltipPosition } {
|
||||
if (typeof value === 'string') return { text: value, position: 'top' };
|
||||
return { text: value.text, position: value.position ?? 'top' };
|
||||
}
|
||||
|
||||
export function tooltip(node: HTMLElement, value: string | TooltipOptions) {
|
||||
let opts = parseOptions(value);
|
||||
let el: HTMLDivElement | null = null;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function show() {
|
||||
timeout = setTimeout(() => {
|
||||
if (!opts.text) return;
|
||||
el = document.createElement('div');
|
||||
el.className = 'tooltip-popup';
|
||||
el.textContent = opts.text;
|
||||
document.body.appendChild(el);
|
||||
position();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function position() {
|
||||
if (!el) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const tipRect = el.getBoundingClientRect();
|
||||
|
||||
let top: number;
|
||||
if (opts.position === 'bottom') {
|
||||
top = rect.bottom + 6;
|
||||
} else {
|
||||
top = rect.top - tipRect.height - 6;
|
||||
}
|
||||
|
||||
let left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
// Clamp to viewport
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
|
||||
|
||||
el.style.top = `${top}px`;
|
||||
el.style.left = `${left}px`;
|
||||
el.classList.add('tooltip-visible');
|
||||
}
|
||||
|
||||
function hide() {
|
||||
clearTimeout(timeout);
|
||||
if (el) {
|
||||
el.remove();
|
||||
el = null;
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('mouseenter', show);
|
||||
node.addEventListener('mouseleave', hide);
|
||||
node.addEventListener('pointerdown', hide);
|
||||
// Remove native title to prevent double tooltip
|
||||
const nativeTitle = node.getAttribute('title');
|
||||
if (nativeTitle) node.removeAttribute('title');
|
||||
|
||||
return {
|
||||
update(newValue: string | TooltipOptions) {
|
||||
opts = parseOptions(newValue);
|
||||
},
|
||||
destroy() {
|
||||
hide();
|
||||
node.removeEventListener('mouseenter', show);
|
||||
node.removeEventListener('mouseleave', hide);
|
||||
node.removeEventListener('pointerdown', hide);
|
||||
if (nativeTitle) node.setAttribute('title', nativeTitle);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { User, Cpu, ShieldCheck, SlidersHorizontal, Info } from 'lucide-svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let pathname = $derived(page.url.pathname);
|
||||
@@ -23,7 +23,7 @@
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/preferences' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
data-testid="settings-nav-preferences"
|
||||
>
|
||||
<Icon icon="pixelarticons:sliders" width={16} />
|
||||
<SlidersHorizontal size={16} />
|
||||
Preferences
|
||||
</a>
|
||||
</li>
|
||||
@@ -32,7 +32,7 @@
|
||||
href="/settings/profile"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/profile' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:user" width={16} />
|
||||
<User size={16} />
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
@@ -41,7 +41,7 @@
|
||||
href="/settings/mcp"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:cpu" width={16} />
|
||||
<Cpu size={16} />
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
@@ -51,7 +51,7 @@
|
||||
href="/settings/users"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/users' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:shield" width={16} />
|
||||
<ShieldCheck size={16} />
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
@@ -61,7 +61,7 @@
|
||||
href="/settings/about"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/about' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:info-box" width={16} />
|
||||
<Info size={16} />
|
||||
About
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { Key, Copy, Trash2, Plus, Check } from 'lucide-svelte';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
@@ -118,7 +118,7 @@ ${mcpJsonConfig}`);
|
||||
<!-- API Keys -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Icon icon="pixelarticons:lock" width={16} class="text-[var(--primary)]" />
|
||||
<Key size={16} class="text-[var(--primary)]" />
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-[var(--text-muted)]">
|
||||
@@ -140,7 +140,7 @@ ${mcpJsonConfig}`);
|
||||
class="flex items-center gap-1 rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="create-api-key-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" width={14} />
|
||||
<Plus size={14} />
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
@@ -167,9 +167,9 @@ ${mcpJsonConfig}`);
|
||||
data-testid="copy-key-btn"
|
||||
>
|
||||
{#if copiedKey}
|
||||
<Icon icon="pixelarticons:check" width={14} class="text-[var(--success-text,#3a5a40)]" />
|
||||
<Check size={14} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:copy" width={14} />
|
||||
<Copy size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@ ${mcpJsonConfig}`);
|
||||
data-testid="delete-api-key-btn"
|
||||
title="Revoke key"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" width={14} />
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -253,9 +253,9 @@ ${mcpJsonConfig}`);
|
||||
title="Copy JSON config"
|
||||
>
|
||||
{#if copiedJson}
|
||||
<Icon icon="pixelarticons:check" width={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:copy" width={12} />
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -279,9 +279,9 @@ ${mcpJsonConfig}`);
|
||||
title="Copy LLM prompt"
|
||||
>
|
||||
{#if copiedPrompt}
|
||||
<Icon icon="pixelarticons:check" width={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:copy" width={12} />
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user