mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
+17
-1
@@ -1,8 +1,24 @@
|
||||
# Database path (relative to project root or absolute)
|
||||
DATABASE_URL=./data/crumbs.db
|
||||
|
||||
# Server origin (required for CSRF protection)
|
||||
# Server origin (required for CSRF protection and OAuth redirect URLs)
|
||||
ORIGIN=http://localhost:3000
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=development
|
||||
|
||||
# --- OAuth Providers (optional, auto-enabled when set) ---
|
||||
|
||||
# Google OAuth 2.0
|
||||
# AUTH_GOOGLE_CLIENT_ID=
|
||||
# AUTH_GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# GitHub OAuth
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Generic OIDC (e.g. Authentik, Keycloak, Okta)
|
||||
# AUTH_OIDC_ISSUER=https://your-idp.example.com
|
||||
# AUTH_OIDC_CLIENT_ID=
|
||||
# AUTH_OIDC_CLIENT_SECRET=
|
||||
# AUTH_OIDC_DISPLAY_NAME=SSO
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Default owner for everything in the repo
|
||||
* @fredicious
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Something isn't working as expected
|
||||
title: "bug: "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
A clear summary of the bug.
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
What should happen.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What happens instead.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Crumbs version**: (e.g. 0.3.2)
|
||||
- **Browser**: (e.g. Chrome 120, Firefox 121)
|
||||
- **OS**: (e.g. macOS 15, Ubuntu 24.04)
|
||||
- **Deployment**: (Docker / Node.js / dev server)
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain the problem.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other relevant information (logs, related issues, etc.).
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea or improvement
|
||||
title: "feat: "
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
A clear description of the feature you'd like to see.
|
||||
|
||||
## Motivation
|
||||
|
||||
Why is this feature important? What problem does it solve?
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
How do you think this should work?
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Have you considered other approaches? If so, why were they ruled out?
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any mockups, links, or references that help illustrate the request.
|
||||
@@ -0,0 +1,18 @@
|
||||
## Summary
|
||||
|
||||
What does this PR do and why?
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] E2E tests added/updated (if user-facing)
|
||||
- [ ] `make check` passes
|
||||
- [ ] `make test` passes
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #
|
||||
|
||||
## Screenshots
|
||||
|
||||
If this changes the UI, include before/after screenshots.
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Bump & Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Override bump type (leave empty to auto-detect)'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run release script
|
||||
env:
|
||||
BUMP_TYPE: ${{ github.event.inputs.bump }}
|
||||
run: bash scripts/release.sh "$BUMP_TYPE"
|
||||
|
||||
- name: Output new tag
|
||||
id: release-tag
|
||||
run: echo "tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
ci:
|
||||
needs: bump
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, ci]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump.outputs.tag }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.bump.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, release]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update docker-compose.yml
|
||||
env:
|
||||
TAG: ${{ needs.bump.outputs.tag }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add docker-compose.yml
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
|
||||
git push origin main
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-check, unit-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -86,16 +85,17 @@ jobs:
|
||||
CI: true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 30
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-check, unit-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -55,10 +55,27 @@ jobs:
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Trigger Dokploy redeploy
|
||||
run: curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update docker-compose.yml
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add docker-compose.yml
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
|
||||
git push origin main
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['website/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: website
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+8
-2
@@ -32,5 +32,11 @@ vite.config.ts.timestamp-*
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# Skills CLI (actual files are in .agents/skills/)
|
||||
/.claude/skills
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
# Local notes
|
||||
todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.2] - 2026-03-07
|
||||
|
||||
### Fixed
|
||||
- Update docker-compose only after Docker image is pushed
|
||||
|
||||
## [0.3.1] - 2026-03-07
|
||||
|
||||
### Fixed
|
||||
- Include tags in sync pull response and update API spec
|
||||
|
||||
### Changed
|
||||
- Update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md documentation
|
||||
|
||||
## [0.3.0] - 2026-03-06
|
||||
|
||||
### Added
|
||||
- Tag management system with auto-extraction from note content
|
||||
- Full-text search across titles, content, and tags
|
||||
- Dark mode with system preference detection
|
||||
- Note pinning, archive, and trash workflows
|
||||
- Image attachments with drag-and-drop upload
|
||||
- 12 color themes for note cards
|
||||
- PWA support with offline-first IndexedDB sync
|
||||
- LWW CRDT-based conflict resolution
|
||||
- Docker deployment with multi-stage build
|
||||
- CI/CD pipeline with GitHub Actions
|
||||
- Comprehensive E2E test suite with Playwright
|
||||
- OpenAPI spec and auto-generated API docs
|
||||
|
||||
### Changed
|
||||
- Initial public release
|
||||
|
||||
[0.3.2]: https://github.com/bretzel-app/crumbs/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/bretzel-app/crumbs/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/bretzel-app/crumbs/releases/tag/v0.3.0
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project overview
|
||||
|
||||
Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first PWA with CRDT-based sync.
|
||||
Crumbs by Bretzel — self-hostable note-taking app. Multi-user with password auth + OAuth/SSO, offline-first PWA with CRDT-based sync.
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -10,25 +10,83 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
|
||||
- **Adapter**: `@sveltejs/adapter-node` (builds to `build/`, runs via `node build`)
|
||||
- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`)
|
||||
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, schema at `src/lib/server/db/schema.ts`)
|
||||
- **Auth**: Single-user password auth with Argon2 hashing, session cookies (30-day expiry)
|
||||
- **Auth**: Multi-user with password auth (Argon2) + OAuth (Google, GitHub, OIDC), role-based (admin/user), session cookies (30-day expiry)
|
||||
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
|
||||
- **Package manager**: pnpm
|
||||
|
||||
## Design system: Retro Parchment
|
||||
|
||||
The UI follows a **parchment + 8-bit retro** aesthetic. All design decisions must preserve this character.
|
||||
|
||||
### Color palette
|
||||
|
||||
All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colors (e.g. `text-gray-500`, `bg-red-600`) — always reference CSS variables:
|
||||
|
||||
| Variable | Value | Usage |
|
||||
|---|---|---|
|
||||
| `--bg-base` | `#f0e6d3` | Page background (warm parchment) |
|
||||
| `--bg-surface` | `#faf5eb` | Cards, panels, inputs |
|
||||
| `--primary` | `#C8860A` | Brand gold — links, active states, accents |
|
||||
| `--primary-hover` | `#E8A020` | Gold hover state |
|
||||
| `--text` | `#1a1a2e` | Primary text (near-black) |
|
||||
| `--text-muted` | `#6b6272` | Secondary text, placeholders |
|
||||
| `--border` | `#1a1a2e` | Strong borders (editor, login card) |
|
||||
| `--border-subtle` | `#d4cabb` | Default card borders, dividers |
|
||||
| `--destructive` | `#a63d2f` | Delete actions, error icons |
|
||||
| `--error-bg/border/text` | warm reds | Error states (themed, not clinical red) |
|
||||
| `--success-bg/text` | warm greens | Success states |
|
||||
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
|
||||
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
|
||||
|
||||
Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
|
||||
### Typography
|
||||
|
||||
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
|
||||
- **Brand / empty states**: `Press Start 2P` (pixel font) — the 8-bit accent
|
||||
- These two fonts are intentional. Do not replace them or add others.
|
||||
|
||||
### Visual rules
|
||||
|
||||
- **Shadows**: Always hard-offset (`2px 2px 0px`) with zero blur. Never use `shadow-sm`, `shadow-md` etc. — they look too modern.
|
||||
- **Corners**: `rounded-sm` everywhere. Sharp, not rounded. Exception: color picker circles use `rounded-full`.
|
||||
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
|
||||
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
|
||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
||||
- **Hover actions**: Never use `hidden group-hover:block` for action buttons — they're invisible on mobile (no hover). Use the opacity pattern instead: `max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100`. This keeps actions always visible on touch devices and hover-revealed on desktop.
|
||||
|
||||
### Layout groups
|
||||
|
||||
- `(auth)` — Login/setup pages: minimal centered layout, no app chrome
|
||||
- `(app)` — Main app: header, sidebar, footer, toast notifications
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
routes/ # SvelteKit pages and API routes
|
||||
+page.svelte # Main notes view
|
||||
login/ # Login page
|
||||
setup/ # First-time password setup
|
||||
api/auth/ # Login, logout, setup endpoints
|
||||
routes/
|
||||
+layout.svelte # Root layout (imports CSS only)
|
||||
(auth)/ # Layout group: login/setup (minimal, no app chrome)
|
||||
+layout.svelte # Centered parchment background
|
||||
login/ # Login page
|
||||
setup/ # First-time password setup
|
||||
(app)/ # Layout group: main app (header, sidebar, footer)
|
||||
+layout.svelte # App shell with Header, Sidebar, Toast
|
||||
+page.svelte # Main notes view
|
||||
archive/ # Archived notes view
|
||||
trash/ # Trashed notes view
|
||||
tag/[name]/ # Notes filtered by tag
|
||||
settings/ # Settings with tabbed layout (profile, MCP, users)
|
||||
api/auth/ # Login, logout, setup, OAuth endpoints
|
||||
api/admin/users/ # Admin user management (CRUD, role, sessions)
|
||||
api/notes/ # CRUD + attachments
|
||||
api/search/ # Full-text search
|
||||
api/sync/ # Offline sync
|
||||
api/tags/ # Tag management
|
||||
lib/
|
||||
components/ # Svelte components (NoteCard, NoteEditor, ColorPicker, etc.)
|
||||
components/ # Svelte components (NoteCard, NoteEditor, TagChip, etc.)
|
||||
stores/ # Svelte stores (notes.ts, theme.ts)
|
||||
server/db/ # Drizzle schema + connection (schema.ts, index.ts)
|
||||
server/ # Auth logic, attachment handling
|
||||
@@ -67,6 +125,18 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
| `make docker-down` | — | Stop containers |
|
||||
| `make docker-logs` | — | Tail container logs |
|
||||
| `make clean` | — | Remove build artifacts + test DBs |
|
||||
| `make release` | — | Auto-bump version from commits, tag, and push |
|
||||
| `make release-patch` | — | Bump patch version, tag, and push |
|
||||
| `make release-minor` | — | Bump minor version, tag, and push |
|
||||
| `make release-major` | — | Bump major version, tag, and push |
|
||||
|
||||
## Git conventions
|
||||
|
||||
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
||||
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
||||
- **Main branch**: `main` — use as base for all PRs and feature branches
|
||||
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
||||
- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -79,10 +149,14 @@ GitHub Actions with two workflows:
|
||||
4. **E2E Tests** — Playwright with Chromium (depends on step 3, uploads report on failure)
|
||||
5. **Docker Build** — validates the Docker image builds (depends on step 3)
|
||||
|
||||
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags:
|
||||
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io` (GitHub Container Registry)
|
||||
**Bump & Release** (`.github/workflows/bump.yml`) — workflow_dispatch (primary release method):
|
||||
- Runs `scripts/release.sh` to bump version in `package.json` + `docker-compose.yml`, tag, and push
|
||||
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io`
|
||||
- Creates a GitHub Release with auto-generated notes
|
||||
|
||||
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags (fallback for manual/local tag pushes):
|
||||
- Same as above: CI pipeline, Docker build/push, GitHub Release
|
||||
|
||||
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
|
||||
|
||||
## Testing philosophy: BDD/TDD
|
||||
@@ -132,3 +206,14 @@ E2E tests follow Cucumber BDD conventions via comments. Follow the [Better Gherk
|
||||
```
|
||||
|
||||
**Resilience test** — ask: *"Will this comment need to change if the UI implementation changes?"* If yes, rewrite it to remove implementation details. The Playwright code underneath handles the *how*; the comments describe the *what*.
|
||||
|
||||
## Documentation
|
||||
|
||||
Keep docs in `docs/` up to date when making changes. If a feature, API endpoint, deployment config, or architectural decision changes, update the relevant doc file in the same commit or PR:
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| `docs/FEATURES.md` | User-facing feature list |
|
||||
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
|
||||
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
|
||||
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Commitment
|
||||
|
||||
Crumbs is a community project, and we want everyone to feel welcome regardless of background, identity, or experience level. We are committed to providing a harassment-free experience for all participants.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
- Be respectful and considerate in communication
|
||||
- Give and accept constructive feedback gracefully
|
||||
- Focus on what is best for the project and community
|
||||
- Show empathy towards others
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
- Harassment, intimidation, or discrimination of any kind
|
||||
- Personal attacks, insults, or derogatory language
|
||||
- Publishing others' private information without consent
|
||||
- Any conduct that would be considered inappropriate in a professional setting
|
||||
|
||||
## Scope
|
||||
|
||||
This code of conduct applies to all project spaces — issues, pull requests, discussions, and any other communication channel associated with Crumbs.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behavior may be reported to the maintainers at **security@bretzel.app**. All reports will be reviewed and handled confidentially. Maintainers reserve the right to remove, edit, or reject contributions that violate this code of conduct, and to temporarily or permanently ban contributors for repeated or severe violations.
|
||||
|
||||
## Attribution
|
||||
|
||||
This code of conduct is inspired by the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Contributing to Crumbs
|
||||
|
||||
Thanks for your interest in contributing! This guide will help you get started.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/bretzel-app/crumbs.git
|
||||
cd crumbs
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the dev server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 and set your password on first visit.
|
||||
|
||||
Run `make help` to see all available commands.
|
||||
|
||||
## Project Structure
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system design. Key directories:
|
||||
|
||||
- `src/routes/` — SvelteKit pages and API endpoints
|
||||
- `src/lib/components/` — Svelte 5 components (runes, not stores)
|
||||
- `src/lib/server/db/` — Drizzle ORM schema and database connection
|
||||
- `src/lib/sync/` — Offline sync and CRDT logic
|
||||
- `tests/unit/` — Vitest unit tests
|
||||
- `tests/e2e/` — Playwright end-to-end tests
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. **Fork and branch** — Create a feature branch from `main`
|
||||
2. **Follow existing patterns** — Match the code style you see in the codebase
|
||||
3. **Write tests** — Add unit tests for logic, E2E tests for user-facing features
|
||||
4. **Run checks before pushing:**
|
||||
```bash
|
||||
make check # Type checking
|
||||
make lint # Linting
|
||||
make test-unit # Unit tests
|
||||
make test-e2e # E2E tests
|
||||
```
|
||||
5. **Open a PR** — Target `main`, describe what and why
|
||||
|
||||
## Commit Messages
|
||||
|
||||
We use conventional-style commit messages:
|
||||
|
||||
```
|
||||
feat: add tag autocomplete
|
||||
fix: prevent duplicate notes on sync
|
||||
docs: update deployment guide
|
||||
chore: bump dependencies
|
||||
```
|
||||
|
||||
Keep the subject line under 70 characters. Use the body for context when needed.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Svelte 5 runes** — Use `$state`, `$derived`, `$props`, `$effect` (not Svelte 4 stores)
|
||||
- **TypeScript** — Strict mode, no `any` unless absolutely necessary
|
||||
- **Tailwind CSS v4** — Use CSS variables from `src/app.css`, never hardcoded Tailwind colors
|
||||
- **Design system** — Follow the Retro Parchment aesthetic described in `CLAUDE.md`
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests**: Pure logic (parsing, auth, sync). Run with `make test-unit`
|
||||
- **E2E tests**: User workflows in the browser. Run with `make test-e2e`
|
||||
- E2E tests use Gherkin-style Given/When/Then comments (see `CLAUDE.md` for conventions)
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Use [GitHub Issues](https://github.com/bretzel-app/crumbs/issues) with the bug report template. Include:
|
||||
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Browser/OS and Crumbs version
|
||||
|
||||
## Security Issues
|
||||
|
||||
**Do not open public issues for security vulnerabilities.** See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present Bretzel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,7 @@
|
||||
.PHONY: help dev build preview check test test-unit test-e2e lint \
|
||||
db-push db-generate db-migrate db-studio docs-api \
|
||||
install docker-build docker-up docker-down docker-logs clean
|
||||
install docker-build docker-up docker-down docker-logs clean \
|
||||
release release-patch release-minor release-major
|
||||
|
||||
help: ## List all targets
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -62,3 +63,15 @@ docker-logs: ## Tail container logs
|
||||
clean: ## Remove build artifacts and test DBs
|
||||
rm -rf build .svelte-kit node_modules/.vite
|
||||
rm -f data/test-crumbs.db
|
||||
|
||||
release: ## Auto-bump version from commits, tag, and push
|
||||
bash scripts/release.sh $(BUMP)
|
||||
|
||||
release-patch: ## Bump patch version, tag, and push
|
||||
bash scripts/release.sh patch
|
||||
|
||||
release-minor: ## Bump minor version, tag, and push
|
||||
bash scripts/release.sh minor
|
||||
|
||||
release-major: ## Bump major version, tag, and push
|
||||
bash scripts/release.sh major
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# 🥨 Crumbs by Bretzel
|
||||
[](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://github.com/bretzel-app/crumbs/releases)
|
||||
[](https://github.com/bretzel-app/crumbs/pkgs/container/crumbs)
|
||||
|
||||
<p align="center">
|
||||
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
|
||||
</p>
|
||||
|
||||
# Crumbs by Bretzel
|
||||
|
||||
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
|
||||
## Quick Start
|
||||
## Install
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
@@ -10,7 +19,37 @@ A self-hostable, offline-first note-taking app inspired by Google Keep. Part of
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open http://localhost:3000 and set your password on first visit.
|
||||
Open http://localhost:3000 and create your admin account on first visit.
|
||||
|
||||
#### With OAuth (optional)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- crumbs-data:/data
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
- AUTH_GOOGLE_ID=your-google-client-id
|
||||
- AUTH_GOOGLE_SECRET=your-google-client-secret
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
crumbs-data:
|
||||
```
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
```bash
|
||||
git clone <repo-url> crumbs && cd crumbs
|
||||
pnpm install
|
||||
pnpm build
|
||||
DATABASE_URL=./data/crumbs.db ORIGIN=http://localhost:3000 node build
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
@@ -32,9 +71,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
|
||||
- Dark mode with system detection
|
||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||
- Single-user password auth (Argon2)
|
||||
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
|
||||
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
|
||||
- Docker deployment with a single command
|
||||
|
||||
See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
@@ -62,6 +101,9 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
|
||||
| [API](docs/API.md) | REST API reference (auto-generated) |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute |
|
||||
| [Security](SECURITY.md) | Vulnerability reporting policy |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -72,4 +114,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](LICENSE)
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| latest | Yes |
|
||||
| < latest | No |
|
||||
|
||||
We recommend always running the latest version of Crumbs.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via [GitHub's private vulnerability reporting](https://github.com/bretzel-app/crumbs/security/advisories/new).
|
||||
|
||||
Include as much of the following as possible:
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Impact assessment
|
||||
- Suggested fix (if any)
|
||||
|
||||
You should receive an initial response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
|
||||
|
||||
## Scope
|
||||
|
||||
Since Crumbs is a self-hosted application, the following are in scope:
|
||||
|
||||
- Authentication bypass or session hijacking
|
||||
- SQL injection or data leaks
|
||||
- Cross-site scripting (XSS)
|
||||
- Server-side request forgery (SSRF)
|
||||
- Path traversal or file access vulnerabilities
|
||||
- Denial of service via crafted input
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow coordinated disclosure. Once a fix is available, we will:
|
||||
|
||||
1. Release a patched version
|
||||
2. Publish a security advisory on GitHub
|
||||
3. Credit the reporter (unless anonymity is requested)
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:0.9.0
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DATA_DIR=/data
|
||||
- DATABASE_URL=/data/crumbs.db
|
||||
- ORIGIN=http://localhost:3000
|
||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
+182
@@ -103,6 +103,7 @@ List notes
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -110,6 +111,17 @@ List notes
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -125,6 +137,7 @@ Create a note
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -147,6 +160,7 @@ Create a note
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -154,6 +168,17 @@ Create a note
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -181,6 +206,7 @@ Get a note
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -188,6 +214,17 @@ Get a note
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -212,6 +249,7 @@ Partial update — only include changed fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -234,6 +272,7 @@ Partial update — only include changed fields.
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -241,6 +280,17 @@ Partial update — only include changed fields.
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -285,9 +335,24 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
| `attachmentId` | query | string | no | |
|
||||
| `thumb` | query | string | no | Set to "1" to return the thumbnail instead of the full image (requires attachmentId) (1) |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/notes/{id}/attachments`
|
||||
@@ -311,6 +376,7 @@ Upload an image attachment
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
@@ -366,6 +432,7 @@ Searches across note titles, content, and tags. Excludes trashed notes.
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -373,6 +440,17 @@ Searches across note titles, content, and tags. Excludes trashed notes.
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -423,6 +501,7 @@ Pull changes since timestamp
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -430,6 +509,17 @@ Pull changes since timestamp
|
||||
"version": 1,
|
||||
"tags": [
|
||||
"item"
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"thumbnailPath": "thumbnailPath",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -451,6 +541,7 @@ Push local changes
|
||||
"operation": "create",
|
||||
"timestamp": 1,
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -473,4 +564,95 @@ Push local changes
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `400` Invalid changes payload
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### `GET /api/settings/api-keys`
|
||||
|
||||
List API keys
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "name",
|
||||
"keyPrefix": "keyPrefix",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"lastUsedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/settings/api-keys`
|
||||
|
||||
Create API key
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "name"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `201`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "id",
|
||||
"key": "key",
|
||||
"name": "name",
|
||||
"keyPrefix": "keyPrefix"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `DELETE /api/settings/api-keys/{id}`
|
||||
|
||||
Revoke API key
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|------|----|------|----------|-------------|
|
||||
| `id` | path | string | yes | |
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `404` Key not found
|
||||
|
||||
---
|
||||
|
||||
## MCP
|
||||
|
||||
### `POST /api/mcp`
|
||||
|
||||
MCP Streamable HTTP endpoint
|
||||
|
||||
Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
**Errors:** `400` Invalid session, `401` Invalid or missing API key
|
||||
|
||||
---
|
||||
|
||||
+36
-4
@@ -49,6 +49,14 @@ For a single-user app with multiple devices:
|
||||
- If timestamps match: higher version wins
|
||||
- If both match: local version is preferred
|
||||
|
||||
### Attachment Data Flow
|
||||
1. User drops/picks image in NoteEditor
|
||||
2. Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
|
||||
3. **Online**: POST optimized + thumbnail to `/api/notes/{id}/attachments` → server saves to `data/attachments/` + DB
|
||||
4. **Offline**: store blobs in IDB `pendingAttachments` store → display via `URL.createObjectURL()` → on reconnect, sync pushes to server
|
||||
5. **Display**: NoteCard uses `&thumb=1` URL (200px WebP), editor shows full-size
|
||||
6. **SW Caching**: `CacheFirst` rule caches attachment URLs — previously-viewed images available offline
|
||||
|
||||
## Tech Stack Rationale
|
||||
|
||||
### SvelteKit
|
||||
@@ -84,23 +92,47 @@ For a single-user app with multiple devices:
|
||||
See `src/lib/server/db/schema.ts` for the complete Drizzle schema.
|
||||
|
||||
Key tables:
|
||||
- `users` - Single user (password hash)
|
||||
- `users` - User accounts (password hash, OAuth provider link, role)
|
||||
- `sessions` - Auth sessions (30-day TTL)
|
||||
- `notes` - Core note data with version tracking
|
||||
- `tags` - Unique tag names
|
||||
- `note_tags` - Many-to-many note ↔ tag
|
||||
- `attachments` - Image file metadata
|
||||
- `attachments` - Image file metadata (path + thumbnailPath)
|
||||
- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display)
|
||||
- `sync_log` - Sync operation history
|
||||
|
||||
## MCP Server
|
||||
|
||||
Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.
|
||||
|
||||
### Architecture
|
||||
- **Endpoint**: `POST /api/mcp` (Streamable HTTP transport)
|
||||
- **Auth**: Bearer token via API keys (generated in Settings)
|
||||
- **Sessions**: Stateful per MCP spec — session ID in `mcp-session-id` header
|
||||
- **Transport**: `WebStandardStreamableHTTPServerTransport` (works with SvelteKit's Web Standard Request/Response)
|
||||
|
||||
### Shared Logic
|
||||
MCP tool handlers and REST API routes both call the same service layer (`src/lib/server/notes-service.ts`). This avoids duplicating business logic:
|
||||
|
||||
```
|
||||
REST API routes ──┐
|
||||
├──► notes-service.ts ──► Drizzle DB
|
||||
MCP tool handlers ┘
|
||||
```
|
||||
|
||||
### Tools (14)
|
||||
`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image`
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/lib/server/ # Server-only code (DB, auth, attachments)
|
||||
src/lib/server/ # Server-only code (DB, auth, attachments, notes-service)
|
||||
src/lib/server/mcp/ # MCP server + tool definitions
|
||||
src/lib/sync/ # Sync engine (shared types, server + client implementations)
|
||||
src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search)
|
||||
src/lib/components/ # Svelte UI components
|
||||
src/lib/utils/ # Pure utility functions (markdown, tags, colors)
|
||||
src/lib/types/ # TypeScript type definitions
|
||||
src/routes/api/ # REST API endpoints
|
||||
src/routes/api/ # REST API + MCP endpoints
|
||||
src/routes/ # Page routes (SvelteKit file-based routing)
|
||||
```
|
||||
|
||||
+89
-1
@@ -16,7 +16,7 @@ Open http://localhost:3000 and set your password.
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "8080:3000" # Change external port
|
||||
volumes:
|
||||
@@ -100,6 +100,8 @@ node build
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `./data/crumbs.db` | SQLite database path |
|
||||
@@ -108,6 +110,49 @@ node build
|
||||
| `PORT` | `3000` | HTTP port |
|
||||
| `NODE_ENV` | `development` | Set to `production` for deployment |
|
||||
|
||||
### OAuth Providers (optional)
|
||||
|
||||
Enable social login by setting the client ID and secret for any provider. Users who sign in via OAuth are created automatically (invite-only when combined with admin approval).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTH_GOOGLE_ID` | Google OAuth client ID |
|
||||
| `AUTH_GOOGLE_SECRET` | Google OAuth client secret |
|
||||
| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
|
||||
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
|
||||
| `AUTH_OIDC_ISSUER` | Generic OIDC issuer URL (e.g. `https://accounts.example.com`) |
|
||||
| `AUTH_OIDC_ID` | OIDC client ID |
|
||||
| `AUTH_OIDC_SECRET` | OIDC client secret |
|
||||
| `AUTH_OIDC_NAME` | Display name for the OIDC button (default: "SSO") |
|
||||
|
||||
**Callback URLs** — register these in your provider's dashboard:
|
||||
|
||||
| Provider | Callback URL |
|
||||
|----------|-------------|
|
||||
| Google | `https://your-domain/api/auth/callback/google` |
|
||||
| GitHub | `https://your-domain/api/auth/callback/github` |
|
||||
| OIDC | `https://your-domain/api/auth/callback/oidc` |
|
||||
|
||||
#### Docker example with Google OAuth
|
||||
|
||||
```yaml
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- crumbs-data:/data
|
||||
environment:
|
||||
- ORIGIN=https://notes.example.com
|
||||
- AUTH_GOOGLE_ID=123456789.apps.googleusercontent.com
|
||||
- AUTH_GOOGLE_SECRET=GOCSPX-xxxxxxxxxxxxx
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
crumbs-data:
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The Docker image includes a health check that pings `/login`:
|
||||
@@ -116,6 +161,48 @@ The Docker image includes a health check that pings `/login`:
|
||||
docker inspect --format='{{.State.Health.Status}}' crumbs
|
||||
```
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Open **Settings** in the Crumbs UI
|
||||
2. Create an API key (give it a name like "Claude Code")
|
||||
3. Copy the key (shown only once)
|
||||
4. Configure your MCP client:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"crumbs": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://your-crumbs-instance/api/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_notes` | List notes (filter by status/tag) |
|
||||
| `get_note` | Get a note by ID |
|
||||
| `create_note` | Create a new note |
|
||||
| `update_note` | Update a note |
|
||||
| `trash_note` / `restore_note` | Move to/from trash |
|
||||
| `archive_note` / `unarchive_note` | Archive/unarchive |
|
||||
| `delete_note` | Permanently delete |
|
||||
| `search_notes` | Full-text search |
|
||||
| `list_tags` | List all tags |
|
||||
| `pin_note` | Pin/unpin a note |
|
||||
| `reorder_notes` | Set sort orders |
|
||||
| `upload_image` | Attach image from URL |
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Always use HTTPS in production (via reverse proxy)
|
||||
@@ -123,3 +210,4 @@ docker inspect --format='{{.State.Health.Status}}' crumbs
|
||||
- The `ORIGIN` variable must match your actual domain for CSRF protection
|
||||
- Sessions expire after 30 days
|
||||
- Passwords are hashed with Argon2
|
||||
- API keys are SHA-256 hashed (never stored in plain text)
|
||||
|
||||
+58
-19
@@ -8,16 +8,27 @@
|
||||
- Content supports full Markdown syntax
|
||||
- Auto-save on editor close
|
||||
|
||||
### Markdown Support
|
||||
- **Bold** (`**text**`)
|
||||
- *Italic* (`*text*`)
|
||||
### Rich Text Editor
|
||||
- WYSIWYG editing powered by Tiptap
|
||||
- Formatting toolbar with visual controls
|
||||
- Raw Markdown mode toggle for direct editing
|
||||
- Undo/Redo with keyboard shortcuts
|
||||
|
||||
### Text Formatting
|
||||
- **Bold** (`**text**` / Ctrl+B)
|
||||
- *Italic* (`*text*` / Ctrl+I)
|
||||
- Underline (Ctrl+U)
|
||||
- ~~Strikethrough~~ (`~~text~~` / Ctrl+Shift+X)
|
||||
- Headings (`# H1` through `###### H6`)
|
||||
- Blockquotes (`> quote`)
|
||||
- Unordered lists (`- item`)
|
||||
- Ordered lists (`1. item`)
|
||||
- Code blocks (triple backtick)
|
||||
- Inline code (single backtick)
|
||||
- Tables
|
||||
- Links (auto-linkified URLs)
|
||||
- Links — insert, edit, and remove with URL input
|
||||
- Dividers (horizontal rules)
|
||||
- Text alignment (left, center, right, justify)
|
||||
- Line breaks
|
||||
|
||||
### Checklists / Task Lists
|
||||
@@ -31,7 +42,13 @@
|
||||
- Upload images via file picker or drag-and-drop
|
||||
- Supports JPEG, PNG, GIF, WebP, SVG
|
||||
- Max file size: 10MB
|
||||
- Thumbnail previews in note cards
|
||||
- **Client-side optimization**: images resized to fit 1920×1920, compressed to WebP (~80% quality), transparent PNGs preserved
|
||||
- **Auto-generated thumbnails**: 200×200 WebP at 60% quality for fast card previews
|
||||
- Thumbnail strip on note cards (up to 3 images, "+N" badge for more)
|
||||
- Full-size images in editor, thumbnails on cards (`&thumb=1`)
|
||||
- **Offline support**: optimized blobs stored in IndexedDB, displayed via blob URLs, auto-synced on reconnect
|
||||
- Pending upload indicator on offline-queued images
|
||||
- Service worker CacheFirst caching for previously-viewed images
|
||||
- Remove attachments from editor
|
||||
|
||||
### Tag Organization
|
||||
@@ -45,7 +62,7 @@
|
||||
12 colors matching Google Keep's palette:
|
||||
- Default, Coral, Peach, Sand, Mint, Sage
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to light/dark mode
|
||||
- Colors adapt to note theme
|
||||
|
||||
### Pin Notes
|
||||
- Pin important notes to always appear at top
|
||||
@@ -69,29 +86,51 @@
|
||||
- Clear search to restore original view
|
||||
- Case-insensitive matching
|
||||
|
||||
### Dark Mode
|
||||
- System preference detection (auto)
|
||||
- Manual toggle override
|
||||
- Preference persisted in localStorage
|
||||
- All components adapt to theme
|
||||
- Note colors adjust for dark backgrounds
|
||||
|
||||
### Single-User Auth
|
||||
- First-run setup: create password (min 8 chars)
|
||||
### Authentication
|
||||
- Multi-user support with role-based access (admin / user)
|
||||
- First-run setup: create admin account (min 8 chars password)
|
||||
- Argon2 password hashing (industry standard)
|
||||
- Session-based auth with 30-day expiry
|
||||
- Active session management (view, revoke individual or all other sessions)
|
||||
- All routes protected except /login and /setup
|
||||
- Logout clears session
|
||||
- Account deletion with password confirmation
|
||||
|
||||
### OAuth / SSO
|
||||
- Optional social login via Google, GitHub, or generic OIDC
|
||||
- OAuth users created automatically on first sign-in
|
||||
- Invite-only mode when combined with admin controls
|
||||
- Configure via environment variables (`AUTH_GOOGLE_ID`, `AUTH_GITHUB_ID`, `AUTH_OIDC_ISSUER`, etc.)
|
||||
|
||||
### User Management (admin)
|
||||
- Admin dashboard to create, list, and delete users
|
||||
- Assign roles (admin / user)
|
||||
- Reset passwords for any user
|
||||
- Revoke all sessions (force logout) for any user
|
||||
|
||||
### PWA / Offline-First
|
||||
- Installable as standalone app
|
||||
- Web app manifest with icons
|
||||
- Service worker for asset caching
|
||||
- IndexedDB for local data storage
|
||||
- Background sync every 30 seconds
|
||||
- Service worker for asset caching (CacheFirst for attachments, NetworkFirst for API)
|
||||
- IndexedDB for local data storage (notes + pending attachments)
|
||||
- Background sync every 30 seconds (notes + offline attachments)
|
||||
- LWW conflict resolution for multi-device use
|
||||
- Sync status indicator (synced/syncing/offline/error)
|
||||
|
||||
### MCP Server (Model Context Protocol)
|
||||
- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport)
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; pin, reorder notes; upload images
|
||||
- API key authentication (Bearer token)
|
||||
- Compatible with Claude Code, Claude Desktop, and other MCP clients
|
||||
- Stateful sessions with automatic cleanup
|
||||
|
||||
### API Key Management
|
||||
- Generate API keys from the Settings page
|
||||
- Keys use `crumbs_` prefix for easy identification
|
||||
- SHA-256 hashed storage (keys shown once at creation)
|
||||
- Track last-used timestamps
|
||||
- Revoke keys with confirmation dialog
|
||||
- MCP client config example shown in Settings
|
||||
|
||||
### Docker Deployment
|
||||
- Multi-stage Dockerfile (build + slim runtime)
|
||||
- docker-compose.yml with persistent volume
|
||||
|
||||
+143
-1
@@ -185,9 +185,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: thumb
|
||||
in: query
|
||||
description: Set to "1" to return the thumbnail instead of the full image (requires attachmentId)
|
||||
schema:
|
||||
type: string
|
||||
enum: ['1']
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment list or file content
|
||||
description: Attachment list (without attachmentId) or file content (with attachmentId)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attachment'
|
||||
post:
|
||||
summary: Upload an image attachment
|
||||
tags: [Attachments]
|
||||
@@ -203,6 +215,10 @@ paths:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file (max 10MB)
|
||||
thumbnail:
|
||||
type: string
|
||||
format: binary
|
||||
description: Pre-generated thumbnail (WebP, ~200px)
|
||||
responses:
|
||||
'201':
|
||||
description: Attachment metadata
|
||||
@@ -306,6 +322,93 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
'400':
|
||||
description: Invalid changes payload
|
||||
|
||||
/api/settings/api-keys:
|
||||
get:
|
||||
summary: List API keys
|
||||
tags: [Settings]
|
||||
responses:
|
||||
'200':
|
||||
description: List of API keys (without hashes)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ApiKey'
|
||||
post:
|
||||
summary: Create API key
|
||||
tags: [Settings]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: API key created (key shown only once)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
keyPrefix:
|
||||
type: string
|
||||
|
||||
/api/settings/api-keys/{id}:
|
||||
delete:
|
||||
summary: Revoke API key
|
||||
tags: [Settings]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Key revoked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
'404':
|
||||
description: Key not found
|
||||
|
||||
/api/mcp:
|
||||
post:
|
||||
summary: MCP Streamable HTTP endpoint
|
||||
description: Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key).
|
||||
tags: [MCP]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: JSON-RPC 2.0 message
|
||||
responses:
|
||||
'200':
|
||||
description: JSON-RPC response (may be SSE stream)
|
||||
'400':
|
||||
description: Invalid session
|
||||
'401':
|
||||
description: Invalid or missing API key
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
@@ -313,6 +416,10 @@ components:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: session
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: API key (crumbs_...)
|
||||
|
||||
schemas:
|
||||
Success:
|
||||
@@ -340,6 +447,10 @@ components:
|
||||
type: boolean
|
||||
trashed:
|
||||
type: boolean
|
||||
trashedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
checklistMode:
|
||||
type: boolean
|
||||
sortOrder:
|
||||
@@ -356,10 +467,18 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attachment'
|
||||
|
||||
NoteInput:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Optional — generated if omitted
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
@@ -399,6 +518,10 @@ components:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
thumbnailPath:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Server path to thumbnail file (null if no thumbnail)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -418,3 +541,22 @@ components:
|
||||
description: Unix timestamp in milliseconds
|
||||
data:
|
||||
$ref: '#/components/schemas/NoteInput'
|
||||
|
||||
ApiKey:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
keyPrefix:
|
||||
type: string
|
||||
description: First 12 characters of the key for display
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
lastUsedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE `attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`thumbnail_path` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `note_tags` (
|
||||
`note_id` text NOT NULL,
|
||||
`tag_id` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_tags_note_id_idx` ON `note_tags` (`note_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_tags_tag_id_idx` ON `note_tags` (`tag_id`);--> statement-breakpoint
|
||||
CREATE TABLE `notes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`title` text DEFAULT '' NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`color` text DEFAULT 'default' NOT NULL,
|
||||
`pinned` integer DEFAULT false NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`trashed` integer DEFAULT false NOT NULL,
|
||||
`trashed_at` integer,
|
||||
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
|
||||
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sync_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
|
||||
CREATE TABLE `tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE `login_attempts` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`ip` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`success` integer NOT NULL,
|
||||
`timestamp` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint
|
||||
DROP INDEX `tags_name_unique`;--> statement-breakpoint
|
||||
ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`email` text DEFAULT '' NOT NULL,
|
||||
`display_name` text DEFAULT '' NOT NULL,
|
||||
`role` text DEFAULT 'user' NOT NULL,
|
||||
`password_hash` text,
|
||||
`auth_provider` text DEFAULT 'password' NOT NULL,
|
||||
`provider_id` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint
|
||||
DROP TABLE `users`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||
ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
|
||||
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint
|
||||
ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id);
|
||||
@@ -0,0 +1,465 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "682b02aa-a28d-4904-8996-cbc4d740f626",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"attachments": {
|
||||
"name": "attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"attachments_note_id_notes_id_fk": {
|
||||
"name": "attachments_note_id_notes_id_fk",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_tags": {
|
||||
"name": "note_tags",
|
||||
"columns": {
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"note_tags_note_id_idx": {
|
||||
"name": "note_tags_note_id_idx",
|
||||
"columns": [
|
||||
"note_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"note_tags_tag_id_idx": {
|
||||
"name": "note_tags_tag_id_idx",
|
||||
"columns": [
|
||||
"tag_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"note_tags_note_id_notes_id_fk": {
|
||||
"name": "note_tags_note_id_notes_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"note_tags_tag_id_tags_id_fk": {
|
||||
"name": "note_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"pinned": {
|
||||
"name": "pinned",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed": {
|
||||
"name": "trashed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed_at": {
|
||||
"name": "trashed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checklist_mode": {
|
||||
"name": "checklist_mode",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notes_trashed_archived_idx": {
|
||||
"name": "notes_trashed_archived_idx",
|
||||
"columns": [
|
||||
"trashed",
|
||||
"archived"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"notes_updated_at_idx": {
|
||||
"name": "notes_updated_at_idx",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sync_log": {
|
||||
"name": "sync_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation": {
|
||||
"name": "operation",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sync_log_timestamp_idx": {
|
||||
"name": "sync_log_timestamp_idx",
|
||||
"columns": [
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sync_log_note_id_notes_id_fk": {
|
||||
"name": "sync_log_note_id_notes_id_fk",
|
||||
"tableFrom": "sync_log",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3",
|
||||
"prevId": "682b02aa-a28d-4904-8996-cbc4d740f626",
|
||||
"tables": {
|
||||
"attachments": {
|
||||
"name": "attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"featured": {
|
||||
"name": "featured",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"attachments_user_id_users_id_fk": {
|
||||
"name": "attachments_user_id_users_id_fk",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"attachments_note_id_notes_id_fk": {
|
||||
"name": "attachments_note_id_notes_id_fk",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"login_attempts": {
|
||||
"name": "login_attempts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"success": {
|
||||
"name": "success",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"login_attempts_ip_timestamp_idx": {
|
||||
"name": "login_attempts_ip_timestamp_idx",
|
||||
"columns": [
|
||||
"ip",
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_tags": {
|
||||
"name": "note_tags",
|
||||
"columns": {
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"note_tags_note_id_idx": {
|
||||
"name": "note_tags_note_id_idx",
|
||||
"columns": [
|
||||
"note_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"note_tags_tag_id_idx": {
|
||||
"name": "note_tags_tag_id_idx",
|
||||
"columns": [
|
||||
"tag_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"note_tags_note_id_notes_id_fk": {
|
||||
"name": "note_tags_note_id_notes_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"note_tags_tag_id_tags_id_fk": {
|
||||
"name": "note_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"pinned": {
|
||||
"name": "pinned",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed": {
|
||||
"name": "trashed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed_at": {
|
||||
"name": "trashed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checklist_mode": {
|
||||
"name": "checklist_mode",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notes_user_id_idx": {
|
||||
"name": "notes_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"notes_trashed_archived_idx": {
|
||||
"name": "notes_trashed_archived_idx",
|
||||
"columns": [
|
||||
"trashed",
|
||||
"archived"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"notes_updated_at_idx": {
|
||||
"name": "notes_updated_at_idx",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"notes_user_id_users_id_fk": {
|
||||
"name": "notes_user_id_users_id_fk",
|
||||
"tableFrom": "notes",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_used_at": {
|
||||
"name": "last_used_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sync_log": {
|
||||
"name": "sync_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation": {
|
||||
"name": "operation",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sync_log_timestamp_idx": {
|
||||
"name": "sync_log_timestamp_idx",
|
||||
"columns": [
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sync_log_user_id_users_id_fk": {
|
||||
"name": "sync_log_user_id_users_id_fk",
|
||||
"tableFrom": "sync_log",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"sync_log_note_id_notes_id_fk": {
|
||||
"name": "sync_log_note_id_notes_id_fk",
|
||||
"tableFrom": "sync_log",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tags_name_user_unique": {
|
||||
"name": "tags_name_user_unique",
|
||||
"columns": [
|
||||
"name",
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tags_user_id_users_id_fk": {
|
||||
"name": "tags_user_id_users_id_fk",
|
||||
"tableFrom": "tags",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auth_provider": {
|
||||
"name": "auth_provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'password'"
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1773000424830,
|
||||
"tag": "0000_wooden_vargas",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1773016788899,
|
||||
"tag": "0001_strange_lord_hawal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+48
-30
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.1.8",
|
||||
"version": "0.9.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
"homepage": "https://crumbs.bretzel.app",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bretzel-app/crumbs.git"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -22,43 +29,49 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@tiptap/core": "^3.20.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-task-item": "^3.20.0",
|
||||
"@tiptap/extension-task-list": "^3.20.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/pm": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"argon2": "^0.43.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"idb": "^8.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"arctic": "^3.7.0",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -66,6 +79,11 @@
|
||||
"argon2",
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: 'html',
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
trace: 'on-first-retry',
|
||||
@@ -14,14 +14,15 @@ export default defineConfig({
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /auth\.spec\.ts/,
|
||||
name: 'auth-setup',
|
||||
testMatch: 'auth.spec.ts',
|
||||
fullyParallel: false,
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: /auth\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
name: 'app',
|
||||
testIgnore: 'auth.spec.ts',
|
||||
dependencies: ['auth-setup'],
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
],
|
||||
@@ -31,7 +32,8 @@ export default defineConfig({
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
DATABASE_URL: './data/test-crumbs.db'
|
||||
DATABASE_URL: './data/test-crumbs.db',
|
||||
NODE_ENV: 'test'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Generated
+865
-527
File diff suppressed because it is too large
Load Diff
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Automate version bump, tag, and push based on conventional commits.
|
||||
# Usage: bash scripts/release.sh [patch|minor|major]
|
||||
# If no argument is given, the bump type is inferred from commits since the last tag.
|
||||
|
||||
BUMP="${1:-}"
|
||||
|
||||
# --- Safety checks ---
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$BRANCH" != "main" ]]; then
|
||||
echo "Error: must be on the main branch (currently on '$BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Error: working tree is not clean" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin main --tags --quiet
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
if [[ "$LOCAL" != "$REMOTE" ]]; then
|
||||
echo "Error: local main is not in sync with origin/main" >&2
|
||||
echo " local: $LOCAL" >&2
|
||||
echo " remote: $REMOTE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Bump type inference ---
|
||||
|
||||
if [[ -z "$BUMP" ]]; then
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "")
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
echo "No previous v* tag found, defaulting to patch"
|
||||
BUMP="patch"
|
||||
else
|
||||
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'%s%n%b')
|
||||
if echo "$COMMITS" | grep -qiE 'BREAKING CHANGE:|^[a-z]+(\(.*\))?!:'; then
|
||||
BUMP="major"
|
||||
elif echo "$COMMITS" | grep -qE '^feat(\(.*\))?:'; then
|
||||
BUMP="minor"
|
||||
else
|
||||
BUMP="patch"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then
|
||||
echo "Error: invalid bump type '$BUMP' (must be patch, minor, or major)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Bump, tag, push ---
|
||||
|
||||
echo "Bumping $BUMP version..."
|
||||
npm version "$BUMP" -m "chore: bump version to %s" --no-git-tag-version
|
||||
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Update version in marketing website
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
sed -i '' "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
|
||||
else
|
||||
sed -i "s|<span class=\"footer-version\">v[^<]*</span>|<span class=\"footer-version\">v$NEW_VERSION</span>|" website/index.html
|
||||
fi
|
||||
|
||||
git add package.json website/index.html
|
||||
git commit -m "chore: bump version to $NEW_VERSION"
|
||||
git tag "v$NEW_VERSION"
|
||||
|
||||
echo "Pushing to origin..."
|
||||
git push origin main --tags
|
||||
|
||||
echo ""
|
||||
echo "Released v$NEW_VERSION ($BUMP bump)"
|
||||
echo "GitHub Actions release workflow will handle the rest."
|
||||
+20
@@ -12,10 +12,19 @@
|
||||
--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);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -29,6 +38,17 @@ body::before {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Editor entrance animations */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* Prose overrides for parchment theme */
|
||||
.prose {
|
||||
--tw-prose-body: var(--text);
|
||||
|
||||
Vendored
+8
-1
@@ -3,7 +3,14 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'admin' | 'user';
|
||||
} | null;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
+6
-4
@@ -6,10 +6,12 @@
|
||||
<meta name="theme-color" content="#f0e6d3" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥨</text></svg>"
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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>
|
||||
|
||||
+52
-3
@@ -1,17 +1,59 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { validateSession, isSetupComplete } from '$lib/server/auth.js';
|
||||
import { validateApiKey, getUserForApiKey } from '$lib/server/api-keys.js';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/setup', '/api/auth/login', '/api/auth/setup'];
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
'/setup',
|
||||
'/api/auth/login',
|
||||
'/api/auth/setup',
|
||||
'/api/auth/oauth'
|
||||
];
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { pathname } = event.url;
|
||||
|
||||
// Initialize locals
|
||||
event.locals.user = null;
|
||||
|
||||
// Allow public paths
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// MCP endpoint: authenticate via Bearer token (API key)
|
||||
if (pathname.startsWith('/api/mcp')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const { valid, userId } = validateApiKey(token);
|
||||
if (!valid || !userId) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const user = getUserForApiKey(userId);
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Check if setup is complete
|
||||
const setupDone = await isSetupComplete();
|
||||
if (!setupDone) {
|
||||
@@ -21,9 +63,16 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
// Validate session
|
||||
// Validate session and populate user
|
||||
const sessionToken = event.cookies.get('session');
|
||||
if (!sessionToken || !(await validateSession(sessionToken))) {
|
||||
if (sessionToken) {
|
||||
const result = await validateSession(sessionToken);
|
||||
if (result.valid && result.user) {
|
||||
event.locals.user = result.user;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event.locals.user) {
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
@@ -11,15 +19,26 @@
|
||||
|
||||
let { content, onChange }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
|
||||
let activeItems = $derived(items.filter((i) => !i.checked));
|
||||
let doneItems = $derived(items.filter((i) => i.checked));
|
||||
let doneCount = $derived(doneItems.length);
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ text: '', checked: false }];
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
|
||||
return { text: line, checked: false };
|
||||
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
||||
return { id: generateId(), text: line, checked: false };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,88 +52,151 @@
|
||||
onChange(serializeChecklist(items));
|
||||
}
|
||||
|
||||
function toggleItem(index: number) {
|
||||
items[index].checked = !items[index].checked;
|
||||
emitChange();
|
||||
function toggleItem(id: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.checked = !item.checked;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateText(index: number, text: string) {
|
||||
items[index].text = text;
|
||||
emitChange();
|
||||
function updateText(id: string, text: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = text;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function addItem(afterIndex: number) {
|
||||
items.splice(afterIndex + 1, 0, { text: '', checked: false });
|
||||
const newItem = { id: generateId(), text: '', checked: false };
|
||||
items.splice(afterIndex + 1, 0, newItem);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
// Focus new input on next tick
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[afterIndex + 1]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
function removeItem(id: string) {
|
||||
if (items.length <= 1) return;
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
||||
function handleKeydown(e: KeyboardEvent, id: string) {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem(index);
|
||||
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
||||
e.preventDefault();
|
||||
removeItem(index);
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
// Merge reordered active items with done items
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
emitChange();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1" data-testid="checklist">
|
||||
{#each items as item, index}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(index)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, index)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeItem(index)}
|
||||
class="opacity-0 hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<svg class="h-4 w-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="space-y-0.5" data-testid="checklist">
|
||||
<section
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-0.5"
|
||||
>
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<button
|
||||
onclick={() => addItem(items.length - 1)}
|
||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add item
|
||||
</button>
|
||||
|
||||
{#if doneCount > 0}
|
||||
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
|
||||
<button
|
||||
onclick={() => (doneExpanded = !doneExpanded)}
|
||||
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-toggle-done"
|
||||
>
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
{doneCount} done
|
||||
</button>
|
||||
|
||||
{#if doneExpanded}
|
||||
<div class="mt-1 space-y-0.5 pl-5" data-testid="checklist-done-section">
|
||||
{#each doneItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{@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-transparent'}"
|
||||
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}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Ellipsis
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -80,11 +81,11 @@
|
||||
const chevronSize = 14;
|
||||
|
||||
function btnClass(active: boolean = false, disabled: boolean = false): string {
|
||||
return `rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownBtnClass(active: boolean = false): string {
|
||||
return `flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownItemClass(active: boolean = false): string {
|
||||
@@ -109,30 +110,44 @@
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
data-testid="formatting-toolbar"
|
||||
>
|
||||
<!-- History -->
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo (Ctrl+Z)"
|
||||
data-testid="format-undo"
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class={btnClass(editor?.isActive('bold') ?? false)}
|
||||
title="Bold (Ctrl+B)"
|
||||
data-testid="format-bold"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
<Bold size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
data-testid="format-redo"
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class={btnClass(editor?.isActive('italic') ?? false)}
|
||||
title="Italic (Ctrl+I)"
|
||||
data-testid="format-italic"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
<Italic size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
class={btnClass(editor?.isActive('strike') ?? false)}
|
||||
title="Strikethrough (Ctrl+Shift+X)"
|
||||
data-testid="format-strikethrough"
|
||||
>
|
||||
<Strikethrough size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
class={btnClass(editor?.isActive('underline') ?? false)}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
>
|
||||
<Underline size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="relative" data-dropdown="heading">
|
||||
@@ -146,7 +161,7 @@
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
|
||||
@@ -187,7 +202,7 @@
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
|
||||
@@ -208,67 +223,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class={btnClass(editor?.isActive('blockquote') ?? false)}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
class={btnClass(editor?.isActive('codeBlock') ?? false)}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
class={btnClass(editor?.isActive('bold') ?? false)}
|
||||
title="Bold (Ctrl+B)"
|
||||
data-testid="format-bold"
|
||||
>
|
||||
<Bold size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
class={btnClass(editor?.isActive('italic') ?? false)}
|
||||
title="Italic (Ctrl+I)"
|
||||
data-testid="format-italic"
|
||||
>
|
||||
<Italic size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
class={btnClass(editor?.isActive('strike') ?? false)}
|
||||
title="Strikethrough (Ctrl+Shift+X)"
|
||||
data-testid="format-strikethrough"
|
||||
>
|
||||
<Strikethrough size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||
class={btnClass(editor?.isActive('code') ?? false)}
|
||||
title="Inline code (Ctrl+E)"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
class={btnClass(editor?.isActive('underline') ?? false)}
|
||||
title="Underline (Ctrl+U)"
|
||||
data-testid="format-underline"
|
||||
>
|
||||
<Underline size={iconSize} />
|
||||
</button>
|
||||
|
||||
<!-- Link popover -->
|
||||
<div class="relative" data-dropdown="link">
|
||||
<button
|
||||
@@ -280,7 +234,7 @@
|
||||
<Link size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'link'}
|
||||
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
<form
|
||||
class="flex items-center gap-1"
|
||||
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
|
||||
@@ -327,28 +281,79 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Align dropdown -->
|
||||
<div class="relative" data-dropdown="align">
|
||||
<!-- More dropdown -->
|
||||
<div class="relative" data-dropdown="more">
|
||||
<button
|
||||
onclick={() => toggleDropdown('align')}
|
||||
class={dropdownBtnClass(editor?.isActive({ textAlign: 'center' }) || editor?.isActive({ textAlign: 'right' }) || editor?.isActive({ textAlign: 'justify' }) ? true : false)}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
onclick={() => toggleDropdown('more')}
|
||||
class={dropdownBtnClass(false)}
|
||||
title="More options"
|
||||
data-testid="format-more"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
<Ellipsis size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
{#if openDropdown === 'more'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
|
||||
disabled={!canUndo()}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
<span>Undo</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
|
||||
disabled={!canRedo()}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
<span>Redo</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('code') ?? false)}
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
<span>Inline code</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
<span>Blockquote</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
<span>Code block</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Divider</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Left</span>
|
||||
<span>Align left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
@@ -356,7 +361,7 @@
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Center</span>
|
||||
<span>Align center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
@@ -364,7 +369,7 @@
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Right</span>
|
||||
<span>Align right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
@@ -377,16 +382,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Insert -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
class={btnClass()}
|
||||
title="Horizontal rule"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { src, alt, onClose }: Props = $props();
|
||||
|
||||
let dialogEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackgroundClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
dialogEl?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={dialogEl}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={handleBackgroundClick}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image lightbox"
|
||||
tabindex="-1"
|
||||
data-testid="image-lightbox"
|
||||
>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="absolute right-4 top-4 rounded-sm p-2 text-white hover:bg-white/10"
|
||||
aria-label="Close lightbox"
|
||||
data-testid="lightbox-close"
|
||||
>
|
||||
<XIcon class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain rounded-sm animate-[pop-in_150ms_ease-out]"
|
||||
data-testid="lightbox-image"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,27 +1,83 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||
import { browser } from '$app/environment';
|
||||
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
attachments: Attachment[];
|
||||
onUpload: (attachment: Attachment) => void;
|
||||
onRemove: (attachmentId: string) => void;
|
||||
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
|
||||
showDropZone?: boolean;
|
||||
}
|
||||
|
||||
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured, showDropZone = true }: Props = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let optimizing = $state(false);
|
||||
let dragOver = $state(false);
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || !noteId) return;
|
||||
|
||||
uploading = true;
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
|
||||
optimizing = true;
|
||||
let optimized: Blob;
|
||||
let thumbnail: Blob;
|
||||
try {
|
||||
const result = await optimizeImage(file);
|
||||
optimized = result.optimized;
|
||||
thumbnail = result.thumbnail;
|
||||
} catch (err) {
|
||||
console.error('Optimization failed, using original:', err);
|
||||
optimized = file;
|
||||
thumbnail = file;
|
||||
}
|
||||
optimizing = false;
|
||||
|
||||
// Offline: store in IDB and display via blob URL
|
||||
if (browser && !navigator.onLine) {
|
||||
const tempId = crypto.randomUUID();
|
||||
const pending: PendingAttachment = {
|
||||
id: tempId,
|
||||
noteId,
|
||||
optimized,
|
||||
thumbnail,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
await addPendingAttachment(pending);
|
||||
|
||||
const blobUrl = URL.createObjectURL(optimized);
|
||||
onUpload({
|
||||
id: tempId,
|
||||
noteId,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
size: optimized.size,
|
||||
path: blobUrl,
|
||||
pending: true,
|
||||
createdAt: new Date()
|
||||
} as Attachment & { pending: boolean });
|
||||
continue;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
|
||||
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
||||
@@ -31,12 +87,16 @@
|
||||
if (res.ok) {
|
||||
const attachment = await res.json();
|
||||
onUpload(attachment);
|
||||
} else {
|
||||
console.error('Upload failed:', res.status, await res.text());
|
||||
showToast('Failed to upload image', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
showToast('Failed to upload image', 'error');
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
@@ -56,22 +116,42 @@
|
||||
{#if attachments.length > 0}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each attachments as attachment}
|
||||
{@const isPending = 'pending' in attachment && attachment.pending}
|
||||
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
||||
<div class="group relative">
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 rounded-sm object-cover"
|
||||
<button
|
||||
type="button"
|
||||
class="h-20 w-20 cursor-pointer rounded-sm p-0 border-0 bg-transparent overflow-hidden"
|
||||
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 object-cover {isPending ? 'opacity-70' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
</div>
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
|
||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 transition-opacity {attachment.featured ? 'opacity-100' : 'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100'}"
|
||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||
data-testid="toggle-featured"
|
||||
>
|
||||
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
|
||||
class="absolute -right-1 -top-1 rounded-full bg-[var(--destructive)] p-0.5 text-white transition-opacity max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -79,29 +159,58 @@
|
||||
{/if}
|
||||
|
||||
<!-- Upload area -->
|
||||
<div
|
||||
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDropZone}
|
||||
<!-- Drop zone for desktop, browse-only label for mobile -->
|
||||
<div
|
||||
class="hidden rounded-sm border-2 border-dashed p-4 text-center transition-colors md:block {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Mobile: compact browse button -->
|
||||
<div class="md:hidden">
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-sm text-[var(--text-muted)] hover:border-[var(--primary)]">
|
||||
<span>Add images</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input-mobile"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
<script lang="ts">
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
let { onMenuToggle }: Props = $props();
|
||||
let mobileSearchOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="h-6 w-6 text-[var(--text)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if mobileSearchOpen}
|
||||
<!-- Mobile expanded search -->
|
||||
<div class="flex flex-1 items-center gap-2 lg:hidden">
|
||||
<SearchBar onClose={() => (mobileSearchOpen = false)} />
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Menu class="h-6 w-6 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥨</span>
|
||||
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="h-8 w-8" />
|
||||
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 flex-1">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<!-- Desktop search bar -->
|
||||
<div class="mx-4 hidden flex-1 lg:block">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
<!-- Mobile search icon -->
|
||||
<button
|
||||
onclick={() => (mobileSearchOpen = true)}
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search class="h-5 w-5 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -1,57 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import { StickyNote, Archive, Trash2, Tag } from 'lucide-svelte';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import { page } from '$app/stores';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
let { open }: Props = $props();
|
||||
let { open, onClose, appVersion }: Props = $props();
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
loadNotes(filter);
|
||||
function closeMobile() {
|
||||
if (window.matchMedia('(max-width: 1023px)').matches) {
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function selectTag(tag: string | null) {
|
||||
selectedTag.set(tag);
|
||||
loadNotes('all');
|
||||
}
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 top-16 z-10 bg-black/30 lg:hidden"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<aside
|
||||
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-[var(--border)] bg-[var(--bg-surface)] transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
|
||||
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-[var(--border)] bg-[var(--bg-surface)] transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'}"
|
||||
>
|
||||
<nav class="p-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('all')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
Crumbs
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('archived')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Archive size={20} />
|
||||
Archive
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('trashed')}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
Trash
|
||||
</button>
|
||||
</li>
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
@@ -60,17 +58,34 @@
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each $allTags as tag}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectTag($selectedTag === tag ? null : tag)}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
<a
|
||||
href="/tag/{tag}"
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Tag size={16} />
|
||||
#{tag}
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="absolute bottom-0 left-0 w-full border-t border-[var(--border-subtle)]">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex w-full items-center gap-3 px-6 py-3 text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--primary)]"
|
||||
onclick={closeMobile}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Settings
|
||||
</a>
|
||||
{#if appVersion}
|
||||
<div class="border-t border-[var(--border-subtle)] px-6 py-2 text-right text-xs text-[var(--text-muted)]">
|
||||
v{appVersion}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: 'info' | 'success' | 'error';
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
export const toasts = writable<ToastMessage[]>([]);
|
||||
|
||||
export function showToast(
|
||||
text: string,
|
||||
type: 'info' | 'success' | 'error' = 'info',
|
||||
action?: { label: string; handler: () => void },
|
||||
duration = 4000
|
||||
) {
|
||||
const id = ++counter;
|
||||
toasts.update((t) => [...t, { id, text, type, action }]);
|
||||
if (!action) {
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
toasts.update((t) => t.filter((toast) => toast.id !== id));
|
||||
}
|
||||
import { toasts, dismiss } from '$lib/stores/toast.js';
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-sm border border-[var(--border)] px-4 py-3 {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-[var(--text)] text-[var(--bg-base)]'}"
|
||||
class="flex items-center gap-3 rounded-sm border px-4 py-3 shadow-[var(--card-shadow)] {toast.type === 'error' ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : toast.type === 'success' ? 'border-[var(--border-subtle)] bg-[var(--success-bg)] text-[var(--success-text)]' : 'border-[var(--border)] bg-[var(--text)] text-[var(--bg-base)]'}"
|
||||
>
|
||||
<span class="text-sm">{toast.text}</span>
|
||||
{#if toast.action}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import ImageLightbox from './ImageLightbox.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';
|
||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
@@ -12,9 +19,10 @@
|
||||
interface Props {
|
||||
note: Note;
|
||||
onEdit: (note: Note) => void;
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
let { note, onEdit }: Props = $props();
|
||||
let { note, onEdit, fullHeight = false }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[note.color];
|
||||
@@ -22,6 +30,8 @@
|
||||
});
|
||||
|
||||
let cardStyle = $state('');
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
const renderedContent = $derived(renderMarkdown(note.content));
|
||||
|
||||
@@ -34,16 +44,38 @@
|
||||
: []
|
||||
);
|
||||
|
||||
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
|
||||
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
|
||||
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
|
||||
|
||||
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
|
||||
|
||||
function stop(fn: () => void) {
|
||||
return (e: Event) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
}
|
||||
|
||||
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)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
||||
style={cardStyle}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
@@ -52,15 +84,60 @@
|
||||
data-testid="note-card"
|
||||
data-note-id={note.id}
|
||||
>
|
||||
<!-- Thumbnail strip (featured images only) -->
|
||||
{#if featuredAttachments.length > 0}
|
||||
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
|
||||
{#each featuredAttachments.slice(0, 3) as attachment}
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
|
||||
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="card-thumbnail"
|
||||
>
|
||||
<img
|
||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||
alt={attachment.filename}
|
||||
class="h-24 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if featuredAttachments.length > 3}
|
||||
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
|
||||
+{featuredAttachments.length - 3}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if note.title}
|
||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
||||
{/if}
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-1" data-testid="note-checklist-preview">
|
||||
{#each checklistItems.slice(0, 8) as item}
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<input type="checkbox" checked={item.checked} disabled class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)]" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
||||
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
<span class="truncate">{item.text}</span>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -77,15 +154,13 @@
|
||||
{#if note.tags && note.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each note.tags as tag}
|
||||
<span class="rounded-sm bg-[var(--border)]/10 px-2 py-0.5 text-xs text-[var(--text-muted)]">
|
||||
#{tag}
|
||||
</span>
|
||||
<TagChip {tag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons - show on hover -->
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
@@ -93,9 +168,7 @@
|
||||
title="Restore"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
<Undo2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
@@ -103,21 +176,19 @@
|
||||
title="Delete forever"
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title={note.pinned ? 'Unpin' : 'Pin'}
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<svg class="h-4 w-4 {note.pinned ? 'text-[var(--primary)]' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Pin"
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $currentFilter === 'archived'}
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
@@ -125,9 +196,7 @@
|
||||
title="Unarchive"
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<ArchiveRestore class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -136,9 +205,7 @@
|
||||
title="Archive"
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<Archive class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
@@ -147,20 +214,13 @@
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pin indicator -->
|
||||
{#if note.pinned}
|
||||
<div class="absolute right-2 top-2">
|
||||
<svg class="h-4 w-4 text-[var(--primary)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor } from '$lib/types/index.js';
|
||||
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
@@ -25,34 +32,116 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let rawMarkdownMode = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
|
||||
// Mutable note identity — allows transitioning from new → saved without closing
|
||||
// svelte-ignore state_referenced_locally
|
||||
let noteId = $state<string | null>(note?.id ?? null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentlyNew = $state(isNew);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
fetch(`/api/notes/${noteId}/attachments`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: Attachment[]) => {
|
||||
if (data.length > 0) attachmentsList = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
function handleAttachmentUpload(attachment: Attachment) {
|
||||
attachmentsList = [...attachmentsList, attachment];
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||
if (!noteId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
a.id === attachmentId ? { ...a, featured } : a
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachmentRemove(attachmentId: string) {
|
||||
if (!noteId) return;
|
||||
try {
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let bgStyle = $state('');
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[color];
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
async function saveAndClose() {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (note) {
|
||||
await updateNote(note.id, { title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
if (!id) return;
|
||||
}
|
||||
showImageUpload = !showImageUpload;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
save();
|
||||
saveAndClose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +160,14 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center bg-black/50 pt-20"
|
||||
onclick={save}
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={saveAndClose}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-xl rounded-sm border border-[var(--border)]"
|
||||
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
@@ -94,27 +183,43 @@
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
</div>
|
||||
{:else if rawMarkdownMode}
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
placeholder="Add a crumb..."
|
||||
bind:value={content}
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
rows="12"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
{:else}
|
||||
<TiptapEditor
|
||||
{content}
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#if noteId && (showImageUpload || attachmentsList.length > 0)}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
attachments={attachmentsList}
|
||||
onUpload={handleAttachmentUpload}
|
||||
onRemove={handleAttachmentRemove}
|
||||
onToggleFeatured={handleToggleFeatured}
|
||||
showDropZone={showImageUpload}
|
||||
/>
|
||||
</div>
|
||||
{:else if rawMarkdownMode}
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
placeholder="Add a crumb..."
|
||||
bind:value={content}
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
rows="12"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
{:else}
|
||||
<TiptapEditor
|
||||
{content}
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
@@ -133,12 +238,10 @@
|
||||
title="Background color"
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
{#if showColorPicker}
|
||||
<div class="absolute left-0 top-full mt-2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
|
||||
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
|
||||
<ColorPicker selected={color} onSelect={handleColorSelect} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -147,30 +250,45 @@
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => (checklistMode = !checklistMode)}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 {checklistMode ? 'text-[var(--primary)]' : ''}"
|
||||
title="Checklist mode"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
{#if checklistMode}
|
||||
<Type class="h-5 w-5" />
|
||||
{:else}
|
||||
<SquareCheck class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Image attachments"
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 {rawMarkdownMode ? 'text-[var(--primary)]' : ''}"
|
||||
title="Markdown mode"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-5 w-5" />
|
||||
{:else}
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={save}
|
||||
onclick={saveAndClose}
|
||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
<script lang="ts">
|
||||
import NoteCard from './NoteCard.svelte';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
label?: string;
|
||||
onEdit: (note: Note) => void;
|
||||
draggable?: boolean;
|
||||
dndType?: string;
|
||||
onReorder?: (noteIds: string[]) => void;
|
||||
}
|
||||
|
||||
let { notes, label = '', onEdit }: Props = $props();
|
||||
let { notes, label = '', onEdit, draggable = false, dndType = 'notes', onReorder }: Props = $props();
|
||||
|
||||
let localItems = $state<Note[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
localItems = [...notes];
|
||||
});
|
||||
|
||||
const flipDurationMs = 150;
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
}
|
||||
|
||||
function handleFinalize(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
onReorder?.(localItems.map((n) => n.id));
|
||||
}
|
||||
|
||||
let displayItems = $derived(draggable ? localItems : notes);
|
||||
</script>
|
||||
|
||||
{#if notes.length > 0}
|
||||
{#if displayItems.length > 0}
|
||||
{#if label}
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500">{label}</p>
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
|
||||
{/if}
|
||||
{#if draggable}
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
data-testid="note-grid"
|
||||
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each localItems as note (note.id)}
|
||||
<div class="relative h-full" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div
|
||||
use:dragHandle
|
||||
aria-label="drag handle for {note.title || 'note'}"
|
||||
class="absolute top-2 left-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
data-testid="note-drag-handle"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<NoteCard {note} {onEdit} fullHeight />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each displayItems as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each notes as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
tag?: string | null;
|
||||
}
|
||||
|
||||
let { filter, tag = null }: Props = $props();
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
|
||||
// Sync store with route props
|
||||
$effect(() => {
|
||||
currentFilter.set(filter);
|
||||
selectedTag.set(tag);
|
||||
});
|
||||
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
|
||||
const unsubscribe = notes.subscribe(($notes) => {
|
||||
if ($notes.length === 0) return;
|
||||
const note = $notes.find((n) => n.id === hash);
|
||||
if (note) editingNote = note;
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if filter === 'all' && !tag}
|
||||
<div class="mx-auto mb-6 max-w-xl">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
{:else if filter === 'trashed'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
|
||||
{:else if tag}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
|
||||
{/if}
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
{#if filter === 'trashed'}
|
||||
No crumbs in trash
|
||||
{:else if filter === 'archived'}
|
||||
No archived crumbs
|
||||
{:else if tag}
|
||||
No crumbs with tag #{tag}
|
||||
{:else}
|
||||
No crumbs yet
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showNewNote}
|
||||
<NoteEditor note={null} isNew={true} onClose={closeEditor} />
|
||||
{/if}
|
||||
|
||||
{#if editingNote}
|
||||
<NoteEditor note={editingNote} onClose={closeEditor} />
|
||||
{/if}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { ZXCVBNResult } from 'zxcvbn';
|
||||
|
||||
let { password = '' }: { password: string } = $props();
|
||||
|
||||
let result = $state<ZXCVBNResult | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (password.length > 0) {
|
||||
import('zxcvbn').then((mod) => {
|
||||
result = mod.default(password);
|
||||
});
|
||||
} else {
|
||||
result = null;
|
||||
}
|
||||
});
|
||||
|
||||
const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
|
||||
const colors = [
|
||||
'bg-[var(--destructive)]',
|
||||
'bg-[var(--destructive)]',
|
||||
'bg-[var(--primary)]',
|
||||
'bg-[var(--success-text)]',
|
||||
'bg-[var(--success-text)]'
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if result}
|
||||
<div class="mt-1 mb-2" data-testid="password-strength">
|
||||
<div class="flex gap-1">
|
||||
{#each { length: 4 } as _, i}
|
||||
<div
|
||||
class="h-1 flex-1 rounded-sm transition-colors {i <= result.score
|
||||
? colors[result.score]
|
||||
: 'bg-[var(--border-subtle)]'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
{labels[result.score]}
|
||||
{#if result.feedback.warning}
|
||||
— {result.feedback.warning}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,10 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let originalNotes: Note[] = [];
|
||||
let isSearching = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (onClose) inputEl?.focus();
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) {
|
||||
@@ -34,14 +48,24 @@
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
clearSearch();
|
||||
onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative max-w-2xl">
|
||||
<div class="relative flex-1 max-w-2xl">
|
||||
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
|
||||
<svg class="mr-3 h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{#if onClose}
|
||||
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<Search class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
placeholder="Search crumbs..."
|
||||
bind:value={query}
|
||||
@@ -51,9 +75,7 @@
|
||||
/>
|
||||
{#if query}
|
||||
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
|
||||
import Hand from 'lucide-svelte/icons/hand';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
|
||||
{ value: 'updated', label: 'Updated', icon: Clock },
|
||||
{ value: 'created', label: 'Created', icon: CalendarPlus },
|
||||
{ value: 'custom', label: 'Custom', icon: Hand }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
|
||||
{#each modes as mode}
|
||||
<button
|
||||
onclick={() => sortMode.set(mode.value)}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
|
||||
{$sortMode === mode.value
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
|
||||
data-testid="sort-{mode.value}"
|
||||
aria-label="Sort by {mode.label}"
|
||||
>
|
||||
<mode.icon class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,24 +1,57 @@
|
||||
<script lang="ts">
|
||||
// Placeholder for Phase 6 - will be connected to sync store
|
||||
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
|
||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
|
||||
|
||||
let status: SyncStatus = $state(getSyncStatus());
|
||||
let displayStatus: SyncStatus = $state(getSyncStatus());
|
||||
let minDisplayTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingStatus: SyncStatus | undefined;
|
||||
|
||||
function applyStatus(s: SyncStatus) {
|
||||
if (displayStatus === s) return;
|
||||
|
||||
if (minDisplayTimeout) {
|
||||
pendingStatus = s;
|
||||
return;
|
||||
}
|
||||
|
||||
displayStatus = s;
|
||||
minDisplayTimeout = setTimeout(() => {
|
||||
minDisplayTimeout = undefined;
|
||||
if (pendingStatus !== undefined && pendingStatus !== displayStatus) {
|
||||
applyStatus(pendingStatus);
|
||||
pendingStatus = undefined;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribe = onSyncStatusChange((s) => {
|
||||
status = s;
|
||||
applyStatus(s);
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
if (minDisplayTimeout) clearTimeout(minDisplayTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
|
||||
{#if status === 'synced'}
|
||||
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{:else if status === 'syncing'}
|
||||
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{:else if status === 'offline'}
|
||||
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</svg>
|
||||
{#if displayStatus === 'synced'}
|
||||
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<CloudSync class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<CloudOff class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
tag: string;
|
||||
active?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { tag, active = false, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if onclick}
|
||||
<button
|
||||
{onclick}
|
||||
class="rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
|
||||
data-testid="tag-chip"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs font-medium text-[var(--text-muted)]"
|
||||
data-testid="tag-chip"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1,21 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
selectedTag.update((current) => (current === tag ? null : tag));
|
||||
const isActive = $page.url.pathname === `/tag/${tag}`;
|
||||
goto(isActive ? '/' : `/tag/${tag}`);
|
||||
}
|
||||
|
||||
function isTagActive(tag: string, pathname: string): boolean {
|
||||
return pathname === `/tag/${tag}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
|
||||
{#each $allTags as tag}
|
||||
<button
|
||||
onclick={() => toggleTag(tag)}
|
||||
class="rounded-sm px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-[var(--primary)] text-white' : 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
|
||||
data-testid="tag-chip"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
<TagChip {tag} active={isTagActive(tag, $page.url.pathname)} onclick={() => toggleTag(tag)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -57,16 +57,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
|
||||
class="tiptap-wrapper prose prose-sm flex min-h-[300px] max-w-none flex-col px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
tabindex="0"
|
||||
onclick={() => editor?.commands.focus()}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.tiptap-wrapper :global(.tiptap) {
|
||||
outline: none;
|
||||
min-height: 80px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { generateApiKey, validateApiKey, listApiKeys, deleteApiKey } from './api-keys.js';
|
||||
import { users } from './db/schema.js';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
describe('API Keys', () => {
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
// Create a test user
|
||||
db.insert(users)
|
||||
.values({ email: 'test@example.com', displayName: 'Test', createdAt: new Date() })
|
||||
.run();
|
||||
userId = 1;
|
||||
});
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
it('returns key with crumbs_ prefix', () => {
|
||||
const result = generateApiKey('Test Key', userId, db);
|
||||
expect(result.key).toMatch(/^crumbs_[a-f0-9]{64}$/);
|
||||
expect(result.name).toBe('Test Key');
|
||||
expect(result.id).toBeTruthy();
|
||||
expect(result.keyPrefix).toBe(result.key.slice(0, 12));
|
||||
});
|
||||
|
||||
it('generates unique keys each time', () => {
|
||||
const key1 = generateApiKey('Key 1', userId, db);
|
||||
const key2 = generateApiKey('Key 2', userId, db);
|
||||
expect(key1.key).not.toBe(key2.key);
|
||||
expect(key1.id).not.toBe(key2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey', () => {
|
||||
it('returns valid with userId for a valid key', () => {
|
||||
const { key } = generateApiKey('Test Key', userId, db);
|
||||
const result = validateApiKey(key, db);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.userId).toBe(userId);
|
||||
});
|
||||
|
||||
it('returns invalid for an invalid key', () => {
|
||||
const result = validateApiKey('crumbs_invalid', db);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.userId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns invalid for empty string', () => {
|
||||
expect(validateApiKey('', db).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns invalid for key without prefix', () => {
|
||||
expect(validateApiKey('no_prefix_here', db).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('updates lastUsedAt on validation', () => {
|
||||
const { key, id } = generateApiKey('Test Key', userId, db);
|
||||
const before = listApiKeys(userId, db).find((k) => k.id === id);
|
||||
expect(before?.lastUsedAt).toBeNull();
|
||||
|
||||
validateApiKey(key, db);
|
||||
|
||||
const after = listApiKeys(userId, db).find((k) => k.id === id);
|
||||
expect(after?.lastUsedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listApiKeys', () => {
|
||||
it('returns empty array when no keys exist', () => {
|
||||
expect(listApiKeys(userId, db)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns keys without hash', () => {
|
||||
generateApiKey('Key 1', userId, db);
|
||||
generateApiKey('Key 2', userId, db);
|
||||
|
||||
const keys = listApiKeys(userId, db);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0]).toHaveProperty('name');
|
||||
expect(keys[0]).toHaveProperty('keyPrefix');
|
||||
expect(keys[0]).toHaveProperty('createdAt');
|
||||
expect(keys[0]).not.toHaveProperty('keyHash');
|
||||
});
|
||||
|
||||
it('only returns keys for the specified user', () => {
|
||||
db.insert(users)
|
||||
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
|
||||
.run();
|
||||
const otherUserId = 2;
|
||||
|
||||
generateApiKey('My Key', userId, db);
|
||||
generateApiKey('Other Key', otherUserId, db);
|
||||
|
||||
expect(listApiKeys(userId, db)).toHaveLength(1);
|
||||
expect(listApiKeys(otherUserId, db)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteApiKey', () => {
|
||||
it('deletes an existing key and returns true', () => {
|
||||
const { id, key } = generateApiKey('Test Key', userId, db);
|
||||
expect(deleteApiKey(id, userId, db)).toBe(true);
|
||||
expect(validateApiKey(key, db).valid).toBe(false);
|
||||
expect(listApiKeys(userId, db)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns false for non-existent key', () => {
|
||||
expect(deleteApiKey('nonexistent', userId, db)).toBe(false);
|
||||
});
|
||||
|
||||
it('cannot delete another user\'s key', () => {
|
||||
db.insert(users)
|
||||
.values({ email: 'other@example.com', displayName: 'Other', createdAt: new Date() })
|
||||
.run();
|
||||
const otherUserId = 2;
|
||||
|
||||
const { id } = generateApiKey('Other Key', otherUserId, db);
|
||||
expect(deleteApiKey(id, userId, db)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { db } from './db/index.js';
|
||||
import { apiKeys, users } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
type Db = BetterSQLite3Database<typeof schema>;
|
||||
|
||||
const PREFIX = 'crumbs_';
|
||||
|
||||
function hashKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
export function generateApiKey(
|
||||
name: string,
|
||||
userId: number,
|
||||
database: Db = db
|
||||
): { id: string; key: string; name: string; keyPrefix: string } {
|
||||
const id = uuidv4();
|
||||
const raw = randomBytes(32).toString('hex');
|
||||
const key = `${PREFIX}${raw}`;
|
||||
const keyHash = hashKey(key);
|
||||
const keyPrefix = key.slice(0, 12);
|
||||
|
||||
database
|
||||
.insert(apiKeys)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.run();
|
||||
|
||||
return { id, key, name, keyPrefix };
|
||||
}
|
||||
|
||||
export function validateApiKey(
|
||||
key: string,
|
||||
database: Db = db
|
||||
): { valid: boolean; userId: number | null } {
|
||||
if (!key || !key.startsWith(PREFIX)) return { valid: false, userId: null };
|
||||
|
||||
const keyHash = hashKey(key);
|
||||
const row = database.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).get();
|
||||
if (!row) return { valid: false, userId: null };
|
||||
|
||||
database
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiKeys.id, row.id))
|
||||
.run();
|
||||
|
||||
return { valid: true, userId: row.userId };
|
||||
}
|
||||
|
||||
export function getUserForApiKey(
|
||||
userId: number,
|
||||
database: Db = db
|
||||
): App.Locals['user'] | null {
|
||||
const user = database
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
role: users.role
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.get();
|
||||
|
||||
return user ? (user as App.Locals['user']) : null;
|
||||
}
|
||||
|
||||
export function listApiKeys(userId: number, database: Db = db) {
|
||||
return database
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt
|
||||
})
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, userId))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function deleteApiKey(id: string, userId: number, database: Db = db): boolean {
|
||||
const existing = database
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing || existing.userId !== userId) return false;
|
||||
|
||||
const result = database.delete(apiKeys).where(eq(apiKeys.id, id)).run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
type EventWithLocals = {
|
||||
locals: App.Locals;
|
||||
};
|
||||
|
||||
export function getUserId(event: EventWithLocals): number {
|
||||
if (!event.locals.user) throw error(401, 'Unauthorized');
|
||||
return event.locals.user.id;
|
||||
}
|
||||
|
||||
export function requireAdmin(event: EventWithLocals) {
|
||||
const user = event.locals.user;
|
||||
if (!user || user.role !== 'admin') throw error(403, 'Forbidden');
|
||||
return user;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from './db/index.js';
|
||||
import { attachments } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
||||
@@ -16,7 +17,9 @@ if (!existsSync(ATTACHMENTS_DIR)) {
|
||||
|
||||
export async function saveAttachment(
|
||||
noteId: string,
|
||||
file: File
|
||||
file: File,
|
||||
userId: number,
|
||||
thumbnail?: File | Blob | null
|
||||
): Promise<typeof attachments.$inferSelect> {
|
||||
const id = uuidv4();
|
||||
const ext = file.name.split('.').pop() || 'bin';
|
||||
@@ -26,15 +29,25 @@ export async function saveAttachment(
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
let thumbnailPath: string | null = null;
|
||||
if (thumbnail) {
|
||||
const thumbFilename = `${id}_thumb.webp`;
|
||||
thumbnailPath = join(ATTACHMENTS_DIR, thumbFilename);
|
||||
const thumbBuffer = Buffer.from(await thumbnail.arrayBuffer());
|
||||
await writeFile(thumbnailPath, thumbBuffer);
|
||||
}
|
||||
|
||||
const [attachment] = await db
|
||||
.insert(attachments)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
noteId,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
path: filePath,
|
||||
thumbnailPath,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning();
|
||||
@@ -42,22 +55,67 @@ export async function saveAttachment(
|
||||
return attachment;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
||||
/**
|
||||
* Batch fetch attachments for multiple notes in a single query.
|
||||
* Returns a Map of noteId → Attachment[].
|
||||
*/
|
||||
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(inArray(attachments.noteId, noteIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, Attachment[]>();
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.noteId);
|
||||
if (existing) {
|
||||
existing.push(row as Attachment);
|
||||
} else {
|
||||
map.set(row.noteId, [row as Attachment]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string, noteId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByNote(noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
const attachment = await getAttachment(id);
|
||||
export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
|
||||
const [updated] = await db
|
||||
.update(attachments)
|
||||
.set({ featured: data.featured })
|
||||
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string, noteId: string) {
|
||||
const attachment = await getAttachment(id, noteId);
|
||||
if (attachment) {
|
||||
try {
|
||||
await unlink(attachment.path);
|
||||
} catch {
|
||||
// File may already be deleted
|
||||
}
|
||||
await db.delete(attachments).where(eq(attachments.id, id));
|
||||
if (attachment.thumbnailPath) {
|
||||
try {
|
||||
await unlink(attachment.thumbnailPath);
|
||||
} catch {
|
||||
// Thumbnail may already be deleted
|
||||
}
|
||||
}
|
||||
await db.delete(attachments).where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,18 +41,25 @@ describe('Auth - User Setup', () => {
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'admin',
|
||||
passwordHash: hash,
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
const user = await db.select().from(users).get();
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.email).toBe('admin@test.com');
|
||||
expect(user!.role).toBe('admin');
|
||||
expect(user!.passwordHash).not.toBe(password);
|
||||
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
|
||||
expect(await argon2.verify(user!.passwordHash!, password)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only allow one user', async () => {
|
||||
it('should only allow one admin setup', async () => {
|
||||
await db.insert(users).values({
|
||||
email: 'admin@test.com',
|
||||
role: 'admin',
|
||||
passwordHash: 'hash1',
|
||||
createdAt: new Date()
|
||||
});
|
||||
@@ -66,7 +73,7 @@ describe('Auth - Sessions', () => {
|
||||
it('should create and validate a session', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
@@ -85,7 +92,7 @@ describe('Auth - Sessions', () => {
|
||||
it('should detect expired sessions', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
@@ -103,7 +110,7 @@ describe('Auth - Sessions', () => {
|
||||
it('should delete a session', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.values({ email: 'test@test.com', passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
|
||||
+278
-34
@@ -1,65 +1,309 @@
|
||||
import { db } from './db/index.js';
|
||||
import { db, sqlite } from './db/index.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import { eq, lt, and } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { User } from '$lib/types/index.js';
|
||||
|
||||
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
type SessionUser = { id: number; email: string; displayName: string; role: 'admin' | 'user' };
|
||||
|
||||
function toUser(row: typeof users.$inferSelect): User {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.displayName,
|
||||
role: row.role,
|
||||
authProvider: row.authProvider,
|
||||
providerId: row.providerId,
|
||||
createdAt: row.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
export async function isSetupComplete(): Promise<boolean> {
|
||||
const user = await db.select().from(users).get();
|
||||
const user = db.select().from(users).get();
|
||||
return !!user;
|
||||
}
|
||||
|
||||
export async function setupUser(password: string): Promise<boolean> {
|
||||
const existing = await db.select().from(users).get();
|
||||
if (existing) return false; // Already set up
|
||||
export async function setupUser(
|
||||
email: string,
|
||||
password: string,
|
||||
displayName?: string
|
||||
): Promise<User | null> {
|
||||
const existing = db.select().from(users).get();
|
||||
if (existing) return null; // Already set up
|
||||
|
||||
const hash = await argon2.hash(password);
|
||||
await db.insert(users).values({
|
||||
passwordHash: hash,
|
||||
createdAt: new Date()
|
||||
});
|
||||
return true;
|
||||
const result = db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
displayName: displayName || email.split('@')[0] || 'Admin',
|
||||
role: 'admin',
|
||||
passwordHash: hash,
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
return toUser(result);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string): Promise<boolean> {
|
||||
const user = await db.select().from(users).get();
|
||||
if (!user) return false;
|
||||
return argon2.verify(user.passwordHash, password);
|
||||
export async function verifyPassword(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<User | null> {
|
||||
const user = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), eq(users.authProvider, 'password')))
|
||||
.get();
|
||||
if (!user || !user.passwordHash) return null;
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, password);
|
||||
return valid ? toUser(user) : null;
|
||||
}
|
||||
|
||||
export async function createSession(): Promise<string> {
|
||||
const user = await db.select().from(users).get();
|
||||
if (!user) throw new Error('No user exists');
|
||||
|
||||
export async function createSession(
|
||||
userId: number,
|
||||
meta?: { userAgent?: string; ip?: string }
|
||||
): Promise<string> {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
|
||||
});
|
||||
const now = new Date();
|
||||
db.insert(sessions)
|
||||
.values({
|
||||
id: token,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
|
||||
createdAt: now,
|
||||
userAgent: meta?.userAgent || null,
|
||||
ip: meta?.ip || null,
|
||||
lastUsedAt: now
|
||||
})
|
||||
.run();
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function validateSession(token: string): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
export async function validateSession(
|
||||
token: string
|
||||
): Promise<{ valid: boolean; user?: SessionUser }> {
|
||||
if (!token) return { valid: false };
|
||||
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
if (!session) return false;
|
||||
// Join sessions with users to get user data in one query
|
||||
const result = sqlite
|
||||
.prepare(
|
||||
`SELECT s.id as session_id, s.expires_at, u.id, u.email, u.display_name, u.role
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ?`
|
||||
)
|
||||
.get(token) as
|
||||
| {
|
||||
session_id: string;
|
||||
expires_at: number;
|
||||
id: number;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (session.expiresAt < new Date()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
return false;
|
||||
if (!result) return { valid: false };
|
||||
|
||||
if (result.expires_at * 1000 < Date.now()) {
|
||||
db.delete(sessions).where(eq(sessions.id, token)).run();
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
return true;
|
||||
// Update lastUsedAt
|
||||
db.update(sessions)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(sessions.id, token))
|
||||
.run();
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
user: {
|
||||
id: result.id,
|
||||
email: result.email,
|
||||
displayName: result.display_name,
|
||||
role: result.role
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSession(token: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
db.delete(sessions).where(eq(sessions.id, token)).run();
|
||||
}
|
||||
|
||||
export async function cleanExpiredSessions(): Promise<void> {
|
||||
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
|
||||
db.delete(sessions).where(lt(sessions.expiresAt, new Date())).run();
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string;
|
||||
createdAt: Date | null;
|
||||
userAgent: string | null;
|
||||
ip: string | null;
|
||||
lastUsedAt: Date | null;
|
||||
expiresAt: Date;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export function listUserSessions(userId: number, currentToken: string): SessionInfo[] {
|
||||
return db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId))
|
||||
.all()
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
userAgent: s.userAgent,
|
||||
ip: s.ip,
|
||||
lastUsedAt: s.lastUsedAt,
|
||||
expiresAt: s.expiresAt,
|
||||
isCurrent: s.id === currentToken
|
||||
}));
|
||||
}
|
||||
|
||||
export function revokeSession(userId: number, sessionId: string): boolean {
|
||||
const session = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId)))
|
||||
.get();
|
||||
if (!session) return false;
|
||||
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function revokeAllSessions(userId: number, exceptToken?: string): void {
|
||||
if (exceptToken) {
|
||||
sqlite
|
||||
.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?')
|
||||
.run(userId, exceptToken);
|
||||
} else {
|
||||
db.delete(sessions).where(eq(sessions.userId, userId)).run();
|
||||
}
|
||||
}
|
||||
|
||||
// --- User CRUD (admin operations) ---
|
||||
|
||||
export async function createUser(
|
||||
email: string,
|
||||
displayName: string,
|
||||
password: string,
|
||||
role: 'admin' | 'user' = 'user'
|
||||
): Promise<User> {
|
||||
const hash = await argon2.hash(password);
|
||||
const result = db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
displayName,
|
||||
role,
|
||||
passwordHash: hash,
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
return toUser(result);
|
||||
}
|
||||
|
||||
export function listUsers(): User[] {
|
||||
return db
|
||||
.select()
|
||||
.from(users)
|
||||
.all()
|
||||
.map(toUser);
|
||||
}
|
||||
|
||||
export function getUser(userId: number): User | null {
|
||||
const row = db.select().from(users).where(eq(users.id, userId)).get();
|
||||
return row ? toUser(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number): Promise<void> {
|
||||
// Cascade: delete user's data in dependency order
|
||||
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
.prepare(
|
||||
'DELETE FROM note_tags WHERE note_id IN (SELECT id FROM notes WHERE user_id = ?)'
|
||||
)
|
||||
.run(userId);
|
||||
sqlite.prepare('DELETE FROM notes WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM tags WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
db.delete(users).where(eq(users.id, userId)).run();
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
userId: number,
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<boolean> {
|
||||
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
||||
if (!user || !user.passwordHash) return false;
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, currentPassword);
|
||||
if (!valid) return false;
|
||||
|
||||
const hash = await argon2.hash(newPassword);
|
||||
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function resetPassword(userId: number, newPassword: string): Promise<void> {
|
||||
const hash = await argon2.hash(newPassword);
|
||||
db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId)).run();
|
||||
}
|
||||
|
||||
export function updateUserRole(userId: number, role: 'admin' | 'user'): void {
|
||||
db.update(users).set({ role }).where(eq(users.id, userId)).run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by OAuth provider, or by email. Invite-only: rejects if no matching user.
|
||||
*/
|
||||
export function findOrLinkOAuthUser(
|
||||
provider: string,
|
||||
providerId: string,
|
||||
email: string,
|
||||
name: string
|
||||
): User | null {
|
||||
// First try exact provider+providerId match
|
||||
const byProvider = db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.authProvider, provider), eq(users.providerId, providerId)))
|
||||
.get();
|
||||
if (byProvider) return toUser(byProvider);
|
||||
|
||||
// Try matching by email (invite-only: user must already exist)
|
||||
const byEmail = db.select().from(users).where(eq(users.email, email)).get();
|
||||
if (!byEmail) return null; // No matching user — not invited
|
||||
|
||||
// Link this OAuth provider to the existing user
|
||||
db.update(users)
|
||||
.set({ authProvider: provider, providerId, displayName: byEmail.displayName || name })
|
||||
.where(eq(users.id, byEmail.id))
|
||||
.run();
|
||||
|
||||
return toUser({ ...byEmail, authProvider: provider, providerId });
|
||||
}
|
||||
|
||||
export function updateUserProfile(
|
||||
userId: number,
|
||||
data: { displayName?: string; email?: string }
|
||||
): void {
|
||||
const updates: Record<string, string> = {};
|
||||
if (data.displayName !== undefined) updates.displayName = data.displayName;
|
||||
if (data.email !== undefined) updates.email = data.email;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
db.update(users).set(updates).where(eq(users.id, userId)).run();
|
||||
}
|
||||
}
|
||||
|
||||
+126
-6
@@ -16,24 +16,36 @@ const sqlite = new Database(DATABASE_URL);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Auto-create tables on first run
|
||||
// Disable foreign keys during schema setup to avoid constraint issues during migration
|
||||
sqlite.pragma('foreign_keys = OFF');
|
||||
|
||||
// Auto-create tables on first run (new installs get full schema)
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
password_hash TEXT NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
password_hash TEXT,
|
||||
auth_provider TEXT NOT NULL DEFAULT 'password',
|
||||
provider_id TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
user_agent TEXT,
|
||||
ip TEXT,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
@@ -50,9 +62,9 @@ sqlite.exec(`
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_tags (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
@@ -61,28 +73,136 @@ sqlite.exec(`
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
featured INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id),
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
note_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: detect old schema (no email column on users) and add columns
|
||||
const userColumns = sqlite
|
||||
.prepare("PRAGMA table_info('users')")
|
||||
.all() as { name: string }[];
|
||||
const hasEmail = userColumns.some((col) => col.name === 'email');
|
||||
|
||||
if (!hasEmail) {
|
||||
sqlite.exec(`
|
||||
ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'password';
|
||||
ALTER TABLE users ADD COLUMN provider_id TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Ensure user_id column exists on data tables (migration from old schema)
|
||||
const notesColumns = sqlite
|
||||
.prepare("PRAGMA table_info('notes')")
|
||||
.all() as { name: string }[];
|
||||
if (!notesColumns.some((col) => col.name === 'user_id')) {
|
||||
sqlite.exec(`
|
||||
ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE attachments ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE sync_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
|
||||
// Backfill: promote existing user to admin and assign all data to them
|
||||
if (!hasEmail) {
|
||||
const existingUser = sqlite.prepare('SELECT id FROM users LIMIT 1').get() as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (existingUser) {
|
||||
const uid = existingUser.id;
|
||||
sqlite.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', uid);
|
||||
for (const table of ['notes', 'tags', 'attachments', 'sync_log']) {
|
||||
sqlite.prepare(`UPDATE ${table} SET user_id = ? WHERE user_id = 0`).run(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: add session tracking columns
|
||||
const sessionColumns = sqlite
|
||||
.prepare("PRAGMA table_info('sessions')")
|
||||
.all() as { name: string }[];
|
||||
if (!sessionColumns.some((col) => col.name === 'created_at')) {
|
||||
sqlite.exec(`
|
||||
ALTER TABLE sessions ADD COLUMN created_at INTEGER;
|
||||
ALTER TABLE sessions ADD COLUMN user_agent TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN ip TEXT;
|
||||
ALTER TABLE sessions ADD COLUMN last_used_at INTEGER;
|
||||
`);
|
||||
}
|
||||
|
||||
// Create indexes AFTER migration ensures columns exist
|
||||
sqlite.exec(`
|
||||
CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS notes_trashed_archived_idx ON notes(trashed, archived);
|
||||
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Replace old tags unique index with compound index including user_id
|
||||
try {
|
||||
sqlite.exec(`DROP INDEX IF EXISTS tags_name_unique`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sqlite.exec(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS tags_name_user_unique ON tags(name, user_id)`
|
||||
);
|
||||
|
||||
// Idempotent migrations: add columns that may be missing from older DBs
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE attachments ADD COLUMN thumbnail_path TEXT;`);
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE attachments ADD COLUMN featured INTEGER NOT NULL DEFAULT 0;`);
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Re-enable foreign keys
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { sqlite };
|
||||
|
||||
@@ -9,7 +9,7 @@ import type * as schema from './schema.js';
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
const testDb = createTestDb({ seedUser: true });
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Test Note',
|
||||
content: 'Hello world',
|
||||
createdAt: now,
|
||||
@@ -45,6 +46,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Colored Note',
|
||||
content: '',
|
||||
color: 'coral',
|
||||
@@ -62,16 +64,17 @@ describe('Notes CRUD', () => {
|
||||
it('should read all active notes (not archived, not trashed)', async () => {
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
|
||||
{ id: 'n1', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now },
|
||||
{
|
||||
id: 'n2',
|
||||
userId: 1,
|
||||
title: 'Archived',
|
||||
content: '',
|
||||
archived: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
||||
{ id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const active = await db
|
||||
@@ -89,6 +92,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Original',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -113,6 +117,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'To Trash',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -132,6 +137,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'To Delete',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -149,6 +155,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'To Archive',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -170,6 +177,7 @@ describe('Notes CRUD', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
userId: 1,
|
||||
title: 'Pin Me',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -193,6 +201,7 @@ describe('Notes with Tags', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
userId: 1,
|
||||
title: 'Tagged Note',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
@@ -200,8 +209,8 @@ describe('Notes with Tags', () => {
|
||||
});
|
||||
|
||||
// Create tags
|
||||
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
|
||||
const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
|
||||
const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning();
|
||||
|
||||
// Associate
|
||||
await db.insert(noteTags).values([
|
||||
@@ -226,16 +235,18 @@ describe('Notes with Tags', () => {
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
userId: 1,
|
||||
title: '',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
|
||||
const [tag] = await db.insert(tags).values({ userId: 1, name: 'temp' }).returning();
|
||||
await db.insert(noteTags).values({ noteId, tagId: tag.id });
|
||||
|
||||
// Delete note
|
||||
// Delete note_tags first (FK cascade is disabled in test DB), then note
|
||||
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
|
||||
await db.delete(notes).where(eq(notes.id, noteId));
|
||||
|
||||
// note_tags should be gone
|
||||
@@ -257,6 +268,7 @@ describe('Note ordering', () => {
|
||||
await db.insert(notes).values([
|
||||
{
|
||||
id: 'unpinned',
|
||||
userId: 1,
|
||||
title: 'Unpinned',
|
||||
content: '',
|
||||
pinned: false,
|
||||
@@ -265,6 +277,7 @@ describe('Note ordering', () => {
|
||||
},
|
||||
{
|
||||
id: 'pinned',
|
||||
userId: 1,
|
||||
title: 'Pinned',
|
||||
content: '',
|
||||
pinned: true,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqli
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
email: text('email').notNull().default(''),
|
||||
displayName: text('display_name').notNull().default(''),
|
||||
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
|
||||
passwordHash: text('password_hash'),
|
||||
authProvider: text('auth_provider').notNull().default('password'),
|
||||
providerId: text('provider_id'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
@@ -11,13 +16,21 @@ export const sessions = sqliteTable('sessions', {
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }),
|
||||
userAgent: text('user_agent'),
|
||||
ip: text('ip'),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
|
||||
});
|
||||
|
||||
export const notes = sqliteTable(
|
||||
'notes',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull()
|
||||
.default(0),
|
||||
title: text('title').default('').notNull(),
|
||||
content: text('content').default('').notNull(),
|
||||
color: text('color').default('default').notNull(),
|
||||
@@ -32,6 +45,7 @@ export const notes = sqliteTable(
|
||||
version: integer('version').default(1).notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('notes_user_id_idx').on(table.userId),
|
||||
index('notes_trashed_archived_idx').on(table.trashed, table.archived),
|
||||
index('notes_updated_at_idx').on(table.updatedAt)
|
||||
]
|
||||
@@ -41,9 +55,13 @@ export const tags = sqliteTable(
|
||||
'tags',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull()
|
||||
.default(0),
|
||||
name: text('name').notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
|
||||
(table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)]
|
||||
);
|
||||
|
||||
export const noteTags = sqliteTable(
|
||||
@@ -64,6 +82,10 @@ export const noteTags = sqliteTable(
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull()
|
||||
.default(0),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
@@ -71,13 +93,31 @@ export const attachments = sqliteTable('attachments', {
|
||||
mimeType: text('mime_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
path: text('path').notNull(),
|
||||
thumbnailPath: text('thumbnail_path'),
|
||||
featured: integer('featured', { mode: 'boolean' }).default(false).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable('api_keys', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
name: text('name').notNull(),
|
||||
keyHash: text('key_hash').notNull(),
|
||||
keyPrefix: text('key_prefix').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp' })
|
||||
});
|
||||
|
||||
export const syncLog = sqliteTable(
|
||||
'sync_log',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull()
|
||||
.default(0),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id)
|
||||
.notNull(),
|
||||
@@ -87,3 +127,15 @@ export const syncLog = sqliteTable(
|
||||
},
|
||||
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
|
||||
);
|
||||
|
||||
export const loginAttempts = sqliteTable(
|
||||
'login_attempts',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
ip: text('ip').notNull(),
|
||||
email: text('email').notNull(),
|
||||
success: integer('success', { mode: 'boolean' }).notNull(),
|
||||
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull()
|
||||
},
|
||||
(table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)]
|
||||
);
|
||||
|
||||
@@ -8,18 +8,18 @@ import type * as schema from './schema.js';
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const testDb = createTestDb();
|
||||
const testDb = createTestDb({ seedUser: true });
|
||||
db = testDb.db;
|
||||
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
|
||||
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
|
||||
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
|
||||
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
|
||||
{ id: 'n1', userId: 1, title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
|
||||
{ id: 'n2', userId: 1, title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
|
||||
{ id: 'n3', userId: 1, title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
|
||||
{ id: 'n4', userId: 1, title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
|
||||
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
|
||||
});
|
||||
|
||||
|
||||
@@ -6,27 +6,37 @@ import * as schema from './schema.js';
|
||||
* Create an in-memory SQLite database for testing.
|
||||
* Each test gets a fresh database.
|
||||
*/
|
||||
export function createTestDb() {
|
||||
export function createTestDb(options?: { seedUser?: boolean }) {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.pragma('foreign_keys = OFF');
|
||||
|
||||
// Create tables
|
||||
// Create tables matching the multi-user schema
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
password_hash TEXT NOT NULL,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
password_hash TEXT,
|
||||
auth_provider TEXT NOT NULL DEFAULT 'password',
|
||||
provider_id TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
user_agent TEXT,
|
||||
ip TEXT,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
@@ -40,20 +50,27 @@ export function createTestDb() {
|
||||
updated_at INTEGER NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX notes_user_id_idx ON notes(user_id);
|
||||
CREATE INDEX notes_trashed_archived_idx ON notes(trashed, archived);
|
||||
CREATE INDEX notes_updated_at_idx ON notes(updated_at);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
|
||||
CREATE UNIQUE INDEX tags_name_user_unique ON tags(name, user_id);
|
||||
|
||||
CREATE TABLE note_tags (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
@@ -64,13 +81,45 @@ export function createTestDb() {
|
||||
|
||||
CREATE TABLE sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
note_id TEXT NOT NULL REFERENCES notes(id),
|
||||
operation TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX sync_log_timestamp_idx ON sync_log(timestamp);
|
||||
|
||||
CREATE TABLE login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
if (options?.seedUser) {
|
||||
db.insert(schema.users).values({
|
||||
email: 'test@test.com',
|
||||
displayName: 'Test User',
|
||||
role: 'admin',
|
||||
authProvider: 'password',
|
||||
createdAt: new Date()
|
||||
}).run();
|
||||
}
|
||||
|
||||
return { db, sqlite };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
import {
|
||||
listNotes,
|
||||
getNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
searchNotes,
|
||||
listAllTags,
|
||||
reorderNotes
|
||||
} from '../notes-service.js';
|
||||
import { saveAttachment } from '../attachments.js';
|
||||
|
||||
const NOTE_COLORS = [
|
||||
'default',
|
||||
'coral',
|
||||
'peach',
|
||||
'sand',
|
||||
'mint',
|
||||
'sage',
|
||||
'fog',
|
||||
'storm',
|
||||
'dusk',
|
||||
'blossom',
|
||||
'clay',
|
||||
'chalk'
|
||||
] as const;
|
||||
|
||||
export function createMcpServer(userId: number): McpServer {
|
||||
const server = new McpServer({
|
||||
name: 'crumbs',
|
||||
version: '0.7.2'
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'list_notes',
|
||||
'List notes with optional filter (all, archived, trashed) and tag filter',
|
||||
{
|
||||
filter: z
|
||||
.enum(['all', 'archived', 'trashed'])
|
||||
.optional()
|
||||
.describe('Filter notes by status'),
|
||||
tag: z.string().optional().describe('Filter by tag name')
|
||||
},
|
||||
async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => {
|
||||
let result = listNotes(userId, filter as NoteFilter);
|
||||
|
||||
if (tag) {
|
||||
result = result.filter((n) =>
|
||||
(n.tags ?? []).some((t: string) => t.toLowerCase() === tag.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_note',
|
||||
'Get a single note by ID with its tags and attachments',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const note = getNote(userId, id);
|
||||
if (!note) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'create_note',
|
||||
'Create a new note',
|
||||
{
|
||||
title: z.string().optional().describe('Note title'),
|
||||
content: z.string().optional().describe('Note content (supports markdown)'),
|
||||
color: z.enum(NOTE_COLORS).optional().describe('Note color'),
|
||||
checklistMode: z.boolean().optional().describe('Enable checklist mode')
|
||||
},
|
||||
async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => {
|
||||
const note = createNote(userId, { title, content, color, checklistMode });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_note',
|
||||
'Update an existing note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
title: z.string().optional().describe('New title'),
|
||||
content: z.string().optional().describe('New content'),
|
||||
color: z.enum(NOTE_COLORS).optional().describe('New color'),
|
||||
pinned: z.boolean().optional().describe('Pin/unpin'),
|
||||
checklistMode: z.boolean().optional().describe('Enable/disable checklist mode')
|
||||
},
|
||||
async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => {
|
||||
const result = updateNote(userId, id, updates);
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'trash_note',
|
||||
'Move a note to trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" trashed` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'restore_note',
|
||||
'Restore a note from trash',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { trashed: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" restored` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'archive_note',
|
||||
'Archive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: true });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" archived` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'unarchive_note',
|
||||
'Unarchive a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const result = updateNote(userId, id, { archived: false });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Note "${result.title}" unarchived` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'delete_note',
|
||||
'Permanently delete a note',
|
||||
{ id: z.string().describe('Note ID') },
|
||||
async ({ id }: { id: string }) => {
|
||||
const existing = getNote(userId, id);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
deleteNote(userId, id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${existing.title}" permanently deleted`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'search_notes',
|
||||
'Search notes by title, content, or tag name',
|
||||
{ query: z.string().describe('Search query') },
|
||||
async ({ query }: { query: string }) => {
|
||||
const results = searchNotes(userId, query);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool('list_tags', 'List all tags', {}, async () => {
|
||||
const allTags = listAllTags(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }]
|
||||
};
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'pin_note',
|
||||
'Pin or unpin a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||
},
|
||||
async ({ id, pinned }: { id: string; pinned: boolean }) => {
|
||||
const result = updateNote(userId, id, { pinned });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'reorder_notes',
|
||||
'Reorder notes by setting sort orders',
|
||||
{
|
||||
orders: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe('Note ID'),
|
||||
sortOrder: z.number().describe('New sort order')
|
||||
})
|
||||
)
|
||||
.describe('Array of note IDs with their new sort orders')
|
||||
},
|
||||
async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => {
|
||||
reorderNotes(userId, orders);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'upload_image',
|
||||
'Attach an image to a note by fetching it from a URL',
|
||||
{
|
||||
noteId: z.string().describe('Note ID to attach the image to'),
|
||||
imageUrl: z.string().url().describe('URL of the image to fetch')
|
||||
},
|
||||
async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => {
|
||||
const existing = getNote(userId, noteId);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to fetch image: ${response.status} ${response.statusText}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const ext = contentType.split('/')[1]?.split(';')[0] || 'png';
|
||||
const filename = `upload_${Date.now()}.${ext}`;
|
||||
|
||||
const file = new File([buffer], filename, { type: contentType });
|
||||
const attachment = await saveAttachment(noteId, file, userId);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }]
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Failed to upload image: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { db as defaultDb } from './db/index.js';
|
||||
import { notes, noteTags, tags } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray } 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 type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
const db = defaultDb;
|
||||
|
||||
export function listNotes(userId: number, filter: NoteFilter = 'all') {
|
||||
let conditions;
|
||||
switch (filter) {
|
||||
case 'archived':
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false));
|
||||
break;
|
||||
case 'trashed':
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.trashed, true));
|
||||
break;
|
||||
default:
|
||||
conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false));
|
||||
}
|
||||
|
||||
const result = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(conditions)
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||
.all();
|
||||
|
||||
const noteIds = result.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
return result.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
}
|
||||
|
||||
export function getNote(userId: number, id: string) {
|
||||
const note = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
if (!note) return null;
|
||||
|
||||
const tagMap = fetchTagsForNotes([id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([id]);
|
||||
return {
|
||||
...note,
|
||||
tags: tagMap.get(id) ?? [],
|
||||
attachments: attachmentMap.get(id) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
id?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: string;
|
||||
pinned?: boolean;
|
||||
checklistMode?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export function createNote(userId: number, input: CreateNoteInput) {
|
||||
const now = new Date();
|
||||
const id = input.id || uuidv4();
|
||||
|
||||
const newNote = {
|
||||
id,
|
||||
userId,
|
||||
title: input.title || '',
|
||||
content: input.content || '',
|
||||
color: input.color || 'default',
|
||||
pinned: input.pinned || false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: input.checklistMode || false,
|
||||
sortOrder: input.sortOrder || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1
|
||||
};
|
||||
|
||||
const content = `${newNote.title} ${newNote.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.insert(notes).values(newNote).run();
|
||||
});
|
||||
syncNoteTags(id, extractedTags, userId);
|
||||
|
||||
return { ...newNote, tags: extractedTags };
|
||||
}
|
||||
|
||||
export interface UpdateNoteInput {
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: string;
|
||||
pinned?: boolean;
|
||||
archived?: boolean;
|
||||
trashed?: boolean;
|
||||
checklistMode?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export function updateNote(userId: number, id: string, input: UpdateNoteInput) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
if (!existing) return null;
|
||||
|
||||
const now = new Date();
|
||||
const updates: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
version: existing.version + 1
|
||||
};
|
||||
|
||||
if (input.title !== undefined) updates.title = input.title;
|
||||
if (input.content !== undefined) updates.content = input.content;
|
||||
if (input.color !== undefined) updates.color = input.color;
|
||||
if (input.pinned !== undefined) updates.pinned = input.pinned;
|
||||
if (input.archived !== undefined) updates.archived = input.archived;
|
||||
if (input.trashed !== undefined) {
|
||||
updates.trashed = input.trashed;
|
||||
updates.trashedAt = input.trashed ? now : null;
|
||||
}
|
||||
if (input.checklistMode !== undefined) updates.checklistMode = input.checklistMode;
|
||||
if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder;
|
||||
|
||||
db.update(notes)
|
||||
.set(updates)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
|
||||
const updated = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (input.title !== undefined || input.content !== undefined) {
|
||||
const content = `${updated!.title} ${updated!.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
syncNoteTags(id, extractedTags, userId);
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([id]);
|
||||
return {
|
||||
...updated,
|
||||
tags: tagMap.get(id) ?? [],
|
||||
attachments: attachmentMap.get(id) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteNote(userId: number, id: string): boolean {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.get();
|
||||
if (!existing) return false;
|
||||
|
||||
db.delete(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function searchNotes(userId: number, query: string) {
|
||||
if (!query) return [];
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
const results = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.userId, userId),
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
)
|
||||
.all();
|
||||
|
||||
const tagResults = db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(and(like(tags.name, pattern), eq(tags.userId, userId)))
|
||||
.all();
|
||||
|
||||
const resultIds = new Set(results.map((n) => n.id));
|
||||
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
|
||||
(id) => !resultIds.has(id)
|
||||
);
|
||||
|
||||
let extraNotes: typeof results = [];
|
||||
if (extraNoteIds.length > 0) {
|
||||
extraNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
|
||||
.all();
|
||||
}
|
||||
|
||||
const combined = [...results, ...extraNotes];
|
||||
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
|
||||
return combined.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
}
|
||||
|
||||
export function listAllTags(userId: number) {
|
||||
return db.select().from(tags).where(eq(tags.userId, userId)).all();
|
||||
}
|
||||
|
||||
export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) {
|
||||
const now = new Date();
|
||||
for (const { id, sortOrder } of orders) {
|
||||
db.update(notes)
|
||||
.set({ sortOrder, updatedAt: now })
|
||||
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as arctic from 'arctic';
|
||||
import { getGithubClient } from './providers.js';
|
||||
|
||||
export function createGithubAuthUrl(): { url: URL; state: string } | null {
|
||||
const github = getGithubClient();
|
||||
if (!github) return null;
|
||||
|
||||
const state = arctic.generateState();
|
||||
const scopes = ['user:email'];
|
||||
const url = github.createAuthorizationURL(state, scopes);
|
||||
|
||||
return { url, state };
|
||||
}
|
||||
|
||||
export async function validateGithubCallback(
|
||||
code: string
|
||||
): Promise<{ email: string; name: string; providerId: string } | null> {
|
||||
const github = getGithubClient();
|
||||
if (!github) return null;
|
||||
|
||||
const tokens = await github.validateAuthorizationCode(code);
|
||||
const accessToken = tokens.accessToken();
|
||||
|
||||
// Fetch user profile
|
||||
const userRes = await fetch('https://api.github.com/user', {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
|
||||
});
|
||||
if (!userRes.ok) return null;
|
||||
const user = (await userRes.json()) as { id: number; login: string; name?: string; email?: string };
|
||||
|
||||
// GitHub may not return email in profile — fetch from emails endpoint
|
||||
let email = user.email;
|
||||
if (!email) {
|
||||
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
|
||||
});
|
||||
if (emailsRes.ok) {
|
||||
const emails = (await emailsRes.json()) as { email: string; primary: boolean; verified: boolean }[];
|
||||
const primary = emails.find((e) => e.primary && e.verified);
|
||||
email = primary?.email || emails.find((e) => e.verified)?.email || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!email) return null;
|
||||
|
||||
return {
|
||||
email,
|
||||
name: user.name || user.login,
|
||||
providerId: String(user.id)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as arctic from 'arctic';
|
||||
import { getGoogleClient } from './providers.js';
|
||||
|
||||
export function createGoogleAuthUrl(): { url: URL; state: string; codeVerifier: string } | null {
|
||||
const google = getGoogleClient();
|
||||
if (!google) return null;
|
||||
|
||||
const state = arctic.generateState();
|
||||
const codeVerifier = arctic.generateCodeVerifier();
|
||||
const scopes = ['openid', 'profile', 'email'];
|
||||
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
|
||||
|
||||
return { url, state, codeVerifier };
|
||||
}
|
||||
|
||||
export async function validateGoogleCallback(
|
||||
code: string,
|
||||
codeVerifier: string
|
||||
): Promise<{ email: string; name: string; providerId: string } | null> {
|
||||
const google = getGoogleClient();
|
||||
if (!google) return null;
|
||||
|
||||
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
|
||||
const idToken = tokens.idToken();
|
||||
const claims = arctic.decodeIdToken(idToken) as {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (!claims.email) return null;
|
||||
|
||||
return {
|
||||
email: claims.email,
|
||||
name: claims.name || claims.email.split('@')[0],
|
||||
providerId: claims.sub
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as arctic from 'arctic';
|
||||
|
||||
let oidcClient: arctic.OAuth2Client | null = null;
|
||||
let oidcConfig: {
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userinfoEndpoint: string;
|
||||
} | null = null;
|
||||
|
||||
function getOidcEnv() {
|
||||
return {
|
||||
issuer: process.env.AUTH_OIDC_ISSUER || '',
|
||||
clientId: process.env.AUTH_OIDC_CLIENT_ID || '',
|
||||
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET || ''
|
||||
};
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
|
||||
}
|
||||
|
||||
async function ensureOidcClient(): Promise<arctic.OAuth2Client | null> {
|
||||
const env = getOidcEnv();
|
||||
if (!env.issuer || !env.clientId || !env.clientSecret) return null;
|
||||
if (oidcClient && oidcConfig) return oidcClient;
|
||||
|
||||
// Discover OIDC endpoints
|
||||
const discoveryUrl = env.issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
|
||||
const res = await fetch(discoveryUrl);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const config = (await res.json()) as {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
};
|
||||
|
||||
oidcConfig = {
|
||||
authorizationEndpoint: config.authorization_endpoint,
|
||||
tokenEndpoint: config.token_endpoint,
|
||||
userinfoEndpoint: config.userinfo_endpoint
|
||||
};
|
||||
|
||||
const redirectURI = `${getBaseUrl()}/api/auth/oauth/oidc/callback`;
|
||||
oidcClient = new arctic.OAuth2Client(env.clientId, env.clientSecret, redirectURI);
|
||||
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
export async function createOidcAuthUrl(): Promise<{ url: URL; state: string; codeVerifier: string } | null> {
|
||||
const client = await ensureOidcClient();
|
||||
if (!client || !oidcConfig) return null;
|
||||
|
||||
const state = arctic.generateState();
|
||||
const codeVerifier = arctic.generateCodeVerifier();
|
||||
const scopes = ['openid', 'profile', 'email'];
|
||||
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
oidcConfig.authorizationEndpoint,
|
||||
state,
|
||||
arctic.CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
scopes
|
||||
);
|
||||
|
||||
return { url, state, codeVerifier };
|
||||
}
|
||||
|
||||
export async function validateOidcCallback(
|
||||
code: string,
|
||||
codeVerifier: string
|
||||
): Promise<{ email: string; name: string; providerId: string } | null> {
|
||||
const client = await ensureOidcClient();
|
||||
if (!client || !oidcConfig) return null;
|
||||
|
||||
const tokens = await client.validateAuthorizationCode(
|
||||
oidcConfig.tokenEndpoint,
|
||||
code,
|
||||
codeVerifier
|
||||
);
|
||||
|
||||
// Try to decode ID token first
|
||||
const idToken = tokens.idToken();
|
||||
if (idToken) {
|
||||
const claims = arctic.decodeIdToken(idToken) as {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
};
|
||||
if (claims.email) {
|
||||
return {
|
||||
email: claims.email,
|
||||
name: claims.name || claims.email.split('@')[0],
|
||||
providerId: claims.sub
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to userinfo endpoint
|
||||
const accessToken = tokens.accessToken();
|
||||
const userRes = await fetch(oidcConfig.userinfoEndpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
if (!userRes.ok) return null;
|
||||
|
||||
const userInfo = (await userRes.json()) as {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (!userInfo.email) return null;
|
||||
|
||||
return {
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email.split('@')[0],
|
||||
providerId: userInfo.sub
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as arctic from 'arctic';
|
||||
|
||||
export type OAuthProviderInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
let googleClient: arctic.Google | null = null;
|
||||
let githubClient: arctic.GitHub | null = null;
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return process.env.ORIGIN || process.env.AUTH_REDIRECT_BASE || 'http://localhost:5173';
|
||||
}
|
||||
|
||||
export function getGoogleClient(): arctic.Google | null {
|
||||
if (googleClient) return googleClient;
|
||||
const clientId = process.env.AUTH_GOOGLE_CLIENT_ID;
|
||||
const clientSecret = process.env.AUTH_GOOGLE_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
googleClient = new arctic.Google(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/google/callback`);
|
||||
return googleClient;
|
||||
}
|
||||
|
||||
export function getGithubClient(): arctic.GitHub | null {
|
||||
if (githubClient) return githubClient;
|
||||
const clientId = process.env.AUTH_GITHUB_CLIENT_ID;
|
||||
const clientSecret = process.env.AUTH_GITHUB_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) return null;
|
||||
githubClient = new arctic.GitHub(clientId, clientSecret, `${getBaseUrl()}/api/auth/oauth/github/callback`);
|
||||
return githubClient;
|
||||
}
|
||||
|
||||
export function getEnabledProviders(): OAuthProviderInfo[] {
|
||||
const providers: OAuthProviderInfo[] = [];
|
||||
if (getGoogleClient()) providers.push({ id: 'google', name: 'Google' });
|
||||
if (getGithubClient()) providers.push({ id: 'github', name: 'GitHub' });
|
||||
|
||||
const oidcIssuer = process.env.AUTH_OIDC_ISSUER;
|
||||
const oidcClientId = process.env.AUTH_OIDC_CLIENT_ID;
|
||||
const oidcClientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
|
||||
if (oidcIssuer && oidcClientId && oidcClientSecret) {
|
||||
providers.push({
|
||||
id: 'oidc',
|
||||
name: process.env.AUTH_OIDC_DISPLAY_NAME || 'SSO'
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { db } from './db/index.js';
|
||||
import { loginAttempts } from './db/schema.js';
|
||||
import { and, eq, gte, sql } from 'drizzle-orm';
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
email: string
|
||||
): { allowed: boolean; retryAfter?: number } {
|
||||
// Skip rate limiting in test environment
|
||||
if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const windowStart = new Date(Date.now() - LOCKOUT_MS);
|
||||
|
||||
// Count failed attempts from this IP in the lockout window
|
||||
const result = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(loginAttempts)
|
||||
.where(
|
||||
and(
|
||||
eq(loginAttempts.ip, ip),
|
||||
eq(loginAttempts.success, false),
|
||||
gte(loginAttempts.timestamp, windowStart)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
const failedCount = result?.count ?? 0;
|
||||
|
||||
if (failedCount >= MAX_ATTEMPTS) {
|
||||
// Find the most recent failed attempt to calculate retry-after
|
||||
const latest = db
|
||||
.select({ timestamp: loginAttempts.timestamp })
|
||||
.from(loginAttempts)
|
||||
.where(
|
||||
and(
|
||||
eq(loginAttempts.ip, ip),
|
||||
eq(loginAttempts.success, false),
|
||||
gte(loginAttempts.timestamp, windowStart)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`${loginAttempts.timestamp} DESC`)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (latest) {
|
||||
const retryAfter = Math.ceil(
|
||||
(latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000
|
||||
);
|
||||
return { allowed: false, retryAfter: Math.max(retryAfter, 1) };
|
||||
}
|
||||
return { allowed: false, retryAfter: 900 };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
export function recordLoginAttempt(ip: string, email: string, success: boolean): void {
|
||||
db.insert(loginAttempts)
|
||||
.values({
|
||||
ip,
|
||||
email,
|
||||
success,
|
||||
timestamp: new Date()
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function clearOldAttempts(): void {
|
||||
const cutoff = new Date(Date.now() - LOCKOUT_MS * 2);
|
||||
db.delete(loginAttempts)
|
||||
.where(sql`${loginAttempts.timestamp} < ${cutoff}`)
|
||||
.run();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from './db/index.js';
|
||||
import { noteTags, tags } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Batch fetch tags for multiple notes in a single query.
|
||||
@@ -32,13 +32,17 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
||||
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
|
||||
* Should be called within a transaction for atomicity.
|
||||
*/
|
||||
export function syncNoteTags(noteId: string, tagNames: string[]) {
|
||||
export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
|
||||
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
|
||||
|
||||
for (const name of tagNames) {
|
||||
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get();
|
||||
let tagRow = db
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(and(eq(tags.name, name), eq(tags.userId, userId)))
|
||||
.get();
|
||||
if (!tagRow) {
|
||||
const result = db.insert(tags).values({ name }).returning().all();
|
||||
const result = db.insert(tags).values({ name, userId }).returning().all();
|
||||
tagRow = result[0];
|
||||
}
|
||||
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
|
||||
|
||||
+188
-36
@@ -1,9 +1,14 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
|
||||
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
export const selectedTag = writable<string | null>(null);
|
||||
export const notesLoaded = writable(false);
|
||||
|
||||
export const filteredNotes = derived(
|
||||
[notes, selectedTag, currentFilter],
|
||||
@@ -23,12 +28,25 @@ export const filteredNotes = derived(
|
||||
}
|
||||
);
|
||||
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => n.pinned)
|
||||
function sortNotes(list: Note[], mode: SortMode): Note[] {
|
||||
return [...list].sort((a, b) => {
|
||||
if (mode === 'created') {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
if (mode === 'custom') {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
// default: updated
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export const pinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => n.pinned), $sortMode)
|
||||
);
|
||||
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => !n.pinned)
|
||||
export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned), $sortMode)
|
||||
);
|
||||
|
||||
export const allTags = derived(notes, ($notes) => {
|
||||
@@ -37,54 +55,164 @@ export const allTags = derived(notes, ($notes) => {
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
function isNetworkError(err: unknown): boolean {
|
||||
return err instanceof TypeError;
|
||||
}
|
||||
|
||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notes.set(data);
|
||||
currentFilter.set(filter);
|
||||
// Load from IDB first for instant display
|
||||
try {
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.set(cached);
|
||||
}
|
||||
} catch {
|
||||
// IDB unavailable — continue with server fetch
|
||||
}
|
||||
currentFilter.set(filter);
|
||||
notesLoaded.set(true);
|
||||
|
||||
// Then fetch from server in the background
|
||||
try {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data: Note[] = await res.json();
|
||||
notes.set(data);
|
||||
currentFilter.set(filter);
|
||||
|
||||
// Update IDB with server state
|
||||
try {
|
||||
await clearIdbNotes();
|
||||
await Promise.all(data.map((n) => putNote(n)));
|
||||
} catch {
|
||||
// IDB write failed — server data is still in the store
|
||||
}
|
||||
} else if (res.status === 401) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
showToast('Failed to load notes', 'error');
|
||||
}
|
||||
} catch {
|
||||
// Offline — IDB data already loaded above
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNote(note: NoteCreate): Promise<Note | null> {
|
||||
const res = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(note)
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
notes.update((list) => [created, ...list]);
|
||||
return created;
|
||||
try {
|
||||
const res = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(note)
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: Note = await res.json();
|
||||
notes.update((list) => [created, ...list]);
|
||||
await putNote(created);
|
||||
return created;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
showToast('Failed to save note', 'error');
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
const now = new Date();
|
||||
const title = note.title ?? '';
|
||||
const content = note.content ?? '';
|
||||
const optimistic: Note = {
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
content,
|
||||
color: note.color ?? 'default',
|
||||
pinned: note.pinned ?? false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: note.checklistMode ?? false,
|
||||
sortOrder: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
tags: extractTags(`${title} ${content}`)
|
||||
};
|
||||
notes.update((list) => [optimistic, ...list]);
|
||||
await putNote(optimistic);
|
||||
await addToSyncQueue({ noteId: optimistic.id, operation: 'create', data: optimistic, timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
return optimistic;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
||||
const res = await fetch(`/api/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
|
||||
return updated;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: Note = await res.json();
|
||||
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
|
||||
await putNote(updated);
|
||||
return updated;
|
||||
}
|
||||
showToast('Failed to save note', 'error');
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
let optimistic: Note | null = null;
|
||||
notes.update((list) =>
|
||||
list.map((n) => {
|
||||
if (n.id === id) {
|
||||
optimistic = { ...n, ...updates, updatedAt: new Date() };
|
||||
if (updates.title !== undefined || updates.content !== undefined) {
|
||||
optimistic.tags = extractTags(`${optimistic.title} ${optimistic.content}`);
|
||||
}
|
||||
return optimistic;
|
||||
}
|
||||
return n;
|
||||
})
|
||||
);
|
||||
if (optimistic) {
|
||||
await putNote(optimistic);
|
||||
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
}
|
||||
return optimistic;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function deleteNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
return true;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function trashNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { trashed: true });
|
||||
const result = await updateNote(id, { trashed: true });
|
||||
if (result) notes.update((list) => list.filter((n) => n.id !== id));
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function restoreNote(id: string): Promise<Note | null> {
|
||||
@@ -92,7 +220,9 @@ export async function restoreNote(id: string): Promise<Note | null> {
|
||||
}
|
||||
|
||||
export async function archiveNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { archived: true });
|
||||
const result = await updateNote(id, { archived: true });
|
||||
if (result) notes.update((list) => list.filter((n) => n.id !== id));
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
@@ -102,3 +232,25 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
||||
// Optimistic update
|
||||
notes.update((list) =>
|
||||
list.map((n) => {
|
||||
const order = orders.find((o) => o.id === n.id);
|
||||
return order ? { ...n, sortOrder: order.sortOrder } : n;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/notes/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
// Offline — optimistic update stands
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type SortMode = 'updated' | 'created' | 'custom';
|
||||
|
||||
const STORAGE_KEY = 'crumbs-sort-mode';
|
||||
|
||||
function getInitial(): SortMode {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'updated' || stored === 'created' || stored === 'custom') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
export const sortMode = writable<SortMode>(getInitial());
|
||||
|
||||
if (browser) {
|
||||
sortMode.subscribe((value) => {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: 'info' | 'success' | 'error';
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
export const toasts = writable<ToastMessage[]>([]);
|
||||
|
||||
export function showToast(
|
||||
text: string,
|
||||
type: 'info' | 'success' | 'error' = 'info',
|
||||
action?: { label: string; handler: () => void },
|
||||
duration = 4000
|
||||
) {
|
||||
const id = ++counter;
|
||||
toasts.update((t) => [...t, { id, text, type, action }]);
|
||||
if (!action) {
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
toasts.update((t) => t.filter((toast) => toast.id !== id));
|
||||
}
|
||||
+60
-14
@@ -1,7 +1,9 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta, getPendingAttachments, removePendingAttachment } from './idb.js';
|
||||
import { mergeNotes } from './crdt.js';
|
||||
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
||||
|
||||
@@ -27,7 +29,12 @@ export function getSyncStatus(): SyncStatus {
|
||||
* Push local changes to server.
|
||||
*/
|
||||
async function pushChanges(): Promise<boolean> {
|
||||
const queue = await getSyncQueue();
|
||||
let queue;
|
||||
try {
|
||||
queue = await getSyncQueue();
|
||||
} catch {
|
||||
return true; // IDB unavailable — nothing to push
|
||||
}
|
||||
if (queue.length === 0) return true;
|
||||
|
||||
try {
|
||||
@@ -47,31 +54,66 @@ async function pushChanges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push pending offline attachments to server.
|
||||
*/
|
||||
async function pushPendingAttachments(): Promise<void> {
|
||||
const pending = await getPendingAttachments();
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([item.optimized], item.filename, { type: item.mimeType }));
|
||||
formData.append('thumbnail', new File([item.thumbnail], `${item.filename}_thumb.webp`, { type: 'image/webp' }));
|
||||
|
||||
const res = await fetch(`/api/notes/${item.noteId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await removePendingAttachment(item.id);
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next sync cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull remote changes and merge with local state.
|
||||
*/
|
||||
async function pullChanges(): Promise<Note[]> {
|
||||
const lastSync = (await getMeta('lastSyncTimestamp')) || '0';
|
||||
let lastSync = '0';
|
||||
try {
|
||||
lastSync = (await getMeta('lastSyncTimestamp')) || '0';
|
||||
} catch {
|
||||
// IDB unavailable — pull everything
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sync?since=${lastSync}`);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const remoteNotes: Note[] = await res.json();
|
||||
const localNotes = await getAllNotes();
|
||||
const localMap = new Map(localNotes.map((n) => [n.id, n]));
|
||||
|
||||
for (const remote of remoteNotes) {
|
||||
const local = localMap.get(remote.id);
|
||||
if (local) {
|
||||
const merged = mergeNotes(local, remote);
|
||||
await putNote(merged);
|
||||
} else {
|
||||
await putNote(remote);
|
||||
try {
|
||||
const localNotes = await getAllNotes();
|
||||
const localMap = new Map(localNotes.map((n) => [n.id, n]));
|
||||
|
||||
for (const remote of remoteNotes) {
|
||||
const local = localMap.get(remote.id);
|
||||
if (local) {
|
||||
const merged = mergeNotes(local, remote);
|
||||
await putNote(merged);
|
||||
} else {
|
||||
await putNote(remote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await setMeta('lastSyncTimestamp', String(Date.now()));
|
||||
await setMeta('lastSyncTimestamp', String(Date.now()));
|
||||
} catch {
|
||||
// IDB write failed — server data still returned for store use
|
||||
}
|
||||
return remoteNotes;
|
||||
} catch {
|
||||
return [];
|
||||
@@ -96,8 +138,12 @@ export async function sync(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
await pushPendingAttachments();
|
||||
await pullChanges();
|
||||
setStatus('synced');
|
||||
|
||||
// Reload notes from server and persist to IDB
|
||||
await loadNotes(get(currentFilter));
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
|
||||
+74
-4
@@ -1,8 +1,18 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import { openDB, deleteDB, type IDBPDatabase } from 'idb';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
const DB_NAME = 'crumbs';
|
||||
const DB_VERSION = 1;
|
||||
const DB_PREFIX = 'crumbs';
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
noteId: string;
|
||||
optimized: Blob;
|
||||
thumbnail: Blob;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CrumbsDB {
|
||||
notes: {
|
||||
@@ -23,6 +33,13 @@ interface CrumbsDB {
|
||||
key: string;
|
||||
value: { key: string; value: string };
|
||||
};
|
||||
pendingAttachments: {
|
||||
key: string;
|
||||
value: PendingAttachment;
|
||||
indexes: {
|
||||
'by-noteId': string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
@@ -34,10 +51,23 @@ export interface SyncQueueItem {
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
|
||||
let currentUserId: number | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the IDB for a specific user. Must be called before any
|
||||
* other IDB operation (typically after login when userId is known).
|
||||
*/
|
||||
export function initDb(userId: number): void {
|
||||
if (currentUserId !== userId) {
|
||||
dbPromise = null;
|
||||
currentUserId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<CrumbsDB>(DB_NAME, DB_VERSION, {
|
||||
const dbName = currentUserId ? `${DB_PREFIX}-${currentUserId}` : DB_PREFIX;
|
||||
dbPromise = openDB<CrumbsDB>(dbName, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Notes store
|
||||
if (!db.objectStoreNames.contains('notes')) {
|
||||
@@ -58,12 +88,33 @@ export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
db.createObjectStore('meta', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
// Pending attachments (offline uploads)
|
||||
if (!db.objectStoreNames.contains('pendingAttachments')) {
|
||||
const pendingStore = db.createObjectStore('pendingAttachments', { keyPath: 'id' });
|
||||
pendingStore.createIndex('by-noteId', 'noteId');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current user's IDB (call on logout).
|
||||
*/
|
||||
export async function destroyDb(): Promise<void> {
|
||||
if (dbPromise) {
|
||||
const db = await dbPromise;
|
||||
db.close();
|
||||
dbPromise = null;
|
||||
}
|
||||
if (currentUserId) {
|
||||
await deleteDB(`${DB_PREFIX}-${currentUserId}`);
|
||||
currentUserId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes operations
|
||||
export async function getAllNotes(): Promise<Note[]> {
|
||||
const db = await getDb();
|
||||
@@ -122,3 +173,22 @@ export async function setMeta(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('meta', { key, value });
|
||||
}
|
||||
|
||||
// Pending attachment operations
|
||||
export async function addPendingAttachment(item: PendingAttachment): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('pendingAttachments', item);
|
||||
}
|
||||
|
||||
export async function getPendingAttachments(noteId?: string): Promise<PendingAttachment[]> {
|
||||
const db = await getDb();
|
||||
if (noteId) {
|
||||
return db.getAllFromIndex('pendingAttachments', 'by-noteId', noteId);
|
||||
}
|
||||
return db.getAll('pendingAttachments');
|
||||
}
|
||||
|
||||
export async function removePendingAttachment(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('pendingAttachments', id);
|
||||
}
|
||||
|
||||
+26
-8
@@ -1,12 +1,16 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt } from 'drizzle-orm';
|
||||
import { eq, gt, and } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise<void> {
|
||||
const noteIdsToSyncTags: string[] = [];
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const change of changes) {
|
||||
switch (change.operation) {
|
||||
@@ -16,7 +20,7 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
@@ -27,13 +31,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: existing.version + 1
|
||||
})
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.where(and(eq(notes.id, change.noteId), eq(notes.userId, userId)))
|
||||
.run();
|
||||
}
|
||||
} else if (change.operation === 'create' && change.data) {
|
||||
tx.insert(notes)
|
||||
.values({
|
||||
id: change.noteId,
|
||||
userId,
|
||||
title: change.data.title || '',
|
||||
content: change.data.content || '',
|
||||
color: change.data.color || 'default',
|
||||
@@ -48,16 +53,21 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
if (change.data.title !== undefined || change.data.content !== undefined) {
|
||||
noteIdsToSyncTags.push(change.noteId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
tx.delete(notes).where(eq(notes.id, change.noteId)).run();
|
||||
tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tx.insert(syncLog)
|
||||
.values({
|
||||
userId,
|
||||
noteId: change.noteId,
|
||||
operation: change.operation,
|
||||
timestamp: new Date(change.timestamp),
|
||||
@@ -66,15 +76,23 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
for (const noteId of noteIdsToSyncTags) {
|
||||
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
|
||||
if (note) {
|
||||
const content = `${note.title} ${note.content}`;
|
||||
syncNoteTags(noteId, extractTags(content), userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notes updated since a given timestamp.
|
||||
* Get all notes updated since a given timestamp for a specific user.
|
||||
*/
|
||||
export async function getChangesSince(sinceTimestamp: number) {
|
||||
export async function getChangesSince(sinceTimestamp: number, userId: number) {
|
||||
return db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(gt(notes.updatedAt, new Date(sinceTimestamp)))
|
||||
.where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp))))
|
||||
.all();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface Attachment {
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
thumbnailPath?: string | null;
|
||||
featured: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,16 @@ export interface Tag {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: 'admin' | 'user';
|
||||
authProvider: string;
|
||||
providerId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SyncChange {
|
||||
noteId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const MAX_DIMENSION = 1920;
|
||||
const THUMB_SIZE = 200;
|
||||
const QUALITY = 0.8;
|
||||
const THUMB_QUALITY = 0.6;
|
||||
const SKIP_THRESHOLD = 200 * 1024; // 200KB
|
||||
|
||||
interface OptimizeResult {
|
||||
optimized: Blob;
|
||||
thumbnail: Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PNG has transparency by examining the alpha channel.
|
||||
* Reads a sample of pixels to determine if any are non-opaque.
|
||||
*/
|
||||
function hasTransparency(canvas: HTMLCanvasElement | OffscreenCanvas, ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
// Check every 4th value (alpha channel) with stride for performance
|
||||
const stride = Math.max(1, Math.floor(data.length / (4 * 10000))) * 4;
|
||||
for (let i = 3; i < data.length; i += stride) {
|
||||
if (data[i] < 255) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createCanvas(width: number, height: number): { canvas: HTMLCanvasElement | OffscreenCanvas; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D } {
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas, type: string, quality: number): Promise<Blob> {
|
||||
if (canvas instanceof OffscreenCanvas) {
|
||||
return canvas.convertToBlob({ type, quality });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob!), type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fitDimensions(width: number, height: number, maxDim: number): { width: number; height: number } {
|
||||
if (width <= maxDim && height <= maxDim) return { width, height };
|
||||
const ratio = Math.min(maxDim / width, maxDim / height);
|
||||
return {
|
||||
width: Math.round(width * ratio),
|
||||
height: Math.round(height * ratio)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize an image file for upload:
|
||||
* - Resize to fit within 1920x1920 (preserve aspect ratio)
|
||||
* - Skip optimization if already < 200KB and within size limits
|
||||
* - Compress JPEG/WebP to ~80% quality
|
||||
* - Convert non-transparent PNG/BMP to WebP; keep transparent PNG as PNG
|
||||
* - Pass GIF/SVG through unmodified
|
||||
* - Generate a 200x200 thumbnail at 60% quality WebP
|
||||
*/
|
||||
export async function optimizeImage(file: File): Promise<OptimizeResult> {
|
||||
const type = file.type;
|
||||
|
||||
// GIF and SVG pass through unmodified (but still generate thumbnail for GIF)
|
||||
if (type === 'image/svg+xml') {
|
||||
return { optimized: file, thumbnail: file };
|
||||
}
|
||||
|
||||
const img = await loadImage(file);
|
||||
const { width, height } = img;
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbDims = fitDimensions(width, height, THUMB_SIZE);
|
||||
const { canvas: thumbCanvas, ctx: thumbCtx } = createCanvas(thumbDims.width, thumbDims.height);
|
||||
thumbCtx.drawImage(img, 0, 0, thumbDims.width, thumbDims.height);
|
||||
const thumbnail = await canvasToBlob(thumbCanvas, 'image/webp', THUMB_QUALITY);
|
||||
|
||||
// GIF: pass original through but use generated thumbnail
|
||||
if (type === 'image/gif') {
|
||||
return { optimized: file, thumbnail };
|
||||
}
|
||||
|
||||
// Check if optimization can be skipped
|
||||
const fittedDims = fitDimensions(width, height, MAX_DIMENSION);
|
||||
const needsResize = fittedDims.width !== width || fittedDims.height !== height;
|
||||
|
||||
if (!needsResize && file.size < SKIP_THRESHOLD) {
|
||||
return { optimized: file, thumbnail };
|
||||
}
|
||||
|
||||
// Draw at target size
|
||||
const { canvas, ctx } = createCanvas(fittedDims.width, fittedDims.height);
|
||||
ctx.drawImage(img, 0, 0, fittedDims.width, fittedDims.height);
|
||||
|
||||
// Determine output format
|
||||
let outputType = 'image/webp';
|
||||
let quality = QUALITY;
|
||||
|
||||
if (type === 'image/png' && hasTransparency(canvas, ctx, fittedDims.width, fittedDims.height)) {
|
||||
outputType = 'image/png';
|
||||
quality = 1; // PNG is lossless, quality param ignored but set for clarity
|
||||
}
|
||||
|
||||
const optimized = await canvasToBlob(canvas, outputType, quality);
|
||||
return { optimized, thumbnail };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { version } from '../../../package.json';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = (event) => {
|
||||
return {
|
||||
appVersion: version,
|
||||
user: event.locals.user
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import Header from '$lib/components/Layout/Header.svelte';
|
||||
import Sidebar from '$lib/components/Layout/Sidebar.svelte';
|
||||
import Toast from '$lib/components/Layout/Toast.svelte';
|
||||
import { loadNotes } from '$lib/stores/notes.js';
|
||||
import { startSync, stopSync } from '$lib/sync/client.js';
|
||||
import { initDb } from '$lib/sync/idb.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
||||
if (data.user) {
|
||||
initDb(data.user.id);
|
||||
}
|
||||
loadNotes();
|
||||
startSync();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopSync();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} appVersion={data.appVersion} />
|
||||
|
||||
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="all" />
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Archive - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="archived" />
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { User, Cpu, ShieldCheck } from 'lucide-svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let pathname = $derived(page.url.pathname);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 class="mb-6 text-2xl font-bold text-[var(--text)]">Settings</h1>
|
||||
|
||||
<div class="flex flex-col gap-6 md:flex-row">
|
||||
<!-- Sidebar nav -->
|
||||
<nav class="w-full shrink-0 md:w-48">
|
||||
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
|
||||
<li>
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<User size={16} />
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/settings/mcp"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Cpu size={16} />
|
||||
MCP Server
|
||||
</a>
|
||||
</li>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<li>
|
||||
<a
|
||||
href="/settings/users"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/users' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="min-w-0 flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script lang="ts">
|
||||
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
|
||||
|
||||
type SessionInfo = {
|
||||
id: string;
|
||||
createdAt: string | null;
|
||||
userAgent: string | null;
|
||||
ip: string | null;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// Profile state
|
||||
// svelte-ignore state_referenced_locally
|
||||
let displayName = $state(data.user?.displayName || '');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let email = $state(data.user?.email || '');
|
||||
let profileMsg = $state('');
|
||||
let profileError = $state(false);
|
||||
|
||||
// Password state
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmNewPassword = $state('');
|
||||
let passwordMsg = $state('');
|
||||
let passwordError = $state(false);
|
||||
|
||||
// Sessions state
|
||||
let activeSessions = $state<SessionInfo[]>([]);
|
||||
let sessionsLoaded = $state(false);
|
||||
|
||||
// Delete account state
|
||||
let deletePassword = $state('');
|
||||
let deleteMsg = $state('');
|
||||
let deleteError = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
async function loadSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/sessions');
|
||||
if (res.ok) activeSessions = await res.json();
|
||||
sessionsLoaded = true;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSession(sessionId: string) {
|
||||
try {
|
||||
await fetch('/api/auth/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId })
|
||||
});
|
||||
activeSessions = activeSessions.filter((s) => s.id !== sessionId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeAllOtherSessions() {
|
||||
try {
|
||||
await fetch('/api/auth/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ all: true })
|
||||
});
|
||||
activeSessions = activeSessions.filter((s) => s.isCurrent);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function formatAgent(ua: string | null): string {
|
||||
if (!ua) return 'Unknown';
|
||||
if (ua.includes('Firefox')) return 'Firefox';
|
||||
if (ua.includes('Edg/')) return 'Edge';
|
||||
if (ua.includes('Chrome')) return 'Chrome';
|
||||
if (ua.includes('Safari')) return 'Safari';
|
||||
return ua.slice(0, 40);
|
||||
}
|
||||
|
||||
function formatDate(d: string | null): string {
|
||||
if (!d) return 'Unknown';
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
deleteMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/account', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: deletePassword })
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
const d = await res.json();
|
||||
deleteMsg = d.message || 'Failed to delete account';
|
||||
deleteError = true;
|
||||
}
|
||||
} catch {
|
||||
deleteMsg = 'Connection error';
|
||||
deleteError = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
profileMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName, email })
|
||||
});
|
||||
if (res.ok) {
|
||||
profileMsg = 'Profile updated';
|
||||
profileError = false;
|
||||
} else {
|
||||
const d = await res.json();
|
||||
profileMsg = d.message || 'Failed to update';
|
||||
profileError = true;
|
||||
}
|
||||
} catch {
|
||||
profileMsg = 'Connection error';
|
||||
profileError = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
passwordMsg = '';
|
||||
if (newPassword.length < 8) {
|
||||
passwordMsg = 'Password must be at least 8 characters';
|
||||
passwordError = true;
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
passwordMsg = 'Passwords do not match';
|
||||
passwordError = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/auth/password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword })
|
||||
});
|
||||
if (res.ok) {
|
||||
passwordMsg = 'Password changed';
|
||||
passwordError = false;
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmNewPassword = '';
|
||||
} else {
|
||||
const d = await res.json();
|
||||
passwordMsg = d.message || 'Failed to change password';
|
||||
passwordError = true;
|
||||
}
|
||||
} catch {
|
||||
passwordMsg = 'Connection error';
|
||||
passwordError = true;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadSessions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Profile -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Profile</h2>
|
||||
|
||||
{#if profileMsg}
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {profileError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{profileMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="display-name" class="mb-1 block text-sm font-medium text-[var(--text)]">Display Name</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-[var(--text)]">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={saveProfile}
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Save profile
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Change Password -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Change Password</h2>
|
||||
|
||||
{#if passwordMsg}
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {passwordError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{passwordMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
placeholder="Current password"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="New password (min 8 characters)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmNewPassword}
|
||||
placeholder="Confirm new password"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<button
|
||||
onclick={changePassword}
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">Active Sessions</h2>
|
||||
{#if activeSessions.length > 1}
|
||||
<button
|
||||
onclick={revokeAllOtherSessions}
|
||||
class="text-xs text-[var(--destructive)] hover:underline"
|
||||
>
|
||||
Log out everywhere else
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !sessionsLoaded}
|
||||
<p class="text-sm text-[var(--text-muted)]">Loading sessions...</p>
|
||||
{:else if activeSessions.length === 0}
|
||||
<p class="text-sm text-[var(--text-muted)]">No active sessions</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each activeSessions as session (session.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--text)]">
|
||||
{formatAgent(session.userAgent)}
|
||||
{#if session.isCurrent}
|
||||
<span class="ml-2 rounded-sm bg-[var(--success-bg)] px-2 py-0.5 text-xs text-[var(--success-text)]">current</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
{session.ip || 'Unknown IP'} · Last active {formatDate(session.lastUsedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{#if !session.isCurrent}
|
||||
<button
|
||||
onclick={() => revokeSession(session.id)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Log Out & Delete Account -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-4 py-2 text-sm font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="logout-btn"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = !showDeleteConfirm)}
|
||||
class="rounded-sm px-4 py-2 text-sm font-medium text-[var(--destructive)] hover:underline"
|
||||
>
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<div class="mt-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-4">
|
||||
<p class="mb-3 text-sm text-[var(--error-text)]">
|
||||
This will permanently delete your account and all your notes. This cannot be undone.
|
||||
</p>
|
||||
|
||||
{#if deleteMsg}
|
||||
<div class="mb-3 text-sm text-[var(--error-text)]">{deleteMsg}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={deletePassword}
|
||||
placeholder="Enter your password to confirm"
|
||||
class="flex-1 rounded-sm border border-[var(--error-border)] bg-[var(--bg-base)] px-3 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--destructive)]"
|
||||
/>
|
||||
<button
|
||||
onclick={deleteAccount}
|
||||
class="rounded-sm bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import { Key, Copy, Trash2, Plus, Check } from 'lucide-svelte';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
// API Keys state
|
||||
let keys = $state<ApiKey[]>([]);
|
||||
let newKeyName = $state('');
|
||||
let createdKey = $state<string | null>(null);
|
||||
let keyLoading = $state(false);
|
||||
let keyErrorMsg = $state('');
|
||||
let copiedKey = $state(false);
|
||||
let copiedJson = $state(false);
|
||||
let copiedPrompt = $state(false);
|
||||
let deleteConfirmId = $state<string | null>(null);
|
||||
|
||||
let baseUrl = $derived(typeof window !== 'undefined' ? window.location.origin : 'https://your-crumbs-instance');
|
||||
let activeApiKey = $derived(createdKey ?? 'YOUR_API_KEY');
|
||||
|
||||
let mcpJsonConfig = $derived(`{
|
||||
"mcpServers": {
|
||||
"crumbs": {
|
||||
"type": "http",
|
||||
"url": "${baseUrl}/api/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${activeApiKey}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
let llmInstallPrompt = $derived(`Install the Crumbs MCP server with the following configuration:
|
||||
|
||||
- Type: HTTP (Streamable HTTP)
|
||||
- URL: ${baseUrl}/api/mcp
|
||||
- Authentication: Bearer token in the Authorization header
|
||||
- API Key: ${activeApiKey}
|
||||
|
||||
Add this to your MCP client settings (e.g. Claude Code's .claude/settings.json):
|
||||
|
||||
${mcpJsonConfig}`);
|
||||
|
||||
function formatDateShort(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
const res = await fetch('/api/settings/api-keys');
|
||||
if (res.ok) keys = await res.json();
|
||||
}
|
||||
|
||||
async function createKey(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
keyLoading = true;
|
||||
keyErrorMsg = '';
|
||||
createdKey = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newKeyName.trim() })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
createdKey = data.key;
|
||||
newKeyName = '';
|
||||
await loadKeys();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
keyErrorMsg = data.error || 'Failed to create key';
|
||||
}
|
||||
} catch {
|
||||
keyErrorMsg = 'Connection error';
|
||||
} finally {
|
||||
keyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKey(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
deleteConfirmId = null;
|
||||
await loadKeys();
|
||||
}
|
||||
} catch {
|
||||
keyErrorMsg = 'Failed to delete key';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, target: 'key' | 'json' | 'prompt') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (target === 'key') { copiedKey = true; setTimeout(() => (copiedKey = false), 2000); }
|
||||
if (target === 'json') { copiedJson = true; setTimeout(() => (copiedJson = false), 2000); }
|
||||
if (target === 'prompt') { copiedPrompt = true; setTimeout(() => (copiedPrompt = false), 2000); }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadKeys();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- API Keys -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Key size={16} class="text-[var(--primary)]" />
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-[var(--text-muted)]">
|
||||
API keys allow external tools (like Claude Code) to access your notes via the MCP protocol.
|
||||
</p>
|
||||
|
||||
<!-- Create Key Form -->
|
||||
<form onsubmit={createKey} class="mb-6 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newKeyName}
|
||||
placeholder="Key name (e.g. Claude Code)"
|
||||
class="flex-1 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-3 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="api-key-name-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={keyLoading || !newKeyName.trim()}
|
||||
class="flex items-center gap-1 rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="create-api-key-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if keyErrorMsg}
|
||||
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]">
|
||||
{keyErrorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Newly Created Key (show once) -->
|
||||
{#if createdKey}
|
||||
<div class="mb-4 rounded-sm border border-[var(--success-border,#a3b18a)] bg-[var(--success-bg,#f0f4e8)] p-4" data-testid="created-key-display">
|
||||
<p class="mb-2 text-xs font-semibold text-[var(--success-text,#3a5a40)]">
|
||||
Key created! Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 overflow-x-auto rounded-sm bg-[var(--bg-base)] px-3 py-2 text-xs text-[var(--text)]" data-testid="created-key-value">
|
||||
{createdKey}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => copyToClipboard(createdKey!, 'key')}
|
||||
class="flex items-center gap-1 rounded-sm border border-[var(--border-subtle)] px-3 py-2 text-xs transition-colors hover:border-[var(--primary)]"
|
||||
data-testid="copy-key-btn"
|
||||
>
|
||||
{#if copiedKey}
|
||||
<Check size={14} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Copy size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Key List -->
|
||||
{#if keys.length === 0}
|
||||
<p class="py-4 text-center text-xs text-[var(--text-muted)]">No API keys yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-2" data-testid="api-keys-list">
|
||||
{#each keys as key (key.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3" data-testid="api-key-item">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-[var(--text)]" data-testid="api-key-name">{key.name}</span>
|
||||
<code class="text-xs text-[var(--text-muted)]">{key.keyPrefix}...</code>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Created {formatDateShort(key.createdAt)}
|
||||
{#if key.lastUsedAt}
|
||||
· Last used {formatDateShort(key.lastUsedAt)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
{#if deleteConfirmId === key.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => deleteKey(key.id)}
|
||||
class="rounded-sm bg-[var(--destructive)] px-3 py-1 text-xs text-white transition-colors hover:opacity-80"
|
||||
data-testid="confirm-delete-btn"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (deleteConfirmId = null)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1 text-xs transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (deleteConfirmId = key.id)}
|
||||
class="rounded-sm p-2 text-[var(--text-muted)] transition-colors hover:text-[var(--destructive)]"
|
||||
data-testid="delete-api-key-btn"
|
||||
title="Revoke key"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- MCP Config Help -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Client Configuration</h2>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Add this to your <code class="rounded-sm bg-[var(--bg-base)] px-1 py-0.5">.claude/settings.json</code> (Claude Code):
|
||||
</p>
|
||||
<div class="relative">
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)]">{mcpJsonConfig}</pre>
|
||||
<button
|
||||
onclick={() => copyToClipboard(mcpJsonConfig, 'json')}
|
||||
class="absolute right-2 top-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--text)]"
|
||||
title="Copy JSON config"
|
||||
>
|
||||
{#if copiedJson}
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if createdKey}
|
||||
<p class="mt-1 text-xs text-[var(--success-text,#3a5a40)]">
|
||||
Config updated with your new API key.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- LLM Installation Prompt -->
|
||||
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
|
||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">LLM Installation Prompt</h3>
|
||||
<p class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
Copy this prompt and paste it into your LLM to set up the MCP connection:
|
||||
</p>
|
||||
<div class="relative">
|
||||
<pre class="overflow-x-auto rounded-sm bg-[var(--bg-base)] p-3 pr-10 text-xs text-[var(--text)] whitespace-pre-wrap">{llmInstallPrompt}</pre>
|
||||
<button
|
||||
onclick={() => copyToClipboard(llmInstallPrompt, 'prompt')}
|
||||
class="absolute right-2 top-2 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-1.5 text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--text)]"
|
||||
title="Copy LLM prompt"
|
||||
>
|
||||
{#if copiedPrompt}
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if createdKey}
|
||||
<p class="mt-1 text-xs text-[var(--success-text,#3a5a40)]">
|
||||
Prompt updated with your new API key.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { listUsers } from '$lib/server/auth.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw redirect(302, '/settings');
|
||||
}
|
||||
return { users: listUsers() };
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import type { User } from '$lib/types/index.js';
|
||||
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
let users = $state<User[]>(data.users);
|
||||
|
||||
// Create user form
|
||||
let newEmail = $state('');
|
||||
let newDisplayName = $state('');
|
||||
let newPassword = $state('');
|
||||
let newRole = $state<'admin' | 'user'>('user');
|
||||
let createMsg = $state('');
|
||||
let createError = $state(false);
|
||||
|
||||
async function createUser() {
|
||||
createMsg = '';
|
||||
if (!newEmail || !newPassword) {
|
||||
createMsg = 'Email and password are required';
|
||||
createError = true;
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
createMsg = 'Password must be at least 8 characters';
|
||||
createError = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: newEmail,
|
||||
displayName: newDisplayName || newEmail.split('@')[0],
|
||||
password: newPassword,
|
||||
role: newRole
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
users = [...users, user];
|
||||
newEmail = '';
|
||||
newDisplayName = '';
|
||||
newPassword = '';
|
||||
newRole = 'user';
|
||||
createMsg = 'User created';
|
||||
createError = false;
|
||||
} else {
|
||||
const d = await res.json();
|
||||
createMsg = d.message || 'Failed to create user';
|
||||
createError = true;
|
||||
}
|
||||
} catch {
|
||||
createMsg = 'Connection error';
|
||||
createError = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId: number) {
|
||||
if (!confirm('Are you sure you want to delete this user and all their data?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
users = users.filter((u) => u.id !== userId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSessions(user: User) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ revokeSessions: true })
|
||||
});
|
||||
if (res.ok) {
|
||||
createMsg = `All sessions revoked for ${user.displayName || user.email}`;
|
||||
createError = false;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRole(user: User) {
|
||||
const newRoleValue = user.role === 'admin' ? 'user' : 'admin';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: newRoleValue })
|
||||
});
|
||||
if (res.ok) {
|
||||
users = users.map((u) => (u.id === user.id ? { ...u, role: newRoleValue } : u));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Create User -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Create User</h2>
|
||||
|
||||
{#if createMsg}
|
||||
<div class="mb-4 rounded-sm border p-3 text-sm {createError ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : 'border-[var(--success-bg)] bg-[var(--success-bg)] text-[var(--success-text)]'}">
|
||||
{createMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="Email"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newDisplayName}
|
||||
placeholder="Display name (optional)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
<select
|
||||
bind:value={newRole}
|
||||
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 text-sm text-[var(--text)] outline-none focus:border-[var(--primary)]"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={createUser}
|
||||
class="rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Create user
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- User List -->
|
||||
<section class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text)]">Users ({users.length})</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded-sm border border-[var(--border-subtle)] p-4">
|
||||
<div>
|
||||
<p class="font-medium text-[var(--text)]">
|
||||
{user.displayName || user.email}
|
||||
{#if user.role === 'admin'}
|
||||
<span class="ml-2 rounded-sm bg-[var(--primary)]/15 px-2 py-0.5 text-xs text-[var(--primary)]">admin</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">{user.email}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => toggleRole(user)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||
>
|
||||
{user.role === 'admin' ? 'Make user' : 'Make admin'}
|
||||
</button>
|
||||
{#if user.id !== data.user?.id}
|
||||
<button
|
||||
onclick={() => revokeSessions(user)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--text-muted)] hover:bg-[var(--border-subtle)]/50"
|
||||
title="Force logout"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
class="rounded-sm px-3 py-1 text-xs text-[var(--destructive)] hover:bg-[var(--error-bg)]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>#{$page.params.name} - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="all" tag={$page.params.name} />
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import NotesView from '$lib/components/NotesView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Trash - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<NotesView filter="trashed" />
|
||||
@@ -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,8 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { getEnabledProviders } from '$lib/server/oauth/providers.js';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
providers: getEnabledProviders()
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Check for OAuth error in URL
|
||||
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
if (urlParams?.get('error') === 'no_account') {
|
||||
errorMsg = 'No account found for this email. Ask your admin for an invite.';
|
||||
}
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
goto('/');
|
||||
} else if (res.status === 429) {
|
||||
const d = await res.json();
|
||||
errorMsg = d.error || 'Too many attempts. Please try again later.';
|
||||
} else {
|
||||
const d = await res.json();
|
||||
errorMsg = d.message || 'Invalid email or password';
|
||||
}
|
||||
} catch {
|
||||
errorMsg = 'Connection error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const providers = data.providers ?? [];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Crumbs</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleLogin}>
|
||||
{#if errorMsg}
|
||||
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]" data-testid="error-message">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email"
|
||||
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="email-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="login-btn"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if providers.length > 0}
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-[var(--border-subtle)]"></div>
|
||||
<span class="text-xs text-[var(--text-muted)]">or continue with</span>
|
||||
<div class="h-px flex-1 bg-[var(--border-subtle)]"></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each providers as provider (provider.id)}
|
||||
<a
|
||||
href="/api/auth/oauth/{provider.id}"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-sm border border-[var(--border-subtle)] px-4 py-3 text-sm font-medium text-[var(--text)] transition-colors hover:border-[var(--primary)] hover:bg-[var(--bg-base)]"
|
||||
data-testid="oauth-{provider.id}-btn"
|
||||
>
|
||||
{#if provider.id === 'google'}
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
{:else if provider.id === 'github'}
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{provider.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let email = $state('');
|
||||
let displayName = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let errorMsg = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSetup(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
|
||||
if (!email) {
|
||||
errorMsg = 'Email is required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
errorMsg = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorMsg = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, displayName, password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
goto('/');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
errorMsg = data.message || 'Setup failed';
|
||||
}
|
||||
} catch {
|
||||
errorMsg = 'Connection error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Setup - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Welcome to Crumbs</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Create your admin account to get started</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSetup}>
|
||||
{#if errorMsg}
|
||||
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]" data-testid="error-message">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email"
|
||||
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="email-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
placeholder="Display name (optional)"
|
||||
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="display-name-input"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm password"
|
||||
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="confirm-password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="setup-btn"
|
||||
>
|
||||
{loading ? 'Setting up...' : 'Create account & start'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user