Compare commits
@@ -70,6 +70,9 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -84,9 +87,11 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -44,9 +47,11 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
name: Capture Landing Page Screenshots
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright with system deps
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Capture screenshots
|
||||
run: pnpm exec tsx scripts/screenshots.ts
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add website/assets/screenshot-*.png
|
||||
git diff --cached --quiet && echo "No changes" && exit 0
|
||||
git commit -m "chore: update landing page screenshots [skip ci]"
|
||||
git push
|
||||
@@ -44,4 +44,7 @@ todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Superpowers brainstorm artifacts
|
||||
.superpowers/
|
||||
package-lock.json
|
||||
|
||||
@@ -163,6 +163,13 @@ GitHub Actions with two workflows:
|
||||
|
||||
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
|
||||
|
||||
## Database schema changes
|
||||
|
||||
When adding or modifying tables in `src/lib/server/db/schema.ts`:
|
||||
|
||||
- **If the table has a foreign key to `users`**: update `deleteUser()` in `src/lib/server/auth.ts` to delete from the new table before the user row is removed. Order matters — delete child rows before parent rows.
|
||||
- **If the table has a foreign key to `notes`**: either add `onDelete: 'cascade'` in the schema definition, or add explicit cleanup in the note deletion logic.
|
||||
|
||||
## Testing philosophy: BDD/TDD
|
||||
|
||||
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
|
||||
|
||||
@@ -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)
|
||||
@@ -46,6 +50,21 @@ volumes:
|
||||
|
||||
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
|
||||
|
||||
#### With Email Notifications (optional)
|
||||
|
||||
Add SMTP settings to enable email notifications (share alerts, welcome emails, security alerts):
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=your-username
|
||||
- SMTP_PASS=your-password
|
||||
- SMTP_FROM=Crumbs <noreply@example.com>
|
||||
```
|
||||
|
||||
Auto-enabled when `SMTP_HOST` is set. Users can opt out in Settings > Preferences. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#email-notifications-optional) for details.
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
```bash
|
||||
@@ -75,6 +94,9 @@ pnpm build # Production build
|
||||
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
|
||||
- Organize with #tags, pinning, archive, and trash
|
||||
- Full-text search across titles, content, and tags
|
||||
- Note version history — browse and restore previous versions
|
||||
- Share notes with other users on the same instance
|
||||
- Email notifications for shares, security alerts, and account events (optional SMTP)
|
||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
|
||||
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.21.0
|
||||
image: ghcr.io/bretzel-app/crumbs:0.22.3
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -11,6 +11,16 @@ services:
|
||||
- DATA_DIR=/data
|
||||
- DATABASE_URL=/data/crumbs.db
|
||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||
# OAuth / SSO (optional — providers auto-enable when configured)
|
||||
- AUTH_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID:-}
|
||||
- AUTH_GOOGLE_CLIENT_SECRET=${AUTH_GOOGLE_CLIENT_SECRET:-}
|
||||
- AUTH_GITHUB_CLIENT_ID=${AUTH_GITHUB_CLIENT_ID:-}
|
||||
- AUTH_GITHUB_CLIENT_SECRET=${AUTH_GITHUB_CLIENT_SECRET:-}
|
||||
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||
- AUTH_OIDC_CLIENT_ID=${AUTH_OIDC_CLIENT_ID:-}
|
||||
- AUTH_OIDC_CLIENT_SECRET=${AUTH_OIDC_CLIENT_SECRET:-}
|
||||
- AUTH_OIDC_DISPLAY_NAME=${AUTH_OIDC_DISPLAY_NAME:-}
|
||||
# Email notifications (optional — auto-enabled when SMTP_HOST is set)
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
|
||||
@@ -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,6 +100,7 @@ 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
|
||||
|
||||
### Collaboration Access Control
|
||||
@@ -135,6 +136,16 @@ MCP tool handlers ┘
|
||||
### Tools (14)
|
||||
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Theme switching uses CSS variable overrides on the `<html>` element:
|
||||
|
||||
- **CSS**: `[data-theme="dark"]` attribute on `<html>` overrides all `--bg-*`, `--text-*`, `--border-*`, and `--primary-*` variables defined in `src/app.css`
|
||||
- **Persistence**: preference stored as `theme` in the existing `userPreferences` key-value system (values: `"system"` | `"light"` | `"dark"`)
|
||||
- **FOUC prevention**: a blocking inline `<script>` in `app.html` reads the preference from localStorage and sets `data-theme` before any content renders
|
||||
- **Reactivity**: a `$effect` in the root `+layout.svelte` watches the theme store and updates `document.documentElement.dataset.theme` on change
|
||||
- **Note card colors**: `getNoteColor()` in `src/lib/utils/colors.ts` accepts a `dark` boolean and returns the appropriate color variant per theme
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
|
||||
@@ -116,7 +116,7 @@ Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported
|
||||
|
||||
### Email Notifications (optional)
|
||||
|
||||
Email notifications are sent when a note is shared with a user. Auto-enabled when `SMTP_HOST` is set.
|
||||
Email notifications are auto-enabled when `SMTP_HOST` is set. See [FEATURES.md](FEATURES.md#note-sharing--collaboration) for the full list of notification types.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to note theme
|
||||
|
||||
### Dark Mode
|
||||
- Three-way theme toggle: System / Light / Dark (in Settings > Preferences)
|
||||
- Dark mode uses a warm parchment variant — retro aesthetic preserved in both themes
|
||||
- Respects system `prefers-color-scheme` preference by default
|
||||
- FOUC-free: theme applied before first paint via a blocking inline script
|
||||
|
||||
### Pin Notes
|
||||
- Pin important notes to always appear at top
|
||||
- Pinned section separated from unpinned notes
|
||||
@@ -81,17 +87,39 @@
|
||||
- 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
|
||||
- Email notifications when a note is shared with you (requires SMTP config, opt-out in preferences)
|
||||
- Email notifications (requires SMTP config, opt-out in preferences):
|
||||
- Share notifications when a note is shared with you
|
||||
- Welcome email when admin creates your account
|
||||
- Password reset notification
|
||||
- Account deletion confirmation
|
||||
- Email address change notification
|
||||
- Role change notification (admin/user)
|
||||
- Removed from shared note notification
|
||||
- Shared note permanently deleted notification
|
||||
- Account locked alert (after failed login attempts)
|
||||
|
||||
### Note Version History
|
||||
- Browse previous versions of any note
|
||||
- Versions are automatically saved when content changes
|
||||
- Restore any previous version with one click
|
||||
- Version snapshots are only created when content actually changes (not on metadata-only updates)
|
||||
|
||||
### Full-Text Search
|
||||
- Search across note titles, content, and tags
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -0,0 +1,190 @@
|
||||
# Dark Mode / Theme Toggle — Design Spec
|
||||
|
||||
**Issue:** #26
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Draft
|
||||
|
||||
## Summary
|
||||
|
||||
Add a dark variant of the retro parchment theme with a three-way toggle (system / light / dark) in Settings > Preferences. The dark theme preserves the retro 8-bit character — warm dark tones, not cold grays.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Toggle location:** Settings > Preferences only (no header icon)
|
||||
- **UI pattern:** Segmented buttons (system / light / dark), matching existing `defaultNoteMode` toggle style
|
||||
- **Implementation:** CSS `[data-theme="dark"]` attribute on `<html>`, variable overrides in `app.css`
|
||||
- **Pixel grid texture:** Inverted to light dots (`#e8dcc8`) at 3% opacity in dark mode
|
||||
- **Default:** `system` (follows `prefers-color-scheme`)
|
||||
- **Light mode:** No `data-theme` attribute set (`:root` defaults apply). `data-theme="dark"` is only set for dark mode. This means no redundant `[data-theme="light"]` block is needed.
|
||||
|
||||
## 1. CSS Variable Overrides
|
||||
|
||||
Add `--grid-dot` to the existing `:root` block and add a `[data-theme="dark"]` block after it in `app.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* ...existing vars... */
|
||||
--grid-dot: #1a1a2e; /* new — extracted from body::before */
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-base: #1a1715;
|
||||
--bg-surface: #2a2520;
|
||||
--text: #e8dcc8;
|
||||
--text-muted: #9a8e7e;
|
||||
--border: #e8dcc8;
|
||||
--border-subtle: #3a3530;
|
||||
--destructive: #d4604e;
|
||||
--error-bg: #3a2020;
|
||||
--error-border: #5a3030;
|
||||
--error-text: #e8a090;
|
||||
--success-bg: #203020;
|
||||
--success-text: #90c880;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
--grid-dot: #e8dcc8;
|
||||
}
|
||||
```
|
||||
|
||||
`--primary` and `--primary-hover` remain unchanged — gold works on both backgrounds.
|
||||
|
||||
`--card-shadow` uses `var(--border-subtle)` in both modes for consistency — the dark `--border-subtle` (#3a3530) produces the right effect.
|
||||
|
||||
Update `body::before` to use the new variable:
|
||||
```css
|
||||
body::before {
|
||||
background-image: repeating-conic-gradient(var(--grid-dot) 0% 25%, transparent 0% 50%);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Theme Preference Storage
|
||||
|
||||
Extend the existing `UserPreferences` system:
|
||||
|
||||
- **Type change:** Add `theme: 'system' | 'light' | 'dark'` to `UserPreferences` interface
|
||||
- **Default:** `'system'`
|
||||
- **Storage:** Same key-value `userPreferences` table + localStorage cache — no schema migration needed
|
||||
- **Do NOT add `theme` to `BOOLEAN_PREF_KEYS`** — it's a string enum, not a boolean
|
||||
|
||||
Files to modify:
|
||||
- `src/lib/types/preferences.ts` — add `theme` field and default
|
||||
|
||||
The existing `fromServerRecord` in `preferences.svelte.ts` does not validate string values. The `applyTheme` function (Section 4) must guard against unexpected values and fall back to `'system'` if the value is not one of the three valid options.
|
||||
|
||||
## 3. FOUC Prevention
|
||||
|
||||
Add a blocking `<script>` in `app.html` `<head>`, **after** the `<meta name="theme-color">` tag and **before** `%sveltekit.head%`:
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||
var t = p.theme || 'system';
|
||||
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
if (t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.content = '#1a1715';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
This runs synchronously before first paint. The script validates the theme value and null-checks the meta element.
|
||||
|
||||
## 4. Reactive Theme Application
|
||||
|
||||
Create `src/lib/utils/theme.svelte.ts` (`.svelte.ts` to enable Svelte 5 runes) with:
|
||||
|
||||
- `applyTheme(theme: 'system' | 'light' | 'dark')` — resolves system preference, sets/removes `data-theme="dark"` on `document.documentElement`, updates `<meta name="theme-color">`. Guards against unexpected values (falls back to `'system'`). All DOM/`matchMedia` access guarded with `browser` check from `$app/environment`.
|
||||
- `isDarkMode` — a reactive `$state` boolean, updated by `applyTheme` and the `matchMedia` listener. Components import this to conditionally use dark color variants.
|
||||
|
||||
Place the theme `$effect` in the **root `+layout.svelte`** (not just `(app)` layout). This ensures both `(app)` and `(auth)` layout groups get the theme applied. The root layout currently only imports CSS — this adds a small `$effect` that watches `preferences.theme` and calls `applyTheme`. It also registers a `matchMedia('(prefers-color-scheme: dark)')` change listener when in system mode, cleaned up on destroy.
|
||||
|
||||
## 5. Note Card Dark Colors
|
||||
|
||||
Add a parallel `NOTE_COLORS_DARK` map in `src/lib/utils/colors.ts`:
|
||||
|
||||
| Color | Light | Dark |
|
||||
|-------|-------|------|
|
||||
| default | `#faf5eb` | `#2a2520` |
|
||||
| coral | `#faafa8` | `#4a2522` |
|
||||
| peach | `#f39f76` | `#4a3020` |
|
||||
| sand | `#fff8b8` | `#3a3520` |
|
||||
| mint | `#e2f6d3` | `#2a3a22` |
|
||||
| sage | `#b4ddd3` | `#223a32` |
|
||||
| fog | `#d4e4ed` | `#222e3a` |
|
||||
| storm | `#aeccdc` | `#1e2a35` |
|
||||
| dusk | `#d3bfdb` | `#352540` |
|
||||
| blossom | `#f6e2dd` | `#3a2830` |
|
||||
| clay | `#e9e3d4` | `#302e28` |
|
||||
| chalk | `#efeff1` | `#2e2e30` |
|
||||
|
||||
Export a helper `getNoteColor(color: NoteColor, isDark: boolean): string` that returns the correct bg hex.
|
||||
|
||||
Components (`NoteCard.svelte`, `NoteEditor.svelte`, `ColorPicker.svelte`) import `isDarkMode` from `theme.svelte.ts` and pass it to `getNoteColor`. This is the single approach — do not use direct DOM inspection for dark mode detection.
|
||||
|
||||
## 6. Settings UI
|
||||
|
||||
Add a "Theme" section to `src/routes/(app)/settings/preferences/+page.svelte` as the **first** preference (before "Default note mode"):
|
||||
|
||||
```svelte
|
||||
<!-- Theme -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Theme</span>
|
||||
<div class="flex gap-1" role="group" aria-label="Theme">
|
||||
{#each [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']] as [value, label]}
|
||||
<button
|
||||
onclick={() => updatePreference('theme', value)}
|
||||
class="rounded-sm border px-4 py-2 text-sm transition-colors ..."
|
||||
data-testid="pref-theme-{value}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Test IDs: `pref-theme-system`, `pref-theme-light`, `pref-theme-dark`.
|
||||
|
||||
## 7. Landing Page
|
||||
|
||||
Out of scope for this spec. The landing page (`website/`) uses its own color system separate from `app.css`. Dark mode support for the landing page will be tracked separately.
|
||||
|
||||
## 8. Prose / Typography Overrides
|
||||
|
||||
The `.prose` overrides in `app.css` already use CSS variables, so they'll automatically adapt. No changes needed.
|
||||
|
||||
The TipTap editor styles (table borders, selected cells, task lists) also use CSS variables. No changes needed.
|
||||
|
||||
## 9. Color Picker in Dark Mode
|
||||
|
||||
The `ColorPicker` component shows circular swatches. In dark mode, these should display the dark color variants. The component imports `isDarkMode` from `theme.svelte.ts` and uses `getNoteColor(color, isDarkMode)` to get the correct swatch background.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### Unit tests
|
||||
- `theme.svelte.ts` — test `applyTheme` sets correct attribute and meta tag, validates unknown values
|
||||
- `colors.ts` — test `getNoteColor` returns correct color for light/dark
|
||||
|
||||
### E2E tests
|
||||
- Theme toggle in preferences persists and applies (use `data-testid` selectors)
|
||||
- System preference detection (use Playwright's `page.emulateMedia({ colorScheme: 'dark' })`)
|
||||
- No FOUC on page reload in dark mode
|
||||
- Note cards render with correct dark colors
|
||||
|
||||
## 11. PWA Manifest
|
||||
|
||||
The PWA manifest (`@vite-pwa/sveltekit`) contains a static `theme_color`. This cannot be dynamically changed. The manifest will keep the light theme color (`#f0e6d3`). The `<meta name="theme-color">` is updated dynamically by the FOUC script and `applyTheme`, which handles the browser chrome color. This is a known limitation of PWA manifests and is acceptable.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Header quick-toggle icon
|
||||
- Per-note theme override
|
||||
- Custom color theme builder
|
||||
- Dark mode for public shared note pages (can be added later)
|
||||
- Dark mode for landing page (separate task)
|
||||
@@ -0,0 +1,232 @@
|
||||
# Public Note Sharing — Design Spec
|
||||
|
||||
**Issue**: [#7 — Public links and shared notes](https://github.com/bretzel-app/crumbs/issues/7)
|
||||
**Date**: 2026-03-20
|
||||
|
||||
## Overview
|
||||
|
||||
Allow sharing individual notes via a public URL that anyone can view without authentication. Read-only sharing via unguessable tokens. Integrates into the existing ShareDialog alongside collaborator management.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Share UI location | Inside existing ShareDialog | One "Share" entry point for all sharing (public + collaborators) |
|
||||
| Expiry | Deferred to future version | Simpler v1; active/revoked toggle only |
|
||||
| Rate limiting | In-memory per-IP | Lightweight, resets on restart; token entropy handles enumeration |
|
||||
| Architecture | Separated shares-service | Follows collaborators-service pattern; clean module boundary |
|
||||
| Sharing icon | Globe (public) or Users (collaborators) | Single icon slot; tooltip shows combined sharing state |
|
||||
|
||||
## Schema
|
||||
|
||||
New `sharedNotes` table in `src/lib/server/db/schema.ts`:
|
||||
|
||||
```typescript
|
||||
export const sharedNotes = sqliteTable('shared_notes', {
|
||||
noteId: text('note_id').primaryKey().references(() => notes.id, { onDelete: 'cascade' }),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }), // NULL = no expiry, reserved for future use
|
||||
});
|
||||
```
|
||||
|
||||
- `noteId` as primary key — one share per note, no separate `id` needed
|
||||
- `integer` timestamps with `mode: 'timestamp'` — matches all other tables in the schema
|
||||
- `createdAt` set in service layer (`new Date()`), not via SQL default
|
||||
- CASCADE delete — deleting the note removes the share
|
||||
- Token: `nanoid(21)` — ~128 bits of entropy
|
||||
- No update to `deleteUser()` needed — shares cascade through notes (foreign keys enabled via `PRAGMA foreign_keys = ON` in db connection)
|
||||
|
||||
## Types
|
||||
|
||||
Add to `src/lib/types/index.ts`:
|
||||
|
||||
```typescript
|
||||
export interface NoteShare {
|
||||
noteId: string;
|
||||
token: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
```
|
||||
|
||||
Extend the `Note` interface:
|
||||
|
||||
```typescript
|
||||
export interface Note {
|
||||
// ...existing fields
|
||||
shareToken?: string; // populated during hydration if a public share exists
|
||||
}
|
||||
```
|
||||
|
||||
## Service Layer
|
||||
|
||||
New file: `src/lib/server/shares-service.ts`
|
||||
|
||||
| Function | Params | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `createShare` | `db, noteId, userId` | `{ token }` | Verify ownership, generate nanoid(21), insert row, return token. If share already exists, return existing token. |
|
||||
| `revokeShare` | `db, noteId, userId` | `void` | Verify ownership, delete share row |
|
||||
| `getShareByNoteId` | `db, noteId` | `NoteShare \| null` | Return share if exists (for ShareDialog) |
|
||||
| `getSharedNote` | `db, token` | `SharedNoteData \| null` | Public — fetch note by token, return null if trashed or not found |
|
||||
| `fetchSharesForNotes` | `db, noteIds` | `Map<string, string>` | Batch fetch — returns Map of noteId → token for hydration (only token needed for NoteCard indicator) |
|
||||
|
||||
`SharedNoteData` type (returned to public viewers):
|
||||
|
||||
```typescript
|
||||
interface SharedNoteData {
|
||||
title: string;
|
||||
content: string;
|
||||
checklistMode: boolean;
|
||||
color: NoteColor;
|
||||
attachments: Attachment[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `POST /api/notes/[id]/share`
|
||||
|
||||
- **Auth**: Required (owner only)
|
||||
- **Body**: None
|
||||
- **Response**: `{ token: string, url: string }`
|
||||
- **Behavior**: Creates share or returns existing one. URL constructed from request origin + `/s/${token}`
|
||||
|
||||
### `DELETE /api/notes/[id]/share`
|
||||
|
||||
- **Auth**: Required (owner only)
|
||||
- **Response**: `204 No Content`
|
||||
- **Behavior**: Deletes share row. Link immediately returns 404.
|
||||
|
||||
### `GET /api/shared/[token]`
|
||||
|
||||
- **Auth**: None (public)
|
||||
- **Rate limit**: 60 requests/min per IP (in-memory)
|
||||
- **Response**: `SharedNoteData` JSON
|
||||
- **Error cases**:
|
||||
- Invalid/unknown token → 404
|
||||
- Note is trashed → 404
|
||||
- Rate limited → 429
|
||||
|
||||
### `GET /api/shared/[token]/attachment/[attachmentId]`
|
||||
|
||||
- **Auth**: None (public)
|
||||
- **Rate limit**: Same as above
|
||||
- **Response**: Image binary (proxied from attachment storage)
|
||||
- **Behavior**: Verify attachment belongs to the shared note, then serve
|
||||
|
||||
## Routes
|
||||
|
||||
```
|
||||
src/routes/
|
||||
(share)/
|
||||
+layout.svelte # Minimal layout: parchment bg, centered, no app chrome
|
||||
s/[token]/
|
||||
+page.server.ts # Load shared note by token via shares-service
|
||||
+page.svelte # Read-only note renderer
|
||||
```
|
||||
|
||||
### Auth Middleware
|
||||
|
||||
Add `/s/` and `/api/shared` to `PUBLIC_PATHS` in `src/hooks.server.ts`. Both are needed — `/s/` for the public page route, `/api/shared` for the public API endpoints.
|
||||
|
||||
### Public Page Design
|
||||
|
||||
- Parchment background (`--bg-base`)
|
||||
- Centered content card (`--bg-surface`, `--border-subtle`, `--card-shadow`)
|
||||
- Note title in JetBrains Mono
|
||||
- Rendered markdown content or checklist (read-only)
|
||||
- Inline images from attachments
|
||||
- No tags, no edit controls, no sidebar, no navigation
|
||||
- Footer: "POWERED BY CRUMBS" in Press Start 2P pixel font, links to project
|
||||
|
||||
## UI Components
|
||||
|
||||
### ShareDialog Changes
|
||||
|
||||
Add a "Public link" section **above** the existing collaborators section:
|
||||
|
||||
**Inactive state:**
|
||||
- Globe icon + "Public link" label + toggle switch (off)
|
||||
- Subtitle: "Anyone with the link can view this note (read-only)"
|
||||
|
||||
**Active state:**
|
||||
- Globe icon + "Public link" label + toggle switch (on, gold)
|
||||
- URL input (readonly) + "Copy" button
|
||||
- Subtitle: "Toggle off to revoke access. The link will return a 404."
|
||||
- Border highlight in `--primary` gold
|
||||
|
||||
Toggle on → calls `POST /api/notes/[id]/share`, displays URL
|
||||
Toggle off → calls `DELETE /api/notes/[id]/share`, hides URL
|
||||
Copy button → clipboard API, brief "Copied!" feedback
|
||||
|
||||
### NoteCard Share Indicator
|
||||
|
||||
Single icon slot in top-right of NoteCard (replaces showing both icons):
|
||||
|
||||
| State | Icon | Color |
|
||||
|---|---|---|
|
||||
| Public link active | `Globe` (Lucide) | `--primary` gold |
|
||||
| Collaborators only | `Users` (Lucide) | `--text-muted` |
|
||||
| Both | `Globe` (Lucide) | `--primary` gold |
|
||||
| Neither | No icon | — |
|
||||
|
||||
**Hover tooltip** shows combined sharing info regardless of which icon is displayed:
|
||||
- "Shared publicly via link" (if public share exists)
|
||||
- "Shared with N collaborator(s)" (if collaborators exist)
|
||||
- Both lines if both exist
|
||||
|
||||
### NoteEditor Share Button
|
||||
|
||||
Existing Share button behavior unchanged. The Globe/Users icon logic from NoteCard also applies to the editor toolbar button to indicate current sharing state.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
New file: `src/lib/server/ip-rate-limit.ts` — separate from the existing DB-backed `rate-limit.ts` (which is login-specific).
|
||||
|
||||
```typescript
|
||||
// In-memory store: Map<IP, { count: number, windowStart: number }>
|
||||
// 60 requests per minute per IP
|
||||
// Applied to GET /api/shared/[token] and GET /api/shared/[token]/attachment/[id]
|
||||
```
|
||||
|
||||
Resets on server restart. No database table needed.
|
||||
|
||||
## Hydration
|
||||
|
||||
`hydrateNotes()` in `notes-service.ts` must be updated to call `fetchSharesForNotes()` and populate `shareToken` on each note. This follows the existing pattern where `hydrateNotes` already batch-fetches tags, attachments, and collaborators.
|
||||
|
||||
## Security
|
||||
|
||||
- Tokens are unguessable: nanoid(21) = ~128 bits of entropy
|
||||
- `/s/` and `/api/shared` routes bypass auth middleware — only these prefixes
|
||||
- Trashed notes return 404 even if share token is valid
|
||||
- Only note owners can create/revoke shares — collaborators cannot
|
||||
- No user information exposed on public page (no username, no email, no tags)
|
||||
- Rate limiting prevents brute-force enumeration
|
||||
- Attachments served through share-specific route (validates attachment belongs to shared note)
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- Expiry dates / auto-expire
|
||||
- Password-protected shares
|
||||
- Edit access through share links
|
||||
- Share analytics / view counts
|
||||
- Email notifications for share events
|
||||
- E2EE interaction (no E2EE exists)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Share toggle in ShareDialog creates/revokes public link
|
||||
- [ ] Unique nanoid(21) token generated per shared note
|
||||
- [ ] Public `/s/[token]` route renders note without auth
|
||||
- [ ] Copy link to clipboard with feedback
|
||||
- [ ] Revoke share → link returns 404
|
||||
- [ ] Globe indicator on NoteCard for publicly shared notes
|
||||
- [ ] Combined tooltip showing all sharing state
|
||||
- [ ] Trashed notes return 404 even if shared
|
||||
- [ ] Minimal, parchment-styled public page with "Powered by Crumbs"
|
||||
- [ ] In-memory rate limiting on public endpoints
|
||||
- [ ] Attachments accessible on public page
|
||||
@@ -0,0 +1,322 @@
|
||||
# Screenshot Automation for Landing Page
|
||||
|
||||
## Overview
|
||||
|
||||
A Playwright-based script that seeds the Crumbs app with bretzel-themed demo data, captures 10 screenshots (7 desktop + 3 mobile) for the landing page, and commits them. Triggered manually via a GitHub Actions workflow.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scripts/
|
||||
screenshots.ts # Main orchestrator: seed + capture
|
||||
fixtures/
|
||||
bretzel-pixel.ts # Generates a pixel-art bretzel PNG programmatically
|
||||
```
|
||||
|
||||
Single script (`scripts/screenshots.ts`) that:
|
||||
1. Spawns the preview server as a child process (`pnpm preview --port 4173`), same approach as `playwright.config.ts` uses for e2e tests. The app uses `@sveltejs/adapter-node`, so `vite preview` import won't work — must spawn via CLI.
|
||||
2. Deletes any existing screenshot DB to start fresh
|
||||
3. Seeds data via API calls (setup admin, login, create notes/users/keys)
|
||||
4. Navigates to each view and captures screenshots at specific viewports
|
||||
5. Saves PNGs directly to `website/assets/`
|
||||
6. Kills the preview server in `finally` block
|
||||
|
||||
## Seed Data — Bretzel Universe
|
||||
|
||||
### Users
|
||||
|
||||
| User | Email | Role | Purpose |
|
||||
|------|-------|------|---------|
|
||||
| Bretzel Admin | admin@bretzel.app | admin | Main user, owns all notes |
|
||||
| Alice Chen | alice@bretzel.app | user | Collaborator for sharing screenshot |
|
||||
|
||||
### Tags
|
||||
|
||||
Passed explicitly in the `tags` array of each `POST /api/notes` request body: `baking`, `bretzel`, `dev`, `devops`, `homelab`, `personal`, `reading`, `weekend`. Tags are NOT auto-parsed from hashtags in content — they must be provided as a separate field.
|
||||
|
||||
### Notes
|
||||
|
||||
| # | Title | Color | Mode | Tags | Pinned | Special |
|
||||
|---|-------|-------|------|------|--------|---------|
|
||||
| 1 | Bretzel Ingredients | sand | checklist | #baking, #bretzel | yes | Checklist items (see below) |
|
||||
| 2 | Self-Hosting Stack | fog | rich text | #homelab, #devops | no | Homelab list (see below) |
|
||||
| 3 | Back to Retro Gaming | peach | rich text | #reading, #personal | no | Retro gaming note |
|
||||
| 4 | The Best Software Is the Software You Own | coral | rich text | #personal, #bretzel | no | Self-hosting manifesto |
|
||||
| 5 | Weekend Plans | mint | rich text | #personal, #weekend | no | Casual weekend plans |
|
||||
| 6 | Ancient Flute Harmonica Discovered | chalk | rich text | #reading | no | Quirky news note |
|
||||
| 7 | Baking code for the... | clay | checklist | #dev, #bretzel | no | Dev/baking crossover |
|
||||
|
||||
### Note Content Details
|
||||
|
||||
**Bretzel Ingredients** (checklist):
|
||||
- [x] 500g bread flour
|
||||
- [x] 300ml warm water
|
||||
- [x] 10g salt
|
||||
- [ ] 7g dry yeast
|
||||
- [ ] 30g butter (softened)
|
||||
- [ ] Baking soda for the bath
|
||||
- [ ] Coarse salt for topping
|
||||
|
||||
**Self-Hosting Stack** (rich text — used for editor screenshot):
|
||||
```
|
||||
Current homelab setup:
|
||||
|
||||
**Crumbs** — notes (obviously)
|
||||
**42** — holiday budget tracking
|
||||
**Dokploy** — deployment platform
|
||||
**Outline** — team wiki
|
||||
**Navidrome** — music streaming
|
||||
**Vaultwarden** — passwords
|
||||
**Nextcloud** — files & calendar
|
||||
**Plausible** — analytics
|
||||
|
||||
Everything below daily reverse proxy.
|
||||
```
|
||||
|
||||
**The Best Software Is the Software You Own** (rich text):
|
||||
```
|
||||
Self-host everything. Trust no cloud.
|
||||
|
||||
The best software is the software you own.
|
||||
Your data. Your server. Your rules.
|
||||
|
||||
If it can't run on your hardware, it's not really yours.
|
||||
```
|
||||
|
||||
**Back to Retro Gaming** (rich text):
|
||||
```
|
||||
Games to replay this summer:
|
||||
|
||||
Chrono Trigger (SNES) — best RPG ever made
|
||||
The Legend of Zelda: Link's Awakening (GB)
|
||||
Castlevania: Symphony of the Night (PS1)
|
||||
Advance Wars (GBA)
|
||||
Final Fantasy Tactics (PS1)
|
||||
|
||||
The pixel art era was peak game design.
|
||||
```
|
||||
|
||||
**Weekend Plans** (rich text):
|
||||
```
|
||||
Saturday:
|
||||
- Farmer's market (get sourdough starter)
|
||||
- Fix the Dokploy SSL cert renewal
|
||||
- Bake a batch of bretzels
|
||||
|
||||
Sunday:
|
||||
- Retro gaming marathon
|
||||
- Update the self-hosting stack
|
||||
- Plan next trip on 42
|
||||
```
|
||||
|
||||
**Ancient Flute Harmonica Discovered** (rich text):
|
||||
```
|
||||
Archaeologists found a 40,000-year-old flute carved from vulture bone.
|
||||
|
||||
The oldest known musical instrument.
|
||||
Imagine the first song ever played.
|
||||
```
|
||||
|
||||
**Baking code for the...** (checklist):
|
||||
```
|
||||
- [x] Set up CI/CD pipeline
|
||||
- [x] Write the CRDT sync logic
|
||||
- [ ] Bake the docker image
|
||||
- [ ] Deploy to the homelab
|
||||
- [ ] Celebrate with a bretzel
|
||||
```
|
||||
|
||||
### Image Attachments
|
||||
|
||||
**Pixel-art bretzel image** (`scripts/fixtures/bretzel-pixel.ts`):
|
||||
- Generated programmatically as a PNG using raw pixel data (no external dependencies)
|
||||
- ~64x64 pixel grid, bretzel shape in warm browns (#C8860A gold, #8B6914 dark, #F0E6D3 parchment bg)
|
||||
- Attached as featured image to "Bretzel Ingredients" note
|
||||
- Also create a second smaller image attached to the same note (e.g. a pixel-art salt shaker or oven) for visual variety
|
||||
|
||||
### Version History
|
||||
|
||||
Send 4-5 `PATCH /api/notes/:id` calls to "Self-Hosting Stack" with varying content. Version history entries are created as a side-effect of each PATCH — there is no explicit "create version" endpoint. Each version adds or changes a tool in the stack, so the diff is meaningful when viewed in the history panel.
|
||||
|
||||
### API Keys
|
||||
|
||||
Create via API after seeding:
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| Claude Code | For MCP integration screenshot |
|
||||
| N8N | For automation screenshot |
|
||||
|
||||
## Screenshot Capture Plan
|
||||
|
||||
### Desktop (1280x800)
|
||||
|
||||
| # | Filename | View | Capture Steps |
|
||||
|---|----------|------|---------------|
|
||||
| 1 | screenshot-grid.png | Home grid | Navigate to `/`, wait for notes to render, ensure sidebar is expanded with tags visible |
|
||||
| 2 | screenshot-editor.png | Note editor | Click "Self-Hosting Stack" note, wait for editor to open with content and toolbar visible |
|
||||
| 3 | screenshot-checklist.png | Checklist mode | Click "Bretzel Ingredients" note, wait for checklist items with checkboxes visible |
|
||||
| 4 | screenshot-sharing.png | Share dialog | Open a note, click share button, wait for ShareDialog with "Alice Chen" listed as collaborator |
|
||||
| 5 | screenshot-history.png | Version history | Open "Self-Hosting Stack", click history button, select a middle version in the list, wait for preview panel |
|
||||
| 6 | screenshot-attachments.png | Attachments | Open "Bretzel Ingredients" note (has featured bretzel image), ensure image thumbnails and featured strip visible |
|
||||
| 7 | screenshot-api.png | Settings > API/MCP | Navigate to `/settings/mcp`, wait for API keys list ("Claude Code", "N8N") and MCP config JSON block |
|
||||
|
||||
### Mobile (390x844)
|
||||
|
||||
| # | Filename | View | Capture Steps |
|
||||
|---|----------|------|---------------|
|
||||
| 1 | screenshot-mobile-grid.png | Home grid | Navigate to `/`, wait for notes in mobile layout (no sidebar, tags as horizontal pills) |
|
||||
| 2 | screenshot-mobile-editor.png | Note editor | Open "Self-Hosting Stack" note, wait for mobile editor |
|
||||
| 3 | screenshot-mobile-checklist.png | Checklist | Open "Bretzel Ingredients" note, wait for mobile checklist view |
|
||||
|
||||
### Capture Settings
|
||||
- Format: PNG
|
||||
- Full page: false (viewport only)
|
||||
- Animations disabled via `page.emulateMedia({ reducedMotion: 'reduce' })` to avoid capturing mid-animation
|
||||
- Wait for `networkidle` after each navigation before capturing
|
||||
|
||||
## GitHub Actions Workflow
|
||||
|
||||
**File**: `.github/workflows/screenshots.yml`
|
||||
|
||||
```yaml
|
||||
name: Screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
screenshots:
|
||||
name: Capture Landing Page Screenshots
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers and system deps
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Capture screenshots
|
||||
run: pnpm exec tsx scripts/screenshots.ts
|
||||
env:
|
||||
DATABASE_URL: ./data/screenshots.db
|
||||
|
||||
- name: Commit screenshots
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add website/assets/screenshot-*.png
|
||||
git diff --cached --quiet && echo "No screenshot changes" && exit 0
|
||||
git commit -m "chore: update landing page screenshots [skip ci]"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Script Structure
|
||||
|
||||
```typescript
|
||||
// scripts/screenshots.ts
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { generateBretzelPixelArt } from './fixtures/bretzel-pixel';
|
||||
|
||||
const BASE = 'http://localhost:4173';
|
||||
const DESKTOP = { width: 1280, height: 800 };
|
||||
const MOBILE = { width: 390, height: 844 };
|
||||
const OUTPUT = 'website/assets';
|
||||
|
||||
async function main() {
|
||||
// 1. Start preview server (spawn child process, wait for port 4173)
|
||||
const server = await startPreviewServer();
|
||||
|
||||
// 2. Launch browser
|
||||
const browser = await chromium.launch();
|
||||
|
||||
try {
|
||||
// 3. Seed data (desktop context — API calls work at any viewport)
|
||||
const seedContext = await browser.newContext({ viewport: DESKTOP });
|
||||
const seedPage = await seedContext.newPage();
|
||||
await seed(seedPage);
|
||||
|
||||
// 4. Desktop screenshots
|
||||
await captureDesktop(seedPage);
|
||||
await seedContext.close();
|
||||
|
||||
// 5. Mobile screenshots
|
||||
const mobileContext = await browser.newContext({ viewport: MOBILE });
|
||||
const mobilePage = await mobileContext.newPage();
|
||||
await login(mobilePage);
|
||||
await captureMobile(mobilePage);
|
||||
await mobileContext.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function seed(page: Page) {
|
||||
// Setup admin via POST /api/auth/setup
|
||||
// Login via POST /api/auth/login (sets session cookie on page context)
|
||||
// Create notes via POST /api/notes (with content, color, tags:string[], pinned, checklistMode)
|
||||
// → capture note IDs from responses for later use
|
||||
// Create collaborator user via POST /api/admin/users
|
||||
// → capture alice.id (numeric) from response
|
||||
// Share a note via POST /api/notes/:id/collaborators { userId: alice.id }
|
||||
// Create version history: PATCH /api/notes/:id × 4-5 with varying content
|
||||
// Upload pixel-art bretzel image via POST /api/notes/:id/attachments (multipart form)
|
||||
// Create API keys via POST /api/settings/api-keys { name: "Claude Code" } and { name: "N8N" }
|
||||
}
|
||||
|
||||
async function captureDesktop(page: Page) {
|
||||
// D1: Grid view
|
||||
// D2: Editor (Self-Hosting Stack)
|
||||
// D3: Checklist (Bretzel Ingredients)
|
||||
// D4: Sharing dialog
|
||||
// D5: Version history
|
||||
// D6: Attachments
|
||||
// D7: API/MCP settings
|
||||
}
|
||||
|
||||
async function captureMobile(page: Page) {
|
||||
// M1: Grid view
|
||||
// M2: Editor
|
||||
// M3: Checklist
|
||||
}
|
||||
```
|
||||
|
||||
## Landing Page Updates
|
||||
|
||||
After the screenshots are generated, `website/index.html` needs updating:
|
||||
- Add new screenshot entries for: checklist, sharing, history, attachments, API
|
||||
- Update alt text and labels
|
||||
- Hero image stays as `screenshot-grid.png`
|
||||
- Mobile section adds checklist screenshot, replaces sidebar screenshot
|
||||
|
||||
## Dependencies
|
||||
|
||||
No new dependencies. Uses:
|
||||
- `playwright` (already in devDependencies for e2e tests)
|
||||
- `tsx` (already in devDependencies for other scripts)
|
||||
- Node.js built-in `fs`, `path`, `Buffer` for pixel-art PNG generation
|
||||
- SvelteKit's preview server (spawned as child process via `pnpm preview`)
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all decisions resolved during brainstorming.
|
||||
@@ -0,0 +1,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`);
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1773150888301,
|
||||
"tag": "0002_deep_black_widow",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1774037379518,
|
||||
"tag": "0003_uneven_tomorrow_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.21.1",
|
||||
"version": "0.23.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
@@ -12,7 +12,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"build": "npx tsx scripts/generate-openapi.ts && vite build",
|
||||
"openapi": "npx tsx scripts/generate-openapi.ts",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -54,6 +55,8 @@
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nanoid": "^5.1.7",
|
||||
"node-diff3": "^3.2.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
@@ -75,6 +78,7 @@
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"jsdom": "^29.0.1",
|
||||
"nyc": "^18.0.0",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
@@ -83,7 +87,9 @@
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"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"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -95,7 +101,8 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0"
|
||||
"cookie": ">=0.7.0",
|
||||
"hono": ">=4.12.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure'
|
||||
screenshot: 'only-on-failure',
|
||||
serviceWorkers: 'block'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ overrides:
|
||||
serialize-javascript: '>=7.0.3'
|
||||
esbuild@<0.25.0: 0.25.12
|
||||
cookie: '>=0.7.0'
|
||||
hono: '>=4.12.7'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -79,6 +80,12 @@ importers:
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
nanoid:
|
||||
specifier: ^5.1.7
|
||||
version: 5.1.7
|
||||
node-diff3:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
nodemailer:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
@@ -133,10 +140,13 @@ importers:
|
||||
version: 1.1.0(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 3.2.4(vitest@3.2.4(@types/node@25.3.5)(jiti@2.6.1)(jsdom@29.0.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.9
|
||||
version: 0.31.9
|
||||
jsdom:
|
||||
specifier: ^29.0.1
|
||||
version: 29.0.1
|
||||
nyc:
|
||||
specifier: ^18.0.0
|
||||
version: 18.0.0
|
||||
@@ -161,9 +171,15 @@ importers:
|
||||
vite-plugin-istanbul:
|
||||
specifier: ^7.2.1
|
||||
version: 7.2.1(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vite-plugin-pwa:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
version: 3.2.4(@types/node@25.3.5)(jiti@2.6.1)(jsdom@29.0.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
workbox-window:
|
||||
specifier: ^7.4.0
|
||||
version: 7.4.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
@@ -180,6 +196,17 @@ packages:
|
||||
peerDependencies:
|
||||
ajv: '>=8'
|
||||
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/dom-selector@7.0.4':
|
||||
resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -679,6 +706,46 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
||||
'@csstools/color-helpers@6.0.2':
|
||||
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/css-calc@3.1.1':
|
||||
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-color-parser@4.0.2':
|
||||
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@4.0.0':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.1':
|
||||
resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0':
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@drizzle-team/brocli@0.10.2':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
@@ -1002,11 +1069,20 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@exodus/bytes@1.15.0':
|
||||
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@hono/node-server@1.19.11':
|
||||
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
hono: '>=4.12.7'
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
@@ -1858,6 +1934,9 @@ packages:
|
||||
better-sqlite3@11.10.0:
|
||||
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
@@ -2000,11 +2079,19 @@ packages:
|
||||
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
css-tree@3.2.1:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
data-urls@7.0.0:
|
||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2030,6 +2117,9 @@ packages:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2204,6 +2294,10 @@ packages:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2502,10 +2596,14 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hono@4.12.5:
|
||||
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
|
||||
hono@4.12.7:
|
||||
resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
@@ -2615,6 +2713,9 @@ packages:
|
||||
resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
@@ -2745,6 +2846,15 @@ packages:
|
||||
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
|
||||
hasBin: true
|
||||
|
||||
jsdom@29.0.1:
|
||||
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2891,6 +3001,10 @@ packages:
|
||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@11.2.7:
|
||||
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
@@ -2927,6 +3041,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdn-data@2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
@@ -2988,6 +3105,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.7:
|
||||
resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
@@ -3003,6 +3125,10 @@ packages:
|
||||
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
node-diff3@3.2.0:
|
||||
resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==}
|
||||
engines: {bun: '>=1.3.0', node: '>=18'}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
@@ -3079,6 +3205,9 @@ packages:
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
parse5@8.0.0:
|
||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3365,6 +3494,10 @@ packages:
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -3579,6 +3712,9 @@ packages:
|
||||
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tailwindcss@4.2.1:
|
||||
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
|
||||
|
||||
@@ -3641,6 +3777,13 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^3.0.1
|
||||
|
||||
tldts-core@7.0.27:
|
||||
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
|
||||
|
||||
tldts@7.0.27:
|
||||
resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==}
|
||||
hasBin: true
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -3649,9 +3792,17 @@ packages:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@1.0.1:
|
||||
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
|
||||
|
||||
tr46@6.0.0:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -3706,6 +3857,10 @@ packages:
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
undici@7.24.5:
|
||||
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1:
|
||||
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3860,9 +4015,25 @@ packages:
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
webidl-conversions@4.0.2:
|
||||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
||||
|
||||
webidl-conversions@8.0.1:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-mimetype@5.0.0:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-url@16.0.1:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
whatwg-url@7.1.0:
|
||||
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}
|
||||
|
||||
@@ -3962,6 +4133,13 @@ packages:
|
||||
write-file-atomic@3.0.3:
|
||||
resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==}
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
@@ -4009,6 +4187,24 @@ snapshots:
|
||||
jsonpointer: 5.0.1
|
||||
leven: 3.1.0
|
||||
|
||||
'@asamuzakjp/css-color@5.0.1':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
lru-cache: 11.2.7
|
||||
|
||||
'@asamuzakjp/dom-selector@7.0.4':
|
||||
dependencies:
|
||||
'@asamuzakjp/nwsapi': 2.3.9
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.2.1
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.7
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@@ -4665,6 +4861,34 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/color-helpers@6.0.2': {}
|
||||
|
||||
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.0.2
|
||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@drizzle-team/brocli@0.10.2': {}
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
@@ -4833,9 +5057,11 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@hono/node-server@1.19.11(hono@4.12.5)':
|
||||
'@exodus/bytes@1.15.0': {}
|
||||
|
||||
'@hono/node-server@1.19.11(hono@4.12.7)':
|
||||
dependencies:
|
||||
hono: 4.12.5
|
||||
hono: 4.12.7
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
@@ -4884,7 +5110,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.11(hono@4.12.5)
|
||||
'@hono/node-server': 1.19.11(hono@4.12.7)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
content-type: 1.0.5
|
||||
@@ -4894,7 +5120,7 @@ snapshots:
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.3.0(express@5.2.1)
|
||||
hono: 4.12.5
|
||||
hono: 4.12.7
|
||||
jose: 6.2.0
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -5472,7 +5698,7 @@ snapshots:
|
||||
- workbox-build
|
||||
- workbox-window
|
||||
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@25.3.5)(jiti@2.6.1)(jsdom@29.0.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
@@ -5487,7 +5713,7 @@ snapshots:
|
||||
std-env: 3.10.0
|
||||
test-exclude: 7.0.2
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 3.2.4(@types/node@25.3.5)(jiti@2.6.1)(jsdom@29.0.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -5668,6 +5894,10 @@ snapshots:
|
||||
bindings: 1.5.0
|
||||
prebuild-install: 7.1.3
|
||||
|
||||
bidi-js@1.0.3:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
bindings@1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
@@ -5816,8 +6046,20 @@ snapshots:
|
||||
|
||||
crypto-random-string@2.0.0: {}
|
||||
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
data-urls@7.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -5842,6 +6084,8 @@ snapshots:
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
@@ -5921,6 +6165,8 @@ snapshots:
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
es-abstract@1.24.1:
|
||||
dependencies:
|
||||
array-buffer-byte-length: 1.0.2
|
||||
@@ -6336,7 +6582,13 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hono@4.12.5: {}
|
||||
hono@4.12.7: {}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
@@ -6443,6 +6695,8 @@ snapshots:
|
||||
|
||||
is-obj@1.0.1: {}
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-reference@1.2.1:
|
||||
@@ -6587,6 +6841,32 @@ snapshots:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
jsdom@29.0.1:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.0.1
|
||||
'@asamuzakjp/dom-selector': 7.0.4
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1)
|
||||
'@exodus/bytes': 1.15.0
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 6.0.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.2.7
|
||||
parse5: 8.0.0
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.1
|
||||
undici: 7.24.5
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
@@ -6686,6 +6966,8 @@ snapshots:
|
||||
|
||||
lru-cache@11.2.6: {}
|
||||
|
||||
lru-cache@11.2.7: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
@@ -6729,6 +7011,8 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
@@ -6769,6 +7053,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.7: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
@@ -6779,6 +7065,8 @@ snapshots:
|
||||
|
||||
node-addon-api@8.6.0: {}
|
||||
|
||||
node-diff3@3.2.0: {}
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-preload@0.2.1:
|
||||
@@ -6877,6 +7165,10 @@ snapshots:
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
parse5@8.0.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
@@ -7242,6 +7534,10 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
@@ -7517,6 +7813,8 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
zimmerframe: 1.1.4
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwindcss@4.2.1: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
@@ -7587,14 +7885,28 @@ snapshots:
|
||||
markdown-it-task-lists: 2.1.1
|
||||
prosemirror-markdown: 1.13.4
|
||||
|
||||
tldts-core@7.0.27: {}
|
||||
|
||||
tldts@7.0.27:
|
||||
dependencies:
|
||||
tldts-core: 7.0.27
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.27
|
||||
|
||||
tr46@1.0.1:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tr46@6.0.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
@@ -7666,6 +7978,8 @@ snapshots:
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
undici@7.24.5: {}
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
||||
unicode-match-property-ecmascript@2.0.0:
|
||||
@@ -7768,7 +8082,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
vitest@3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
vitest@3.2.4(@types/node@25.3.5)(jiti@2.6.1)(jsdom@29.0.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
@@ -7795,6 +8109,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.5
|
||||
jsdom: 29.0.1
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
@@ -7811,8 +8126,24 @@ snapshots:
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
webidl-conversions@4.0.2: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
whatwg-url@16.0.1:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.0
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
whatwg-url@7.1.0:
|
||||
dependencies:
|
||||
lodash.sortby: 4.7.0
|
||||
@@ -8011,6 +8342,10 @@ snapshots:
|
||||
signal-exit: 3.0.7
|
||||
typedarray-to-buffer: 3.1.5
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Auto-generates an OpenAPI spec from the SvelteKit route tree.
|
||||
*
|
||||
* Scans all +server.ts files under src/routes/api/ for exported HTTP methods and
|
||||
* builds a minimal OpenAPI 3.1 spec at static/.well-known/openapi.json.
|
||||
*
|
||||
* Usage: npx tsx scripts/generate-openapi.ts
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
||||
import { resolve, relative, dirname, join } from 'path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const ROUTES_DIR = resolve(ROOT, 'src/routes/api');
|
||||
const OUTPUT = resolve(ROOT, 'static/api/openapi.json');
|
||||
|
||||
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'));
|
||||
|
||||
function walkDir(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
results.push(...walkDir(full));
|
||||
} else if (entry === '+server.ts') {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
||||
const METHOD_RE = new RegExp(`export\\s+const\\s+(${HTTP_METHODS.join('|')})`, 'g');
|
||||
|
||||
function svelteKitPathToOpenAPI(filePath: string): string {
|
||||
const rel = relative(ROUTES_DIR, dirname(filePath));
|
||||
const segments = rel === '' ? [] : rel.split('/');
|
||||
const apiPath =
|
||||
'/api/' +
|
||||
segments
|
||||
.map((s) => {
|
||||
const match = s.match(/^\[(.+)\]$/);
|
||||
return match ? `{${match[1]}}` : s;
|
||||
})
|
||||
.join('/');
|
||||
return apiPath;
|
||||
}
|
||||
|
||||
function extractMethods(filePath: string): string[] {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const methods: string[] = [];
|
||||
let match;
|
||||
while ((match = METHOD_RE.exec(content)) !== null) {
|
||||
methods.push(match[1].toLowerCase());
|
||||
}
|
||||
METHOD_RE.lastIndex = 0;
|
||||
return methods;
|
||||
}
|
||||
|
||||
function inferTag(path: string): string {
|
||||
const first = path.replace('/api/', '').split('/')[0];
|
||||
return first.charAt(0).toUpperCase() + first.slice(1);
|
||||
}
|
||||
|
||||
function extractPathParams(path: string) {
|
||||
const params: { name: string; in: string; required: boolean; schema: { type: string } }[] = [];
|
||||
const re = /\{(\w+)\}/g;
|
||||
let match;
|
||||
while ((match = re.exec(path)) !== null) {
|
||||
params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } });
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const files = walkDir(ROUTES_DIR);
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const fullPath of files) {
|
||||
const apiPath = svelteKitPathToOpenAPI(fullPath);
|
||||
const methods = extractMethods(fullPath);
|
||||
|
||||
if (methods.length === 0) continue;
|
||||
|
||||
const pathParams = extractPathParams(apiPath);
|
||||
const pathEntry: Record<string, unknown> = {};
|
||||
|
||||
if (pathParams.length > 0) {
|
||||
pathEntry.parameters = pathParams;
|
||||
}
|
||||
|
||||
for (const method of methods) {
|
||||
const operation: Record<string, unknown> = {
|
||||
tags: [inferTag(apiPath)],
|
||||
responses: {
|
||||
'200': { description: 'Success' }
|
||||
}
|
||||
};
|
||||
|
||||
if (['post', 'put', 'patch'].includes(method)) {
|
||||
operation.requestBody = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'object' }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pathEntry[method] = operation;
|
||||
}
|
||||
|
||||
paths[apiPath] = pathEntry;
|
||||
}
|
||||
|
||||
const sortedPaths: Record<string, Record<string, unknown>> = {};
|
||||
for (const key of Object.keys(paths).sort()) {
|
||||
sortedPaths[key] = paths[key];
|
||||
}
|
||||
|
||||
const spec = {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: `${pkg.name.charAt(0).toUpperCase() + pkg.name.slice(1)} API`,
|
||||
description: `Auto-generated API spec for ${pkg.name}`,
|
||||
version: pkg.version,
|
||||
contact: {
|
||||
name: 'Bretzel',
|
||||
url: 'https://bretzel.app'
|
||||
}
|
||||
},
|
||||
servers: [{ url: '/', description: 'Current instance' }],
|
||||
security: [{ sessionCookie: [] }],
|
||||
paths: sortedPaths,
|
||||
components: {
|
||||
securitySchemes: {
|
||||
sessionCookie: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'session'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mkdirSync(dirname(OUTPUT), { recursive: true });
|
||||
writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + '\n');
|
||||
console.log(`Generated ${relative(ROOT, OUTPUT)} with ${Object.keys(paths).length} paths`);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { existsSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { seed, login } from './screenshots/seed';
|
||||
import { captureDesktop, captureMobile } from './screenshots/capture';
|
||||
import {
|
||||
BASE_URL,
|
||||
SCREENSHOT_DB,
|
||||
DESKTOP_VIEWPORT,
|
||||
MOBILE_VIEWPORT,
|
||||
OUTPUT_DIR
|
||||
} from './screenshots/constants';
|
||||
|
||||
function cleanDatabase(): void {
|
||||
for (const ext of ['', '-journal', '-wal', '-shm']) {
|
||||
const path = `${SCREENSHOT_DB}${ext}`;
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
console.log(` deleted ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok || res.status === 302) return;
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Server not reachable at ${url} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
function startServer(): Promise<ChildProcess> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const dir = SCREENSHOT_DB.substring(0, SCREENSHOT_DB.lastIndexOf('/'));
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
|
||||
const server = spawn('pnpm', ['preview', '--port', '4173'], {
|
||||
env: { ...process.env, DATABASE_URL: SCREENSHOT_DB, NODE_ENV: 'production' },
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
server.on('exit', (code) => {
|
||||
if (code !== null && code !== 0) reject(new Error(`Server exited with code ${code}`));
|
||||
});
|
||||
|
||||
// Poll until server responds (stdout detection is unreliable in CI)
|
||||
waitForServer(BASE_URL, 60_000)
|
||||
.then(() => resolve(server))
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('Screenshot automation starting...\n');
|
||||
|
||||
// 1. Clean DB
|
||||
console.log('Cleaning database...');
|
||||
cleanDatabase();
|
||||
|
||||
// 2. Ensure output dir exists
|
||||
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
// 3. Start preview server
|
||||
console.log('Starting preview server...');
|
||||
const server = await startServer();
|
||||
console.log('Server ready.\n');
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
try {
|
||||
// 4. Seed data (in desktop context)
|
||||
console.log('Seeding data...');
|
||||
const desktopContext = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
|
||||
const desktopPage = await desktopContext.newPage();
|
||||
await seed(desktopPage);
|
||||
console.log('Seed complete.\n');
|
||||
|
||||
// 5. Desktop screenshots
|
||||
await captureDesktop(desktopPage);
|
||||
await desktopContext.close();
|
||||
console.log('');
|
||||
|
||||
// 6. Mobile screenshots (fresh context with mobile viewport)
|
||||
const mobileContext = await browser.newContext({ viewport: MOBILE_VIEWPORT });
|
||||
const mobilePage = await mobileContext.newPage();
|
||||
await login(mobilePage);
|
||||
await captureMobile(mobilePage);
|
||||
await mobileContext.close();
|
||||
|
||||
console.log('\nAll screenshots captured successfully!');
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.kill('SIGTERM');
|
||||
// Force exit — pnpm spawns vite as a grandchild process that
|
||||
// server.kill() doesn't reach, leaving the CI job hanging
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Screenshot automation failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
import { join } from 'path';
|
||||
import { BASE_URL, OUTPUT_DIR } from './constants';
|
||||
|
||||
async function screenshot(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: join(OUTPUT_DIR, name), type: 'png' });
|
||||
console.log(` captured ${name}`);
|
||||
}
|
||||
|
||||
async function waitForApp(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
/** Click a note card by its title (targets the h3 inside note-card to avoid content substring matches) */
|
||||
function noteCard(page: Page, title: string): Locator {
|
||||
return page.locator('[data-testid="note-card"]', { has: page.locator('h3', { hasText: title }) });
|
||||
}
|
||||
|
||||
export async function captureDesktop(page: Page): Promise<void> {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
console.log('Capturing desktop screenshots...');
|
||||
|
||||
// D1: Grid view (also used as hero)
|
||||
await page.goto(BASE_URL);
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-grid.png');
|
||||
|
||||
// D2: Rich text editor — open "Self-Hosting Stack"
|
||||
await noteCard(page, 'Self-Hosting Stack').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-editor.png');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D3: Checklist — open "Bretzel Ingredients"
|
||||
await noteCard(page, 'Bretzel Ingredients').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-checklist.png');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D4: Sharing dialog — open "Weekend Plans", click share
|
||||
await noteCard(page, 'Weekend Plans').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await page.waitForSelector('[data-testid="share-dialog"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-sharing.png');
|
||||
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D5: Version history — open "Self-Hosting Stack", click history
|
||||
await noteCard(page, 'Self-Hosting Stack').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await page.getByTestId('history-toggle').click();
|
||||
await page.waitForSelector('[data-testid="history-panel"]');
|
||||
// Wait for version items to load from API
|
||||
await page.waitForSelector('[data-testid="version-item"]', { timeout: 10_000 });
|
||||
const versionItems = page.getByTestId('version-item');
|
||||
if ((await versionItems.count()) > 1) {
|
||||
await versionItems.nth(1).click();
|
||||
}
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-history.png');
|
||||
// Close history panel first (overlay blocks close-editor-btn)
|
||||
await page.getByTestId('close-history-btn').click();
|
||||
await waitForApp(page);
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// D6: API/MCP settings
|
||||
await page.goto(`${BASE_URL}/settings/mcp`);
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-api.png');
|
||||
}
|
||||
|
||||
export async function captureMobile(page: Page): Promise<void> {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
console.log('Capturing mobile screenshots...');
|
||||
|
||||
// M1: Mobile grid view
|
||||
await page.goto(BASE_URL);
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-mobile-grid.png');
|
||||
|
||||
// M2: Mobile editor — open "Self-Hosting Stack"
|
||||
await noteCard(page, 'Self-Hosting Stack').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-mobile-editor.png');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await waitForApp(page);
|
||||
|
||||
// M3: Mobile checklist — open "Bretzel Ingredients"
|
||||
await noteCard(page, 'Bretzel Ingredients').click();
|
||||
await page.waitForSelector('[data-testid="note-editor"]');
|
||||
await waitForApp(page);
|
||||
await screenshot(page, 'screenshot-mobile-checklist.png');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const BASE_URL = 'http://localhost:4173';
|
||||
export const SCREENSHOT_DB = './data/screenshots.db';
|
||||
export const OUTPUT_DIR = 'website/assets';
|
||||
|
||||
export const DESKTOP_VIEWPORT = { width: 1280, height: 800 };
|
||||
export const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
export const ADMIN = {
|
||||
email: 'admin@bretzel.app',
|
||||
displayName: 'Bretzel Admin',
|
||||
password: 'bretzel2026!'
|
||||
};
|
||||
|
||||
export const COLLABORATOR = {
|
||||
email: 'salzig@bretzel.app',
|
||||
displayName: 'Salzig Bretzel',
|
||||
password: 'salzig2026!!'
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
@@ -20,6 +20,25 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -33,7 +52,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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f0e6d3" />
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('crumbs-preferences') || '{}');
|
||||
var t = p.theme || 'system';
|
||||
if (t !== 'light' && t !== 'dark') t = 'system';
|
||||
if (t === 'system') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
if (t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.content = '#1a1715';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
@@ -11,7 +26,6 @@
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Crumbs" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>Crumbs</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
|
||||
import { COLOR_OPTIONS, getNoteColor } from '$lib/utils/colors.js';
|
||||
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
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: {getNoteColor(option.value, getIsDarkMode())}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
></button>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<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 { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, 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 Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
@@ -26,8 +27,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('');
|
||||
@@ -58,25 +58,11 @@
|
||||
};
|
||||
}
|
||||
|
||||
function toggleChecklistItem(item: ChecklistItem) {
|
||||
const lines = note.content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const text = line.replace(/^- \[[ x]\] /, '');
|
||||
if (text === item.text && line.startsWith(item.checked ? '- [x] ' : '- [ ] ')) {
|
||||
lines[i] = item.checked
|
||||
? `- [ ] ${text}`
|
||||
: `- [x] ${text}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
updateNote(note.id, { content: lines.join('\n') });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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)}
|
||||
@@ -115,10 +101,11 @@
|
||||
|
||||
<!-- 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}
|
||||
@@ -144,8 +131,8 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
disabled
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
@@ -8,7 +9,8 @@
|
||||
import NoteHistory from './NoteHistory.svelte';
|
||||
import { updateNote, createNote } 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';
|
||||
@@ -20,6 +22,7 @@
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -56,18 +59,29 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
|
||||
// can produce a synthetic click that lands on editor buttons rendered at the same
|
||||
// coordinates. Suppress pointer events on toolbar controls until mount completes.
|
||||
let toolbarInteractive = $state(false);
|
||||
onMount(() => {
|
||||
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
// 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) =>
|
||||
@@ -79,6 +93,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();
|
||||
@@ -166,8 +193,7 @@
|
||||
|
||||
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,
|
||||
@@ -316,11 +342,16 @@
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2">
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
|
||||
style={toolbarInteractive ? '' : 'pointer-events: none'}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Color picker toggle -->
|
||||
<div class="relative">
|
||||
@@ -386,7 +417,9 @@
|
||||
title="Share note"
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if isShared}
|
||||
{#if hasPublicLink}
|
||||
<Globe class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if isShared}
|
||||
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<UserPlus class="h-5 w-5" />
|
||||
@@ -427,8 +460,10 @@
|
||||
noteId={noteId}
|
||||
collaborators={collaboratorsList}
|
||||
{ownerName}
|
||||
shareToken={currentShareToken}
|
||||
onClose={() => (showShareDialog = false)}
|
||||
onUpdate={handleCollaboratorsUpdate}
|
||||
onShareUpdate={handleShareUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
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;
|
||||
@@ -15,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()) {
|
||||
@@ -38,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));
|
||||
}
|
||||
@@ -123,6 +175,60 @@
|
||||
</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">
|
||||
|
||||
@@ -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 bottom-full z-20 mb-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>
|
||||
@@ -228,8 +228,19 @@ 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 shared_notes WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(userId);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -190,6 +190,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',
|
||||
{
|
||||
|
||||
@@ -137,6 +137,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,13 +1,15 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { fetchSharesForNotes } from './shares-service.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import { createSnapshot } from './versions-service.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface NoteRow {
|
||||
@@ -32,6 +34,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) ?? [];
|
||||
@@ -43,7 +46,8 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) {
|
||||
attachments: attachmentMap.get(note.id) ?? [],
|
||||
collaborators,
|
||||
isOwner,
|
||||
isShared
|
||||
isShared,
|
||||
shareToken: shareMap.get(note.id) ?? undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -205,6 +209,7 @@ export interface UpdateNoteInput {
|
||||
trashed?: boolean;
|
||||
checklistMode?: boolean;
|
||||
sortOrder?: number;
|
||||
baseVersion?: number;
|
||||
}
|
||||
|
||||
/** Per-user fields that go to noteUserState for collaborators */
|
||||
@@ -232,7 +237,33 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
let hasSharedUpdates = false;
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) { sharedUpdates.content = input.content; 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();
|
||||
|
||||
if (baseSnapshot && baseSnapshot.content !== existing.content) {
|
||||
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
|
||||
|
||||
@@ -302,16 +333,13 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
|
||||
if (hasActualContentChange) {
|
||||
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (saved) {
|
||||
createSnapshot(db, id, {
|
||||
version: saved.version,
|
||||
title: saved.title,
|
||||
content: saved.content,
|
||||
checklistMode: saved.checklistMode,
|
||||
color: saved.color
|
||||
});
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -142,11 +142,19 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
|
||||
}
|
||||
|
||||
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
||||
// Read current version for 3-way merge base on the server
|
||||
let baseVersion: number | undefined;
|
||||
notes.update((list) => {
|
||||
const current = list.find((n) => n.id === id);
|
||||
if (current) baseVersion = current.version;
|
||||
return list;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
body: JSON.stringify({ ...updates, baseVersion })
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: Note = await res.json();
|
||||
@@ -173,7 +181,7 @@ export async function updateNote(id: string, updates: NoteUpdate): Promise<Note
|
||||
);
|
||||
if (optimistic) {
|
||||
await putNote(optimistic);
|
||||
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
|
||||
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now(), baseVersion });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
}
|
||||
return optimistic;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -100,10 +100,26 @@ async function pullChanges(): Promise<Note[]> {
|
||||
const localNotes = await getAllNotes();
|
||||
const localMap = new Map(localNotes.map((n) => [n.id, n]));
|
||||
|
||||
// Build pending-fields map from sync queue to protect unpushed local changes
|
||||
let pendingFieldsMap = new Map<string, Set<string>>();
|
||||
try {
|
||||
const queue = await getSyncQueue();
|
||||
for (const item of queue) {
|
||||
if (item.data) {
|
||||
const fields = pendingFieldsMap.get(item.noteId) || new Set<string>();
|
||||
Object.keys(item.data).forEach((k) => fields.add(k));
|
||||
pendingFieldsMap.set(item.noteId, fields);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Queue unavailable — merge without protection
|
||||
}
|
||||
|
||||
for (const remote of remoteNotes) {
|
||||
const local = localMap.get(remote.id);
|
||||
if (local) {
|
||||
const merged = mergeNotes(local, remote);
|
||||
const pendingFields = pendingFieldsMap.get(remote.id);
|
||||
const merged = mergeNotes(local, remote, pendingFields);
|
||||
await putNote(merged);
|
||||
} else {
|
||||
await putNote(remote);
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
/** Fields that can be individually merged between local and remote notes */
|
||||
const MUTABLE_FIELDS = ['title', 'content', 'color', 'pinned', 'archived', 'trashed', 'checklistMode', 'sortOrder'] as const;
|
||||
|
||||
/**
|
||||
* Last-Write-Wins (LWW) merge strategy per field.
|
||||
* For a single-user app with multiple devices, this is sufficient.
|
||||
* Each field is resolved by comparing updatedAt timestamps.
|
||||
* Last-Write-Wins (LWW) merge strategy with pending-field protection.
|
||||
*
|
||||
* When remote is newer, takes remote values for all fields EXCEPT those
|
||||
* listed in `pendingFields` — these represent locally-queued changes that
|
||||
* haven't been pushed yet and must be preserved.
|
||||
*/
|
||||
export function mergeNotes(local: Note, remote: Note): Note {
|
||||
// If remote is newer overall, take remote
|
||||
export function mergeNotes(local: Note, remote: Note, pendingFields?: Set<string>): Note {
|
||||
const localTime = new Date(local.updatedAt).getTime();
|
||||
const remoteTime = new Date(remote.updatedAt).getTime();
|
||||
|
||||
if (remoteTime > localTime) {
|
||||
return { ...remote };
|
||||
}
|
||||
const remoteWins = remoteTime > localTime ||
|
||||
(remoteTime === localTime && remote.version > local.version);
|
||||
|
||||
if (localTime > remoteTime) {
|
||||
if (!remoteWins) {
|
||||
return { ...local };
|
||||
}
|
||||
|
||||
// Same timestamp - prefer higher version number
|
||||
if (remote.version > local.version) {
|
||||
// Remote wins — take remote as base, but preserve pending local fields
|
||||
if (!pendingFields || pendingFields.size === 0) {
|
||||
return { ...remote };
|
||||
}
|
||||
|
||||
return { ...local };
|
||||
const merged = { ...remote };
|
||||
for (const field of MUTABLE_FIELDS) {
|
||||
if (pendingFields.has(field)) {
|
||||
(merged as unknown as Record<string, unknown>)[field] = (local as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface SyncQueueItem {
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
data?: Partial<Note>;
|
||||
timestamp: number;
|
||||
baseVersion?: number;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
|
||||
|
||||
@@ -224,21 +224,69 @@ describe('processSyncPush — shared notes', () => {
|
||||
expect(note.trashed).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject changes with older timestamp than server note', async () => {
|
||||
it('should apply field changes even when timestamp is older (no timestamp gate)', async () => {
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: new Date('2024-01-01T00:00:00Z') });
|
||||
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt.
|
||||
// With field-level sync, the title change should still be applied because
|
||||
// the sync data only contains fields the client explicitly modified.
|
||||
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1,
|
||||
data: { title: 'Stale edit' }
|
||||
data: { title: 'Offline edit' }
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Shared Note'); // Unchanged
|
||||
expect(note.title).toBe('Offline edit');
|
||||
// updatedAt should be max of change timestamp and server timestamp
|
||||
expect(note.updatedAt.getTime()).toBe(t2.getTime());
|
||||
});
|
||||
|
||||
it('should merge checklist content when collaborator pushes concurrent content change', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: t1,
|
||||
createdAt: t1,
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true
|
||||
});
|
||||
|
||||
// Simulate owner editing content on server (checking Bread)
|
||||
const t2 = new Date('2024-01-01T12:00:00Z');
|
||||
db.update(notes)
|
||||
.set({ content: '- [ ] Milk\n- [x] Bread', updatedAt: t2, version: 2 })
|
||||
.where(eq(notes.id, 'n1'))
|
||||
.run();
|
||||
|
||||
// Create a version snapshot for the base content (version 1)
|
||||
const { noteVersions } = await import('../server/db/schema.js');
|
||||
db.insert(noteVersions).values({
|
||||
id: 'snap-1',
|
||||
noteId: 'n1',
|
||||
version: 1,
|
||||
title: 'Shared Note',
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
color: 'default',
|
||||
createdAt: t1
|
||||
}).run();
|
||||
|
||||
// Collaborator was offline since version 1, checked Milk
|
||||
const t3 = new Date('2024-01-01T13:00:00Z').getTime();
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t3,
|
||||
data: { content: '- [x] Milk\n- [ ] Bread' },
|
||||
baseVersion: 1
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
// Both changes should be merged: Milk checked by collab, Bread checked by owner
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql } from 'drizzle-orm';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql, desc, lte } 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 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.
|
||||
*/
|
||||
@@ -31,59 +58,70 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
|
||||
if (!isOwner && !isCollaborator) continue;
|
||||
|
||||
if (change.timestamp > note.updatedAt.getTime()) {
|
||||
// Shared fields: title, content, color, checklistMode
|
||||
const sharedData: Record<string, unknown> = {
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: note.version + 1
|
||||
};
|
||||
if (change.data.title !== undefined) sharedData.title = change.data.title;
|
||||
if (change.data.content !== undefined) sharedData.content = change.data.content;
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
// Apply field-level updates unconditionally (no timestamp gate).
|
||||
// Each sync change only contains the fields the client explicitly modified,
|
||||
// so applying them won't overwrite other users' changes to different fields.
|
||||
const sharedData: Record<string, unknown> = {
|
||||
updatedAt: new Date(Math.max(change.timestamp, note.updatedAt.getTime())),
|
||||
version: note.version + 1
|
||||
};
|
||||
if (change.data.title !== undefined) sharedData.title = change.data.title;
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table
|
||||
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
|
||||
if (change.data.trashed !== undefined) {
|
||||
sharedData.trashed = change.data.trashed;
|
||||
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
|
||||
}
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
// Content: 3-way merge to preserve concurrent edits from other users
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.run();
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table
|
||||
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
|
||||
if (change.data.trashed !== undefined) {
|
||||
sharedData.trashed = change.data.trashed;
|
||||
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
|
||||
}
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUser = change.data.pinned !== undefined ||
|
||||
change.data.archived !== undefined ||
|
||||
change.data.sortOrder !== undefined;
|
||||
if (hasPerUser) {
|
||||
const existing = tx.select().from(noteUserState)
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.run();
|
||||
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUser = change.data.pinned !== undefined ||
|
||||
change.data.archived !== undefined ||
|
||||
change.data.sortOrder !== undefined;
|
||||
if (hasPerUser) {
|
||||
const existing = tx.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) updates.archived = change.data.archived;
|
||||
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
|
||||
tx.update(noteUserState).set(updates)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) updates.archived = change.data.archived;
|
||||
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
|
||||
tx.update(noteUserState).set(updates)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(noteUserState).values({
|
||||
noteId: change.noteId,
|
||||
userId,
|
||||
pinned: change.data.pinned ?? false,
|
||||
archived: change.data.archived ?? false,
|
||||
sortOrder: change.data.sortOrder ?? 0
|
||||
}).run();
|
||||
}
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(noteUserState).values({
|
||||
noteId: change.noteId,
|
||||
userId,
|
||||
pinned: change.data.pinned ?? false,
|
||||
archived: change.data.archived ?? false,
|
||||
sortOrder: change.data.sortOrder ?? 0
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,71 @@ describe('mergeNotes (LWW)', () => {
|
||||
const result = mergeNotes(local, remote);
|
||||
expect(result.title).toBe('Local');
|
||||
});
|
||||
|
||||
it('should preserve pending local fields when remote is newer', () => {
|
||||
const local = makeNote({
|
||||
title: 'Local title',
|
||||
content: 'Local content',
|
||||
color: 'coral',
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
version: 1
|
||||
});
|
||||
const remote = makeNote({
|
||||
title: 'Remote title',
|
||||
content: 'Remote content',
|
||||
color: 'default',
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
version: 2
|
||||
});
|
||||
|
||||
// Content has pending local changes; title and color do not
|
||||
const pendingFields = new Set(['content']);
|
||||
const result = mergeNotes(local, remote, pendingFields);
|
||||
|
||||
// Remote wins overall, but content is preserved from local
|
||||
expect(result.title).toBe('Remote title');
|
||||
expect(result.content).toBe('Local content');
|
||||
expect(result.color).toBe('default'); // remote wins for non-pending field
|
||||
expect(result.version).toBe(2); // metadata from remote
|
||||
});
|
||||
|
||||
it('should take remote values for non-pending fields when remote is newer', () => {
|
||||
const local = makeNote({
|
||||
pinned: true,
|
||||
archived: true,
|
||||
updatedAt: new Date('2024-01-01')
|
||||
});
|
||||
const remote = makeNote({
|
||||
pinned: false,
|
||||
archived: false,
|
||||
updatedAt: new Date('2024-01-02')
|
||||
});
|
||||
|
||||
const pendingFields = new Set(['pinned']);
|
||||
const result = mergeNotes(local, remote, pendingFields);
|
||||
|
||||
expect(result.pinned).toBe(true); // pending — preserved
|
||||
expect(result.archived).toBe(false); // not pending — remote wins
|
||||
});
|
||||
|
||||
it('should ignore pendingFields when local is newer', () => {
|
||||
const local = makeNote({
|
||||
title: 'Local',
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
version: 2
|
||||
});
|
||||
const remote = makeNote({
|
||||
title: 'Remote',
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
version: 1
|
||||
});
|
||||
|
||||
const pendingFields = new Set(['title']);
|
||||
const result = mergeNotes(local, remote, pendingFields);
|
||||
|
||||
// Local wins entirely since it's newer
|
||||
expect(result.title).toBe('Local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Note {
|
||||
collaborators?: Collaborator[];
|
||||
isOwner?: boolean;
|
||||
isShared?: boolean;
|
||||
shareToken?: string;
|
||||
}
|
||||
|
||||
export interface Collaborator {
|
||||
@@ -118,5 +119,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,25 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,26 @@ 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: '#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
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
|
||||
|
||||
describe('mergeContent (3-way)', () => {
|
||||
it('should return remote when local is unchanged', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread';
|
||||
const local = '- [ ] Milk\n- [ ] Bread';
|
||||
const remote = '- [x] Milk\n- [ ] Bread';
|
||||
|
||||
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
|
||||
});
|
||||
|
||||
it('should return local when remote is unchanged', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread';
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread';
|
||||
|
||||
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
|
||||
});
|
||||
|
||||
it('should merge when User A checks an item and User B adds an item', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter'; // User B adds Butter
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toBe('- [x] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter');
|
||||
});
|
||||
|
||||
it('should merge when both users check different items', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
|
||||
const remote = '- [ ] Milk\n- [x] Bread\n- [ ] Eggs'; // User B checks Bread
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toBe('- [x] Milk\n- [x] Bread\n- [ ] Eggs');
|
||||
});
|
||||
|
||||
it('should LWW (prefer remote) when both users edit the same line', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread';
|
||||
const local = '- [ ] Whole Milk\n- [ ] Bread'; // User A renames Milk
|
||||
const remote = '- [x] Milk\n- [ ] Bread'; // User B checks Milk
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
// Both changed line 1 — conflict resolved by preferring remote
|
||||
expect(result).toBe('- [x] Milk\n- [ ] Bread');
|
||||
});
|
||||
|
||||
it('should handle line additions by both sides', () => {
|
||||
const base = '- [ ] Milk';
|
||||
const local = '- [ ] Milk\n- [ ] Bread'; // User A adds Bread at end
|
||||
const remote = '- [ ] Eggs\n- [ ] Milk'; // User B adds Eggs at beginning
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
// Both additions should be preserved
|
||||
expect(result).toContain('- [ ] Milk');
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
expect(result).toContain('- [ ] Eggs');
|
||||
});
|
||||
|
||||
it('should handle line deletion by one side', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
const local = '- [ ] Milk\n- [ ] Eggs'; // User A removes Bread
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'; // User B unchanged
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toBe('- [ ] Milk\n- [ ] Eggs');
|
||||
});
|
||||
|
||||
it('should work with non-checklist markdown (prose paragraphs)', () => {
|
||||
const base = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nNothing special.';
|
||||
const local = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nRemember coupons.'; // User A edits notes
|
||||
const remote = '# Shopping List\n\nBuy groceries today.\n\n## Notes\n\nNothing special.'; // User B edits title
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toContain('# Shopping List');
|
||||
expect(result).toContain('Remember coupons.');
|
||||
});
|
||||
|
||||
it('should handle empty content', () => {
|
||||
expect(mergeContent('', '', '')).toBe('');
|
||||
expect(mergeContent('', 'hello', '')).toBe('hello');
|
||||
expect(mergeContent('', '', 'hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should return remote when local equals remote', () => {
|
||||
const content = '- [ ] Milk\n- [ ] Bread';
|
||||
expect(mergeContent('old', content, content)).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeContentTwoWay', () => {
|
||||
it('should return remote when both are identical', () => {
|
||||
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should attempt merge when contents differ', () => {
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
|
||||
const result = mergeContentTwoWay(local, remote);
|
||||
// Without a base, conflict resolution prefers remote
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { diff3Merge } from 'node-diff3';
|
||||
|
||||
/**
|
||||
* Resolve a conflict region by comparing each line against the base.
|
||||
* If only one side changed a line, take that change.
|
||||
* If both sides changed the same line, prefer remote (LWW).
|
||||
* Handles different-length regions by appending extra lines.
|
||||
*/
|
||||
function resolveConflict(
|
||||
aLines: string[],
|
||||
oLines: string[],
|
||||
bLines: string[]
|
||||
): string[] {
|
||||
const maxLen = Math.max(aLines.length, bLines.length, oLines.length);
|
||||
const result: string[] = [];
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = i < aLines.length ? aLines[i] : undefined;
|
||||
const o = i < oLines.length ? oLines[i] : undefined;
|
||||
const b = i < bLines.length ? bLines[i] : undefined;
|
||||
|
||||
const aChanged = a !== o;
|
||||
const bChanged = b !== o;
|
||||
|
||||
if (aChanged && !bChanged && a !== undefined) {
|
||||
// Only local changed this line — take local
|
||||
result.push(a);
|
||||
} else if (bChanged && !aChanged && b !== undefined) {
|
||||
// Only remote changed this line — take remote
|
||||
result.push(b);
|
||||
} else if (aChanged && bChanged) {
|
||||
// Both changed — LWW: prefer remote
|
||||
if (b !== undefined) result.push(b);
|
||||
// If remote deleted (undefined) but local has it, skip (remote wins)
|
||||
} else {
|
||||
// Neither changed, or both are the same as base
|
||||
if (o !== undefined) result.push(o);
|
||||
else if (b !== undefined) result.push(b);
|
||||
else if (a !== undefined) result.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-way line-level merge for note content.
|
||||
* Takes a base (common ancestor), local (incoming), and remote (server current).
|
||||
* For conflict regions, resolves line-by-line: if only one side changed a line,
|
||||
* take that change. If both changed the same line, prefer remote (LWW).
|
||||
*/
|
||||
export function mergeContent(base: string, local: string, remote: string): string {
|
||||
// If any two are identical, short-circuit
|
||||
if (local === remote) return remote;
|
||||
if (local === base) return remote;
|
||||
if (remote === base) return local;
|
||||
|
||||
const baseLines = base.split('\n');
|
||||
const localLines = local.split('\n');
|
||||
const remoteLines = remote.split('\n');
|
||||
|
||||
const regions = diff3Merge(localLines, baseLines, remoteLines);
|
||||
const merged: string[] = [];
|
||||
|
||||
for (const region of regions) {
|
||||
if (region.ok) {
|
||||
merged.push(...region.ok);
|
||||
} else if (region.conflict) {
|
||||
merged.push(...resolveConflict(
|
||||
region.conflict.a,
|
||||
region.conflict.o,
|
||||
region.conflict.b
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return merged.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function mergeContentTwoWay(local: string, remote: string): string {
|
||||
if (local === remote) return remote;
|
||||
return mergeContent('', local, remote);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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') {
|
||||
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('#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');
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,8 @@
|
||||
href="https://bretzel.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">Bretzel</a
|
||||
> family of apps.
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">bretzel.app</a
|
||||
> universe.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -9,6 +9,22 @@
|
||||
<div class="space-y-8">
|
||||
<h2 class="text-xl font-bold text-[var(--text)]">Preferences</h2>
|
||||
|
||||
<!-- 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 as 'system' | 'light' | 'dark')}
|
||||
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>
|
||||
|
||||
<!-- Default note mode -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Default note mode</span>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Not Found - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 text-center">
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="mt-4 font-['Press_Start_2P'] text-xl text-[var(--primary)]">
|
||||
{page.status}
|
||||
</h1>
|
||||
<p class="mt-4 text-sm text-[var(--text-muted)]">
|
||||
{page.error?.message ?? 'This shared note does not exist or has been removed.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)] text-[var(--text)]">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { getSharedNote } from '$lib/server/shares-service.js';
|
||||
import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, getClientAddress }) => {
|
||||
const ip = getClientAddress();
|
||||
if (!checkIpRateLimit(ip)) {
|
||||
throw error(429, 'Too many requests');
|
||||
}
|
||||
|
||||
const data = getSharedNote(db, params.token);
|
||||
if (!data) {
|
||||
throw error(404, 'This shared note does not exist or has been removed.');
|
||||
}
|
||||
|
||||
return {
|
||||
token: params.token,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
checklistMode: data.checklistMode,
|
||||
color: data.color,
|
||||
attachments: data.attachments,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import type { NoteColor, PublicAttachment } from '$lib/types/index.js';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const noteColor = data.color as NoteColor;
|
||||
const bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
|
||||
|
||||
const renderedContent = renderMarkdown(data.content);
|
||||
|
||||
const checklistItems: ChecklistItem[] = data.checklistMode
|
||||
? data.content
|
||||
.split('\n')
|
||||
.filter((l: string) => l.trim())
|
||||
.map((line: string) => ({
|
||||
text: line.replace(/^- \[[ x]\] /, ''),
|
||||
checked: line.startsWith('- [x] ')
|
||||
}))
|
||||
: [];
|
||||
|
||||
const activeChecklistItems = checklistItems.filter((i) => !i.checked);
|
||||
const doneChecklistItems = checklistItems.filter((i) => i.checked);
|
||||
const sortedChecklistItems = [...activeChecklistItems, ...doneChecklistItems];
|
||||
|
||||
const imageAttachments = (data.attachments as PublicAttachment[]).filter((a) =>
|
||||
a.mimeType.startsWith('image/')
|
||||
);
|
||||
|
||||
function formatDate(date: Date | string) {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title || 'Shared Note'} — Crumbs</title>
|
||||
<meta name="description" content={data.content.slice(0, 160)} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-4 w-full max-w-2xl py-10">
|
||||
<article
|
||||
class="rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
|
||||
style="background-color: {bgColor}"
|
||||
data-testid="shared-note"
|
||||
>
|
||||
<div class="px-6 py-5">
|
||||
{#if data.title}
|
||||
<h1 class="mb-4 text-xl font-semibold text-[var(--text)]" data-testid="shared-note-title">
|
||||
{data.title}
|
||||
</h1>
|
||||
{/if}
|
||||
|
||||
{#if data.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-2" data-testid="shared-note-checklist">
|
||||
{#each sortedChecklistItems as item}
|
||||
<li
|
||||
class="flex items-start gap-2 text-sm {item.checked
|
||||
? 'text-[var(--text-muted)] line-through'
|
||||
: 'text-[var(--text)]'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
disabled
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)]"
|
||||
/>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if data.content}
|
||||
<div
|
||||
class="prose prose-sm max-w-none text-[var(--text)]"
|
||||
data-testid="shared-note-content"
|
||||
>
|
||||
{@html renderedContent}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if imageAttachments.length > 0}
|
||||
<div class="mt-4 grid grid-cols-2 gap-2" data-testid="shared-note-images">
|
||||
{#each imageAttachments as attachment}
|
||||
<img
|
||||
src="/api/shared/{data.token}/attachment/{attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-6 py-3"
|
||||
>
|
||||
<span class="text-xs text-[var(--text-muted)]">
|
||||
{formatDate(data.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="https://crumbs.bretzel.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-['Press_Start_2P'] text-[8px] tracking-wider text-[var(--text-muted)] hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
POWERED BY CRUMBS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,59 @@
|
||||
<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()}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { createShare, revokeShare } from '$lib/server/shares-service.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
const { token } = createShare(db, params.id, userId);
|
||||
return json({ token, url: `${url.origin}/s/${token}` }, { status: 201 });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
revokeShare(db, params.id, userId);
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getSharedNote } from '$lib/server/shares-service.js';
|
||||
import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, getClientAddress }) => {
|
||||
const ip = getClientAddress();
|
||||
if (!checkIpRateLimit(ip)) {
|
||||
throw error(429, 'Too many requests');
|
||||
}
|
||||
|
||||
const data = getSharedNote(db, params.token);
|
||||
if (!data) {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const { noteId: _, ...publicData } = data;
|
||||
return json(publicData);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getSharedNote } from '$lib/server/shares-service.js';
|
||||
import { getAttachment } from '$lib/server/attachments.js';
|
||||
import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, getClientAddress }) => {
|
||||
const ip = getClientAddress();
|
||||
if (!checkIpRateLimit(ip)) {
|
||||
throw error(429, 'Too many requests');
|
||||
}
|
||||
|
||||
const data = getSharedNote(db, params.token);
|
||||
if (!data) {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const attachment = await getAttachment(db, params.attachmentId, data.noteId);
|
||||
if (!attachment) {
|
||||
throw error(404, 'Attachment not found');
|
||||
}
|
||||
|
||||
const wantThumb = url.searchParams.get('thumb') === '1';
|
||||
const filePath = wantThumb && attachment.thumbnailPath ? attachment.thumbnailPath : attachment.path;
|
||||
const mimeType = wantThumb && attachment.thumbnailPath ? 'image/webp' : attachment.mimeType;
|
||||
|
||||
try {
|
||||
const buffer = await readFile(filePath);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
throw error(404, 'Attachment not found');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,796 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Crumbs API",
|
||||
"description": "Auto-generated API spec for crumbs",
|
||||
"version": "0.21.9",
|
||||
"contact": {
|
||||
"name": "Bretzel",
|
||||
"url": "https://bretzel.app"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/",
|
||||
"description": "Current instance"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/admin/users": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/users/{id}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/account": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/oauth/{provider}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/oauth/{provider}/callback": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/password": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/profile": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/sessions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/setup": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/mcp": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Mcp"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Mcp"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Mcp"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/reorder": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/attachments": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/collaborators": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/versions": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/versions/{versionId}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "versionId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/versions/{versionId}/restore": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "versionId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/preferences": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Preferences"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Preferences"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/search": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Search"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/settings/api-keys": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/settings/api-keys/{id}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/sync": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Sync"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Sync"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tags": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/search": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"sessionCookie": {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "session"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 299 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "Crumbs by Bretzel",
|
||||
"short_name": "Crumbs",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#f9f4ea",
|
||||
"background_color": "#f9f4ea",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -5,7 +5,10 @@ const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build'
|
||||
})
|
||||
}),
|
||||
serviceWorker: {
|
||||
register: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test, expect, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe.serial('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');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// When the user selects dark theme
|
||||
// Set up response listener BEFORE clicking (response may arrive before waitForResponse)
|
||||
const prefSaved = page.waitForResponse((r) => r.url().includes('/api/preferences') && r.ok());
|
||||
await page.getByTestId('pref-theme-dark').click();
|
||||
|
||||
// Then the page applies dark theme
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
// Wait for preference to persist to server before reloading
|
||||
await prefSaved;
|
||||
|
||||
// When the page is reloaded
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the dark theme persists (FOUC script applies it before paint)
|
||||
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.waitForLoadState('networkidle');
|
||||
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 navigates to preferences with system theme active
|
||||
await page.goto('/settings/preferences');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByTestId('pref-theme-system').click();
|
||||
|
||||
// Then the page applies dark theme from system preference
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
|
||||
test('Scenario: Note cards use dark colors in dark mode', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a note exists
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await createNote(page, 'Dark Mode Test Note');
|
||||
|
||||
// And 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 viewing the notes page
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then note cards should have dark background colors
|
||||
const noteCard = page.getByTestId('note-card').first();
|
||||
await expect(noteCard).toBeVisible();
|
||||
const bg = await noteCard.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
// Dark default color is #2a2520 = rgb(42, 37, 32); NOT light default rgb(250, 245, 235)
|
||||
expect(bg).not.toBe('rgb(250, 245, 235)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Create a note via API and return it.
|
||||
*/
|
||||
async function createNoteViaApi(page: Page, title: string, content = 'Public share content') {
|
||||
const res = await page.request.post('/api/notes', {
|
||||
data: { title, content }
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a public share link for a note via API and return the token.
|
||||
*/
|
||||
async function createShareViaApi(page: Page, noteId: string): Promise<string> {
|
||||
const res = await page.request.post(`/api/notes/${noteId}/share`);
|
||||
const data = await res.json();
|
||||
return data.token;
|
||||
}
|
||||
|
||||
test.describe('Public Note Sharing', () => {
|
||||
test('Scenario: User creates a public share link', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a note titled "Public Test" exists
|
||||
const note = await createNoteViaApi(page, 'Public Test', 'Hello world');
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// When the owner opens the share dialog and enables the public link
|
||||
await noteCard(page, 'Public Test').click();
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
await page.getByTestId('public-share-toggle').click();
|
||||
|
||||
// Then a share URL is displayed
|
||||
await expect(page.getByTestId('share-url-container')).toBeVisible({ timeout: 5000 });
|
||||
const urlText = await page.getByTestId('share-url-text').textContent();
|
||||
expect(urlText).toContain('/s/');
|
||||
});
|
||||
|
||||
test('Scenario: Public page renders note content without auth', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a publicly shared note exists
|
||||
const note = await createNoteViaApi(page, 'Shared Publicly', 'This is **public** content');
|
||||
const token = await createShareViaApi(page, note.id);
|
||||
|
||||
// When an unauthenticated user visits the share URL
|
||||
const context = await browser.newContext();
|
||||
const publicPage = await context.newPage();
|
||||
await publicPage.goto(`/s/${token}`);
|
||||
|
||||
// Then the note content is rendered
|
||||
await expect(publicPage.getByTestId('shared-note')).toBeVisible();
|
||||
await expect(publicPage.getByTestId('shared-note-title')).toHaveText('Shared Publicly');
|
||||
await expect(publicPage.getByTestId('shared-note-content')).toContainText('public');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Scenario: User revokes share and link returns 404', async ({
|
||||
authenticatedPage: page,
|
||||
browser
|
||||
}) => {
|
||||
// Given a publicly shared note exists
|
||||
const note = await createNoteViaApi(page, 'Revoke Test', 'Will be revoked');
|
||||
const token = await createShareViaApi(page, note.id);
|
||||
|
||||
// And the public page is accessible
|
||||
const context = await browser.newContext();
|
||||
const publicPage = await context.newPage();
|
||||
await publicPage.goto(`/s/${token}`);
|
||||
await expect(publicPage.getByTestId('shared-note')).toBeVisible();
|
||||
|
||||
// When the owner revokes the share
|
||||
const revokeRes = await page.request.delete(`/api/notes/${note.id}/share`);
|
||||
expect(revokeRes.status()).toBe(204);
|
||||
|
||||
// Then the public page returns 404
|
||||
const res = await publicPage.request.get(`/api/shared/${token}`);
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Scenario: Trashed note share link returns 404', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a publicly shared note that is then trashed
|
||||
const note = await createNoteViaApi(page, 'Trash Share Test', 'Will be trashed');
|
||||
const token = await createShareViaApi(page, note.id);
|
||||
|
||||
// When the note is trashed
|
||||
await page.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { trashed: true }
|
||||
});
|
||||
|
||||
// Then the shared API returns 404
|
||||
const res = await page.request.get(`/api/shared/${token}`);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('Scenario: Globe icon appears on shared note card', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a note with a public share link
|
||||
const note = await createNoteViaApi(page, 'Globe Icon Test');
|
||||
await createShareViaApi(page, note.id);
|
||||
|
||||
// When the user views the notes list
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the note card shows a sharing indicator
|
||||
const card = noteCard(page, 'Globe Icon Test');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByTestId('sharing-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Copy link button copies to clipboard', async ({
|
||||
authenticatedPage: page
|
||||
}) => {
|
||||
// Given a note with a public share link
|
||||
const note = await createNoteViaApi(page, 'Copy Link Test');
|
||||
await createShareViaApi(page, note.id);
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// When the owner opens the share dialog
|
||||
await noteCard(page, 'Copy Link Test').click();
|
||||
await page.getByTestId('share-toggle').click();
|
||||
await expect(page.getByTestId('share-dialog')).toBeVisible();
|
||||
|
||||
// Then the share URL is already visible (token was created via API)
|
||||
await expect(page.getByTestId('share-url-container')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// And clicking copy shows the check icon
|
||||
// Grant clipboard permissions for the test
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.getByTestId('copy-share-url-btn').click();
|
||||
|
||||
// The clipboard should contain the share URL
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clipboardText).toContain('/s/');
|
||||
});
|
||||
});
|
||||
@@ -54,8 +54,8 @@ test.describe('Note Sharing', () => {
|
||||
await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } });
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the owner sees a collaborator indicator
|
||||
await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible();
|
||||
// Then the owner sees a sharing indicator
|
||||
await expect(noteCard(page, 'Share Test').getByTestId('sharing-indicator')).toBeVisible();
|
||||
|
||||
// And the collaborator sees the shared note in their list
|
||||
await collabPage.reload();
|
||||
@@ -124,6 +124,37 @@ test.describe('Note Sharing', () => {
|
||||
expect(syncedNote.content).toBe('Owner sync update');
|
||||
});
|
||||
|
||||
test('Scenario: Both users\' edits to different fields are preserved after concurrent PATCH', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared checklist note exists
|
||||
const note = await createSharedNote(
|
||||
page,
|
||||
collabPage,
|
||||
'Shopping List',
|
||||
'- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'
|
||||
);
|
||||
|
||||
// When the owner checks Milk
|
||||
await page.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: '- [x] Milk\n- [ ] Bread\n- [ ] Eggs', baseVersion: note.version }
|
||||
});
|
||||
|
||||
// And the collaborator adds Butter (based on the same original version)
|
||||
await collabPage.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter', baseVersion: note.version }
|
||||
});
|
||||
|
||||
// Then both changes are preserved via content merge
|
||||
const res = await page.request.get(`/api/notes/${note.id}`);
|
||||
const merged = await res.json();
|
||||
expect(merged.content).toContain('- [x] Milk');
|
||||
expect(merged.content).toContain('- [ ] Butter');
|
||||
expect(merged.content).toContain('- [ ] Bread');
|
||||
expect(merged.content).toContain('- [ ] Eggs');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
|
||||
@@ -23,6 +23,8 @@ export default defineConfig({
|
||||
]
|
||||
: []),
|
||||
SvelteKitPWA({
|
||||
scope: '/',
|
||||
buildBase: '/',
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Crumbs by Bretzel',
|
||||
@@ -34,16 +36,26 @@ export default defineConfig({
|
||||
theme_color: '#C8860A',
|
||||
icons: [
|
||||
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
|
||||
navigateFallback: '/',
|
||||
navigateFallback: null,
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
|
||||
urlPattern: ({ request }) => request.mode === 'navigate',
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'pages-cache',
|
||||
networkTimeoutSeconds: 3,
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request, url }) =>
|
||||
request.method === 'GET' &&
|
||||
/\/api\/notes\/.*\/attachments\?attachmentId=/.test(url.href),
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'attachment-cache',
|
||||
@@ -51,12 +63,21 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https?:\/\/.*\/api\//,
|
||||
urlPattern: ({ request, url }) =>
|
||||
request.method === 'GET' && /\/api\//.test(url.pathname),
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https?:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-cache',
|
||||
expiration: { maxEntries: 10, maxAgeSeconds: 365 * 24 * 60 * 60 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import path from 'path';
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: path.resolve(__dirname, 'src/lib')
|
||||
$lib: path.resolve(__dirname, 'src/lib'),
|
||||
'$app/environment': path.resolve(__dirname, 'src/lib/__mocks__/app-environment.ts')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
setupFiles: ['src/lib/__mocks__/svelte-runes.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov', 'json-summary'],
|
||||
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 74 KiB |
@@ -166,6 +166,33 @@
|
||||
<h3>OAuth & SSO</h3>
|
||||
<p>Sign in with Google, GitHub, or any OIDC provider. Or just use a password — your choice.</p>
|
||||
</div>
|
||||
|
||||
<!-- Note Sharing -->
|
||||
<div class="feature-card animate-in delay-4">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</div>
|
||||
<h3>Note Sharing</h3>
|
||||
<p>Share notes via public link or with other users. Full collaboration with per-user pins and archives.</p>
|
||||
</div>
|
||||
|
||||
<!-- Version History -->
|
||||
<div class="feature-card animate-in delay-5">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<h3>Version History</h3>
|
||||
<p>Browse and restore previous versions of any note. Never lose important edits again.</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Notifications -->
|
||||
<div class="feature-card animate-in delay-6">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
</div>
|
||||
<h3>Email Alerts</h3>
|
||||
<p>Optional SMTP notifications for shares, security alerts, and account events. Opt out per user.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -181,27 +208,39 @@
|
||||
<div class="screenshot-label">Notes grid view</div>
|
||||
</div>
|
||||
<div class="screenshot-card animate-in delay-2">
|
||||
<img src="assets/screenshot-editor.png" alt="Rich text editor with formatting toolbar" width="1280" height="800" loading="lazy">
|
||||
<img src="assets/screenshot-editor.png" alt="Rich text editor with formatting toolbar and self-hosting stack note" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Rich text editor</div>
|
||||
</div>
|
||||
<div class="screenshot-card animate-in delay-3">
|
||||
<img src="assets/screenshot-tags.png" alt="Tag filtering showing bretzel-tagged notes" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Tag filtering</div>
|
||||
<img src="assets/screenshot-checklist.png" alt="Checklist note with bretzel ingredients and checkboxes" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Checklist mode</div>
|
||||
</div>
|
||||
<div class="screenshot-card animate-in delay-4">
|
||||
<img src="assets/screenshot-sharing.png" alt="Share dialog with collaborator management" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Note sharing</div>
|
||||
</div>
|
||||
<div class="screenshot-card animate-in delay-5">
|
||||
<img src="assets/screenshot-history.png" alt="Note version history with side-by-side preview" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">Version history</div>
|
||||
</div>
|
||||
<div class="screenshot-card animate-in delay-6">
|
||||
<img src="assets/screenshot-api.png" alt="API keys and MCP client configuration for Claude Code" width="1280" height="800" loading="lazy">
|
||||
<div class="screenshot-label">API & MCP</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="screenshots-subtitle animate-in">Mobile</h3>
|
||||
<div class="screenshots-grid screenshots-grid-mobile">
|
||||
<div class="screenshot-card screenshot-card-mobile animate-in delay-1">
|
||||
<img src="assets/screenshot-mobile-grid.png" alt="Mobile grid view with notes and tags" width="390" height="844" loading="lazy">
|
||||
<img src="assets/screenshot-mobile-grid.png" alt="Mobile notes grid with tags and colorful cards" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Notes view</div>
|
||||
</div>
|
||||
<div class="screenshot-card screenshot-card-mobile animate-in delay-2">
|
||||
<img src="assets/screenshot-mobile-editor.png" alt="Mobile checklist editor" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Checklist editor</div>
|
||||
<img src="assets/screenshot-mobile-editor.png" alt="Mobile rich text editor with self-hosting stack note" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Rich text editor</div>
|
||||
</div>
|
||||
<div class="screenshot-card screenshot-card-mobile animate-in delay-3">
|
||||
<img src="assets/screenshot-mobile-sidebar.png" alt="Mobile sidebar with tags" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Sidebar & tags</div>
|
||||
<img src="assets/screenshot-mobile-checklist.png" alt="Mobile checklist with bretzel ingredients" width="390" height="844" loading="lazy">
|
||||
<div class="screenshot-label">Checklist mode</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +292,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.21.1</span>
|
||||
<span class="footer-version">v0.23.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||