forked from mirror/crumbs-original
Compare commits
299
Commits
@@ -70,9 +70,6 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -87,7 +84,6 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -30,9 +30,6 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up QEMU (for multi-platform builds)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -47,7 +44,6 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -44,4 +44,3 @@ todo.md
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
package-lock.json
|
||||
|
||||
@@ -54,7 +54,7 @@ Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
|
||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Keep at 16-20px size. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
|
||||
- **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
|
||||
@@ -163,13 +163,6 @@ GitHub Actions with two workflows:
|
||||
|
||||
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
|
||||
|
||||
## Database schema changes
|
||||
|
||||
When adding or modifying tables in `src/lib/server/db/schema.ts`:
|
||||
|
||||
- **If the table has a foreign key to `users`**: update `deleteUser()` in `src/lib/server/auth.ts` to delete from the new table before the user row is removed. Order matters — delete child rows before parent rows.
|
||||
- **If the table has a foreign key to `notes`**: either add `onDelete: 'cascade'` in the schema definition, or add explicit cleanup in the note deletion logic.
|
||||
|
||||
## Testing philosophy: BDD/TDD
|
||||
|
||||
Write tests first or alongside features. Tests serve as living documentation of expected behavior.
|
||||
@@ -187,7 +180,6 @@ Write tests first or alongside features. Tests serve as living documentation of
|
||||
- Test database: `./data/test-crumbs.db` (cleaned via `global-setup.ts`)
|
||||
- Auth fixture: `tests/e2e/helpers/fixtures.ts` provides `authenticatedPage` (handles setup/login race conditions across parallel workers)
|
||||
- Auth tests use `test.describe.serial` because they depend on sequential database state
|
||||
- **Offline/online tests**: After `setOffline(false)`, always manually dispatch the `online` event via `page.evaluate(() => window.dispatchEvent(new Event('online')))` — Playwright doesn't reliably emit it in headless/CI environments, causing flaky `waitForResponse` timeouts
|
||||
|
||||
### Gherkin-style Given/When/Then
|
||||
|
||||
|
||||
@@ -46,21 +46,6 @@ volumes:
|
||||
|
||||
Google and GitHub OAuth are also supported. See [docs/AUTH.md](docs/AUTH.md) for all providers and setup guides.
|
||||
|
||||
#### With Email Notifications (optional)
|
||||
|
||||
Add SMTP settings to enable email notifications (share alerts, welcome emails, security alerts):
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- SMTP_HOST=smtp.example.com
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=your-username
|
||||
- SMTP_PASS=your-password
|
||||
- SMTP_FROM=Crumbs <noreply@example.com>
|
||||
```
|
||||
|
||||
Auto-enabled when `SMTP_HOST` is set. Users can opt out in Settings > Preferences. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#email-notifications-optional) for details.
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
```bash
|
||||
@@ -90,9 +75,6 @@ pnpm build # Production build
|
||||
- Rich notes with Markdown, checklists, image attachments, and 12 color themes
|
||||
- Organize with #tags, pinning, archive, and trash
|
||||
- Full-text search across titles, content, and tags
|
||||
- Note version history — browse and restore previous versions
|
||||
- Share notes with other users on the same instance
|
||||
- Email notifications for shares, security alerts, and account events (optional SMTP)
|
||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||
- MCP server — let AI assistants (Claude Code, etc.) manage your notes
|
||||
- Multi-user auth (Argon2) with optional OAuth/SSO (Google, GitHub, OIDC)
|
||||
|
||||
+1
-11
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.22.3
|
||||
image: ghcr.io/bretzel-app/crumbs:0.19.3
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -11,16 +11,6 @@ services:
|
||||
- DATA_DIR=/data
|
||||
- DATABASE_URL=/data/crumbs.db
|
||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||
# OAuth / SSO (optional — providers auto-enable when configured)
|
||||
- AUTH_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID:-}
|
||||
- AUTH_GOOGLE_CLIENT_SECRET=${AUTH_GOOGLE_CLIENT_SECRET:-}
|
||||
- AUTH_GITHUB_CLIENT_ID=${AUTH_GITHUB_CLIENT_ID:-}
|
||||
- AUTH_GITHUB_CLIENT_SECRET=${AUTH_GITHUB_CLIENT_SECRET:-}
|
||||
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
|
||||
- AUTH_OIDC_CLIENT_ID=${AUTH_OIDC_CLIENT_ID:-}
|
||||
- AUTH_OIDC_CLIENT_SECRET=${AUTH_OIDC_CLIENT_SECRET:-}
|
||||
- AUTH_OIDC_DISPLAY_NAME=${AUTH_OIDC_DISPLAY_NAME:-}
|
||||
# Email notifications (optional — auto-enabled when SMTP_HOST is set)
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ Google, GitHub, and generic OIDC (Authentik, Keycloak, Okta, etc.) are supported
|
||||
|
||||
### Email Notifications (optional)
|
||||
|
||||
Email notifications are auto-enabled when `SMTP_HOST` is set. See [FEATURES.md](FEATURES.md#note-sharing--collaboration) for the full list of notification types.
|
||||
Email notifications are sent when a note is shared with a user. Auto-enabled when `SMTP_HOST` is set.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
|
||||
+6
-21
@@ -64,10 +64,10 @@
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to note theme
|
||||
|
||||
### Favorite Notes
|
||||
- Favorite important notes to always appear at top
|
||||
- Favorites section separated from other notes
|
||||
- Toggle favorite from note card hover actions
|
||||
### Pin Notes
|
||||
- Pin important notes to always appear at top
|
||||
- Pinned section separated from unpinned notes
|
||||
- Toggle pin from note card hover actions
|
||||
|
||||
### Archive
|
||||
- Archive notes to declutter main view
|
||||
@@ -91,22 +91,7 @@
|
||||
- Share dialog accessible from editor toolbar
|
||||
- Collaborators can leave shared notes
|
||||
- Shared notes appear mixed in main list alongside owned notes
|
||||
- Email notifications (requires SMTP config, opt-out in preferences):
|
||||
- Share notifications when a note is shared with you
|
||||
- Welcome email when admin creates your account
|
||||
- Password reset notification
|
||||
- Account deletion confirmation
|
||||
- Email address change notification
|
||||
- Role change notification (admin/user)
|
||||
- Removed from shared note notification
|
||||
- Shared note permanently deleted notification
|
||||
- Account locked alert (after failed login attempts)
|
||||
|
||||
### Note Version History
|
||||
- Browse previous versions of any note
|
||||
- Versions are automatically saved when content changes
|
||||
- Restore any previous version with one click
|
||||
- Version snapshots are only created when content actually changes (not on metadata-only updates)
|
||||
- Email notifications when a note is shared with you (requires SMTP config, opt-out in preferences)
|
||||
|
||||
### Full-Text Search
|
||||
- Search across note titles, content, and tags
|
||||
@@ -138,7 +123,7 @@
|
||||
|
||||
### MCP Server (Model Context Protocol)
|
||||
- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport)
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; pin, reorder notes; upload images
|
||||
- API key authentication (Bearer token)
|
||||
- Compatible with Claude Code, Claude Desktop, and other MCP clients
|
||||
- Stateful sessions with automatic cleanup
|
||||
|
||||
@@ -1,672 +0,0 @@
|
||||
# Pixelarticons Migration + Favorites Rename — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace Lucide icons with pixelarticons via @iconify/svelte across the app (except 4 formatting letterforms), and rename user-facing "pinned" to "favorites" with a heart icon.
|
||||
|
||||
**Architecture:** Each component file gets its Lucide imports replaced with a single `Icon` import from `@iconify/svelte`. The `size` prop becomes `width`, and the icon name becomes a `pixelarticons:name` string. The 4 formatting letterforms (Bold, Italic, Strikethrough, Underline) stay as Lucide. The favorites rename touches stores, components, MCP server, and one e2e test.
|
||||
|
||||
**Tech Stack:** `@iconify/svelte` v5.2.1, `@iconify-json/pixelarticons` v1.2.4 (both already installed), `lucide-svelte` (kept for 4 icons)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
No new files created. All changes are modifications to existing files:
|
||||
|
||||
**Icon migration (16 files):** Replace Lucide imports with `@iconify/svelte` Icon component.
|
||||
**Favorites rename (4 source files + 1 test file):** Rename stores, functions, labels, test IDs.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Favorites Rename — Stores
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/stores/notes.ts:38-44,234-236`
|
||||
|
||||
- [ ] **Step 1: Rename store exports and function**
|
||||
|
||||
In `src/lib/stores/notes.ts`, make these changes:
|
||||
|
||||
```typescript
|
||||
// Line 38-44: Rename stores
|
||||
export const favoriteNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => n.pinned))
|
||||
);
|
||||
|
||||
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned))
|
||||
);
|
||||
|
||||
// Line 234-236: Rename function
|
||||
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
```
|
||||
|
||||
Note: The `pinned` field in updateNote stays — it's the DB column name.
|
||||
|
||||
- [ ] **Step 2: Verify no type errors**
|
||||
|
||||
Run: `pnpm check 2>&1 | head -30`
|
||||
Expected: Errors in files that still reference old names (NoteCard, NotesView) — that's expected, we fix those next.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/stores/notes.ts
|
||||
git commit -m "refactor: rename pinned stores to favorites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Favorites Rename — NoteCard Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteCard.svelte:4,110-118,170-178`
|
||||
|
||||
- [ ] **Step 1: Update import**
|
||||
|
||||
Change line 4:
|
||||
```typescript
|
||||
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update favorite indicator (lines 110-118)**
|
||||
|
||||
Replace the pinned indicator block:
|
||||
```svelte
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unfavorite"
|
||||
data-testid="favorite-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update favorite button (lines 170-178)**
|
||||
|
||||
Replace the pin button block:
|
||||
```svelte
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Favorite"
|
||||
data-testid="favorite-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteCard.svelte
|
||||
git commit -m "refactor: rename pin to favorite in NoteCard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Favorites Rename — NotesView Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NotesView.svelte:5,89-104`
|
||||
|
||||
- [ ] **Step 1: Update imports (line 5)**
|
||||
|
||||
```typescript
|
||||
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template references**
|
||||
|
||||
Replace all `$pinnedNotes` with `$favoriteNotes`, `$unpinnedNotes` with `$unfavoritedNotes`, and label `"Pinned"` with `"Favorites"`:
|
||||
|
||||
Line 89: `{#if $favoriteNotes.length > 0}`
|
||||
Line 91: `<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />`
|
||||
Line 96: `notes={$unfavoritedNotes}`
|
||||
Line 97: `label={$favoriteNotes.length > 0 ? 'Others' : ''}`
|
||||
Line 104: `{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}`
|
||||
|
||||
Note: Keep `dndType="pinned-notes"` unchanged — it's an internal DnD identifier, not user-facing.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NotesView.svelte
|
||||
git commit -m "refactor: rename pinned to favorites in NotesView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Favorites Rename — MCP Server
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/server/mcp/server.ts:241-265`
|
||||
|
||||
- [ ] **Step 1: Rename MCP tool**
|
||||
|
||||
Replace the `pin_note` tool definition (lines 241-265):
|
||||
```typescript
|
||||
server.tool(
|
||||
'favorite_note',
|
||||
'Favorite or unfavorite a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to favorite (true) or unfavorite (false)')
|
||||
},
|
||||
async ({ id, pinned }: { id: string; pinned: boolean }) => {
|
||||
const result = updateNote(db, userId, id, { pinned });
|
||||
if (!result) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Note not found' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${result.title}" ${pinned ? 'favorited' : 'unfavorited'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Note: The `pinned` parameter name stays since it maps to the DB column.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/server/mcp/server.ts
|
||||
git commit -m "refactor: rename pin_note MCP tool to favorite_note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Favorites Rename — E2E Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/e2e/organization.spec.ts:4-15`
|
||||
|
||||
- [ ] **Step 1: Update test scenario**
|
||||
|
||||
Replace lines 4-15:
|
||||
```typescript
|
||||
test('Scenario: Favorited note appears under the Favorites section', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Favorite Me" exists
|
||||
await createNote(page, 'Favorite Me');
|
||||
|
||||
// When the user favorites the note
|
||||
const favCard = noteCard(page, 'Favorite Me');
|
||||
await favCard.hover();
|
||||
await favCard.getByTestId('favorite-btn').first().click({ force: true });
|
||||
|
||||
// Then the "Favorites" section is visible
|
||||
await expect(page.getByText('Favorites')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run check to verify favorites rename is complete**
|
||||
|
||||
Run: `pnpm check 2>&1 | head -20`
|
||||
Expected: PASS (no errors related to old pin names)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/e2e/organization.spec.ts
|
||||
git commit -m "test: update pin test to favorites"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Icon Migration — Header + SearchBar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/Layout/Header.svelte:2-5`
|
||||
- Modify: `src/lib/components/SearchBar.svelte:4-6`
|
||||
|
||||
- [ ] **Step 1: Migrate Header.svelte**
|
||||
|
||||
Replace imports (lines 2-5):
|
||||
```typescript
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace icon usage in template:
|
||||
- `<Menu class="h-6 w-6 text-[var(--text)]" />` → `<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />`
|
||||
- `<Search class="h-5 w-5 text-[var(--text)]" />` → `<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />`
|
||||
|
||||
- [ ] **Step 2: Migrate SearchBar.svelte**
|
||||
|
||||
Replace imports (lines 4-6):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace icon usage:
|
||||
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<ArrowLeft .../>` → `<Icon icon="pixelarticons:arrow-left" .../>`
|
||||
|
||||
Match existing `class` attributes exactly — just change the component and add the `icon` prop.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/Layout/Header.svelte src/lib/components/SearchBar.svelte
|
||||
git commit -m "feat: migrate Header and SearchBar to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Icon Migration — Sidebar
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/Layout/Sidebar.svelte:2-4,19-23,47,65,81`
|
||||
|
||||
- [ ] **Step 1: Replace imports and nav items**
|
||||
|
||||
Replace the import (line 4):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace navItems array (lines 19-23):
|
||||
```typescript
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update template icon usage**
|
||||
|
||||
Line 47: `<item.icon size={20} />` → `<Icon icon={item.icon} width={20} />`
|
||||
Line 65: `<Tag size={16} />` → `<Icon icon="pixelarticons:label" width={16} />`
|
||||
Line 81: `<Settings size={20} />` → `<Icon icon="pixelarticons:settings-cog" width={20} />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/Layout/Sidebar.svelte
|
||||
git commit -m "feat: migrate Sidebar to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Icon Migration — NoteCard
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteCard.svelte:5-13,117,159,167,177,187,196,206,215`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 5-13:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Remove all individual Lucide imports (Undo2, Trash2, Bookmark, Archive, ArchiveRestore, UserMinus).
|
||||
|
||||
- [ ] **Step 2: Replace all icon usages in template**
|
||||
|
||||
- `<Bookmark class="h-4 w-4 fill-[var(--primary)]" />` (favorite indicator) → `<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />`
|
||||
- `<Bookmark class="h-4 w-4" />` (favorite button) → `<Icon icon="pixelarticons:heart" class="h-4 w-4" />`
|
||||
- `<Undo2 class="h-4 w-4" />` (restore) → `<Icon icon="pixelarticons:undo" class="h-4 w-4" />`
|
||||
- `<Trash2 class="h-4 w-4 text-[var(--destructive)]" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />`
|
||||
- `<Trash2 class="h-4 w-4" />` → `<Icon icon="pixelarticons:trash" class="h-4 w-4" />`
|
||||
- `<Archive class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
|
||||
- `<ArchiveRestore class="h-4 w-4" />` → `<Icon icon="pixelarticons:archive" class="h-4 w-4" />`
|
||||
- `<UserMinus class="h-4 w-4" />` → `<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteCard.svelte
|
||||
git commit -m "feat: migrate NoteCard to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Icon Migration — NoteEditor
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteEditor.svelte:16-24`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 16-24:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace all icon usages**
|
||||
|
||||
Search the file for each Lucide component and replace:
|
||||
- `<Palette .../>` → `<Icon icon="pixelarticons:colors-swatch" .../>`
|
||||
- `<SquareCheck .../>` → `<Icon icon="pixelarticons:checkbox-on" .../>`
|
||||
- `<ImageIcon .../>` → `<Icon icon="pixelarticons:image" .../>`
|
||||
- `<Type .../>` → `<Icon icon="pixelarticons:art-text" .../>`
|
||||
- `<FileCode .../>` → `<Icon icon="pixelarticons:script" .../>`
|
||||
- `<FileText .../>` → `<Icon icon="pixelarticons:file-text" .../>`
|
||||
- `<UserPlus .../>` → `<Icon icon="pixelarticons:user-plus" .../>`
|
||||
- `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
|
||||
- `<History .../>` → `<Icon icon="pixelarticons:timeline" .../>`
|
||||
|
||||
For each, keep the existing `size` prop but rename to `width`. Keep `class` as-is.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteEditor.svelte
|
||||
git commit -m "feat: migrate NoteEditor to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Icon Migration — FormattingToolbar
|
||||
|
||||
This is the largest file. Bold, Italic, Strikethrough, Underline stay as Lucide. Everything else migrates.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/FormattingToolbar.svelte:2-31,131-482`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace the import block (lines 3-31):
|
||||
```typescript
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Underline
|
||||
} from 'lucide-svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace icon usages in template**
|
||||
|
||||
Replace every non-B/I/S/U icon. The pattern: `<IconName size={iconSize} />` → `<Icon icon="pixelarticons:name" width={iconSize} />`. Full mapping:
|
||||
|
||||
- `<Undo2 size={iconSize} />` → `<Icon icon="pixelarticons:undo" width={iconSize} />`
|
||||
- `<Redo2 size={iconSize} />` → `<Icon icon="pixelarticons:redo" width={iconSize} />`
|
||||
- `<Heading size={iconSize} />` → `<Icon icon="pixelarticons:heading" width={iconSize} />`
|
||||
- `<List size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:list" width={iconSize} />`
|
||||
- `<ListOrdered size={iconSize} />` → `<Icon icon="pixelarticons:bulletlist" width={iconSize} />`
|
||||
- `<ListChecks size={iconSize} />` → `<Icon icon="pixelarticons:checklist" width={iconSize} />`
|
||||
- `<Link size={iconSize} />` → `<Icon icon="pixelarticons:link" width={iconSize} />`
|
||||
- `<CornerDownLeft size={16} />` → `<Icon icon="pixelarticons:corner-down-left" width={16} />`
|
||||
- `<ExternalLink size={16} />` → `<Icon icon="pixelarticons:external-link" width={16} />`
|
||||
- `<Trash2 size={16} />` → `<Icon icon="pixelarticons:trash" width={16} />`
|
||||
- `<Table2 size={iconSize} />` (appears 2x) → `<Icon icon="pixelarticons:table" width={iconSize} />`
|
||||
- `<ChevronDown size={chevronSize} />` (appears 4x) → `<Icon icon="pixelarticons:chevron-down" width={chevronSize} />`
|
||||
- `<BetweenHorizontalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-row" width={iconSize} />`
|
||||
- `<BetweenVerticalEnd size={iconSize} />` → `<Icon icon="pixelarticons:add-col" width={iconSize} />`
|
||||
- `<Minus size={iconSize} />` (appears 3x) → `<Icon icon="pixelarticons:minus" width={iconSize} />`
|
||||
- `<RemoveFormatting size={iconSize} />` → `<Icon icon="pixelarticons:remove-box" width={iconSize} />`
|
||||
- `<Code size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
|
||||
- `<TextQuote size={iconSize} />` → `<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />`
|
||||
- `<CodeXml size={iconSize} />` → `<Icon icon="pixelarticons:braces" width={iconSize} />`
|
||||
- `<AlignLeft size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-left" width={iconSize} />`
|
||||
- `<AlignCenter size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-center" width={iconSize} />`
|
||||
- `<AlignRight size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-right" width={iconSize} />`
|
||||
- `<AlignJustify size={iconSize} />` (2x) → `<Icon icon="pixelarticons:text-align-justify" width={iconSize} />`
|
||||
|
||||
Leave these unchanged: `<Bold size={iconSize} />`, `<Italic size={iconSize} />`, `<Strikethrough size={iconSize} />`, `<Underline size={iconSize} />`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/FormattingToolbar.svelte
|
||||
git commit -m "feat: migrate FormattingToolbar to pixelarticons (keep B/I/S/U as Lucide)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Icon Migration — SyncIndicator
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/SyncIndicator.svelte:4-7,54-61`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Replace lines 4-7:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace icon usages**
|
||||
|
||||
Replace the status icon block (lines 53-61):
|
||||
```svelte
|
||||
{#if displayStatus === 'synced'}
|
||||
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/SyncIndicator.svelte
|
||||
git commit -m "feat: migrate SyncIndicator to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Icon Migration — Remaining Small Components
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/components/NoteHistory.svelte:3-5`
|
||||
- Modify: `src/lib/components/ShareDialog.svelte:3-5`
|
||||
- Modify: `src/lib/components/Checklist.svelte:2-5`
|
||||
- Modify: `src/lib/components/CollaboratorPopover.svelte:3`
|
||||
- Modify: `src/lib/components/ImageLightbox.svelte:2`
|
||||
- Modify: `src/lib/components/ImageUpload.svelte:2-4`
|
||||
- Modify: `src/lib/components/NotesView.svelte:8`
|
||||
|
||||
- [ ] **Step 1: Migrate NoteHistory.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<RotateCcw .../>` → `<Icon icon="pixelarticons:undo" .../>`
|
||||
- `<Clock .../>` → `<Icon icon="pixelarticons:clock" .../>`
|
||||
|
||||
- [ ] **Step 2: Migrate ShareDialog.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<X .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<UserMinus .../>` → `<Icon icon="pixelarticons:user-minus" .../>`
|
||||
- `<Search .../>` → `<Icon icon="pixelarticons:search" .../>`
|
||||
|
||||
- [ ] **Step 3: Migrate Checklist.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
|
||||
- `<GripVertical .../>` → `<Icon icon="pixelarticons:drag-and-drop" .../>`
|
||||
- `<ChevronRight .../>` → `<Icon icon="pixelarticons:chevron-right" .../>`
|
||||
|
||||
- [ ] **Step 4: Migrate CollaboratorPopover.svelte**
|
||||
|
||||
Replace import:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace: `<Users .../>` → `<Icon icon="pixelarticons:users" .../>`
|
||||
|
||||
- [ ] **Step 5: Migrate ImageLightbox.svelte**
|
||||
|
||||
Replace import:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace: `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
|
||||
- [ ] **Step 6: Migrate ImageUpload.svelte**
|
||||
|
||||
Replace imports:
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<XIcon .../>` → `<Icon icon="pixelarticons:close" .../>`
|
||||
- `<Star .../>` → `<Icon icon="pixelarticons:pin" .../>`
|
||||
- `<RefreshCw .../>` → `<Icon icon="pixelarticons:reload" .../>`
|
||||
|
||||
- [ ] **Step 7: Migrate NotesView.svelte (Plus icon)**
|
||||
|
||||
Replace import (line 8):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
Replace usages:
|
||||
- `<Plus class="h-4 w-4" />` → `<Icon icon="pixelarticons:plus" class="h-4 w-4" />`
|
||||
- `<Plus class="h-5 w-5" />` → `<Icon icon="pixelarticons:plus" class="h-5 w-5" />`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/components/NoteHistory.svelte src/lib/components/ShareDialog.svelte src/lib/components/Checklist.svelte src/lib/components/CollaboratorPopover.svelte src/lib/components/ImageLightbox.svelte src/lib/components/ImageUpload.svelte src/lib/components/NotesView.svelte
|
||||
git commit -m "feat: migrate remaining components to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Icon Migration — Settings Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/(app)/settings/+layout.svelte:3`
|
||||
- Modify: `src/routes/(app)/settings/mcp/+page.svelte:2`
|
||||
|
||||
- [ ] **Step 1: Migrate settings layout**
|
||||
|
||||
Replace import (line 3):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace usages:
|
||||
- `<SlidersHorizontal size={16} />` → `<Icon icon="pixelarticons:sliders" width={16} />`
|
||||
- `<User size={16} />` → `<Icon icon="pixelarticons:user" width={16} />`
|
||||
- `<Cpu size={16} />` → `<Icon icon="pixelarticons:cpu" width={16} />`
|
||||
- `<ShieldCheck size={16} />` → `<Icon icon="pixelarticons:shield" width={16} />`
|
||||
- `<Info size={16} />` → `<Icon icon="pixelarticons:info-box" width={16} />`
|
||||
|
||||
- [ ] **Step 2: Migrate MCP settings page**
|
||||
|
||||
Replace import (line 2):
|
||||
```typescript
|
||||
import Icon from '@iconify/svelte';
|
||||
```
|
||||
|
||||
Replace usages:
|
||||
- `<Key .../>` → `<Icon icon="pixelarticons:lock" .../>`
|
||||
- `<Copy .../>` → `<Icon icon="pixelarticons:copy" .../>`
|
||||
- `<Trash2 .../>` → `<Icon icon="pixelarticons:trash" .../>`
|
||||
- `<Plus .../>` → `<Icon icon="pixelarticons:plus" .../>`
|
||||
- `<Check .../>` → `<Icon icon="pixelarticons:check" .../>`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/routes/(app)/settings/+layout.svelte src/routes/(app)/settings/mcp/+page.svelte
|
||||
git commit -m "feat: migrate settings pages to pixelarticons"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Update docs/FEATURES.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/FEATURES.md:67-70,141`
|
||||
|
||||
- [ ] **Step 1: Rename "Pin Notes" section to "Favorite Notes"**
|
||||
|
||||
Replace lines 67-70:
|
||||
```markdown
|
||||
### Favorite Notes
|
||||
- Favorite important notes to always appear at top
|
||||
- Favorites section separated from other notes
|
||||
- Toggle favorite from note card hover actions
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update MCP tools description**
|
||||
|
||||
Line 141: replace `pin` with `favorite` in the tools list:
|
||||
```markdown
|
||||
- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; favorite, reorder notes; upload images
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/FEATURES.md
|
||||
git commit -m "docs: rename pin to favorites in FEATURES.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Verification
|
||||
|
||||
- [ ] **Step 1: Run type check**
|
||||
|
||||
Run: `pnpm check`
|
||||
Expected: PASS with no errors
|
||||
|
||||
- [ ] **Step 2: Run unit tests**
|
||||
|
||||
Run: `pnpm test:unit`
|
||||
Expected: All tests pass (DB/CRDT tests use `pinned` field which is unchanged)
|
||||
|
||||
- [ ] **Step 3: Run e2e tests**
|
||||
|
||||
Run: `pnpm test:e2e`
|
||||
Expected: All tests pass including the updated organization spec
|
||||
|
||||
- [ ] **Step 4: Update CLAUDE.md icons section**
|
||||
|
||||
Add to the "Visual rules" section in CLAUDE.md after the "Icons" line:
|
||||
|
||||
```markdown
|
||||
- **Icons**: Pixelarticons via `@iconify/svelte` — use `<Icon icon="pixelarticons:name" width={size} class="..." />`. Exception: Bold, Italic, Strikethrough, Underline stay as Lucide (`lucide-svelte`). See full mapping in `docs/superpowers/specs/2026-03-20-pixelarticons-migration-design.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit docs update**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: update CLAUDE.md with pixelarticons icon usage"
|
||||
```
|
||||
@@ -1,182 +0,0 @@
|
||||
# Icon Migration: Lucide → Pixelarticons + "Pinned" → "Favorites" Rename
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Replace Lucide icons with pixelarticons (via `@iconify/svelte`) across the app to match the retro parchment aesthetic. Keep Lucide only for 4 text-formatting letterform icons (Bold, Italic, Strikethrough, Underline). Additionally, rename the user-facing "pinned" concept to "favorites" with a heart icon, while keeping the `pinned` database column unchanged.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current Lucide icons have smooth, modern strokes that clash with the 8-bit retro parchment design system. Pixelarticons are drawn on a strict 24×24 pixel grid with no anti-aliasing — a natural fit for the `Press Start 2P` pixel font and hard-offset shadow aesthetic already in use.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Icon Library
|
||||
|
||||
- **Package:** `@iconify/svelte` (v5.2.1, already installed) + `@iconify-json/pixelarticons` (v1.2.4, already installed)
|
||||
- **Usage:** `<Icon icon="pixelarticons:icon-name" width={size} class="..." />`
|
||||
- **Tree-shaking:** Iconify handles this automatically — only icons used in code are bundled
|
||||
|
||||
### Icon Mapping
|
||||
|
||||
56 icons migrated to pixelarticons, 4 kept as Lucide:
|
||||
|
||||
#### Navigation & UI
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| Menu | `menu` |
|
||||
| Search | `search` |
|
||||
| X / XIcon | `close` |
|
||||
| Plus | `plus` |
|
||||
| Check | `check` |
|
||||
| ArrowLeft | `arrow-left` |
|
||||
| ChevronRight | `chevron-right` |
|
||||
| ChevronDown | `chevron-down` |
|
||||
| Settings | `settings-cog` |
|
||||
| ExternalLink | `external-link` |
|
||||
|
||||
#### Notes & Content
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| StickyNote | `sticky-note` | |
|
||||
| Archive | `archive` | |
|
||||
| ArchiveRestore | `archive` | Context makes it clear |
|
||||
| Trash2 | `trash` | |
|
||||
| Bookmark | `heart` | Renamed to "favorite" |
|
||||
| Clock | `clock` | |
|
||||
| History | `timeline` | |
|
||||
| FileText | `file-text` | |
|
||||
| FileCode | `script` | |
|
||||
| ImageIcon | `image` | |
|
||||
| Palette | `colors-swatch` | |
|
||||
| GripVertical | `drag-and-drop` | |
|
||||
| Star | `pin` | Used in ImageUpload for "featured image" toggle — not related to favorites |
|
||||
| SquareCheck | `checkbox-on` | |
|
||||
| Type | `art-text` | |
|
||||
|
||||
#### Text Formatting (toolbar)
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| **Bold** | **Keep Lucide** | Letterform |
|
||||
| **Italic** | **Keep Lucide** | Letterform |
|
||||
| **Strikethrough** | **Keep Lucide** | Letterform |
|
||||
| **Underline** | **Keep Lucide** | Letterform |
|
||||
| Code | `braces` | |
|
||||
| CodeXml | `braces` | |
|
||||
| Heading | `heading` | |
|
||||
| Link | `link` | |
|
||||
| TextQuote | `quote-text-inline` | |
|
||||
| List | `list` | |
|
||||
| ListOrdered | `bulletlist` | |
|
||||
| ListChecks | `checklist` | |
|
||||
| AlignLeft | `text-align-left` | |
|
||||
| AlignCenter | `text-align-center` | |
|
||||
| AlignRight | `text-align-right` | |
|
||||
| AlignJustify | `text-align-justify` | |
|
||||
| Table2 | `table` | |
|
||||
| Minus | `minus` | |
|
||||
| RemoveFormatting | `remove-box` | |
|
||||
| CornerDownLeft | `corner-down-left` | |
|
||||
| BetweenHorizontalEnd | `add-row` | |
|
||||
| BetweenVerticalEnd | `add-col` | |
|
||||
|
||||
#### Undo/Redo
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| Undo2 | `undo` |
|
||||
| Redo2 | `redo` |
|
||||
| RotateCcw | `undo` |
|
||||
| RefreshCw | `reload` |
|
||||
|
||||
#### Sync Status
|
||||
| Lucide | Pixelarticons | Notes |
|
||||
|--------|--------------|-------|
|
||||
| CloudCheck | `cloud-done` | |
|
||||
| CloudSync | `sync` | |
|
||||
| CloudOff | `cloud` | Color conveys offline state |
|
||||
| CloudAlert | `cloud` | Color conveys error state |
|
||||
|
||||
#### Users & Admin
|
||||
| Lucide | Pixelarticons |
|
||||
|--------|--------------|
|
||||
| User | `user` |
|
||||
| Users | `users` |
|
||||
| UserPlus | `user-plus` |
|
||||
| UserMinus | `user-minus` |
|
||||
| Cpu | `cpu` |
|
||||
| ShieldCheck | `shield` |
|
||||
| SlidersHorizontal | `sliders` |
|
||||
| Info | `info-box` |
|
||||
| Key | `lock` |
|
||||
| Copy | `copy` |
|
||||
| Tag | `label` |
|
||||
|
||||
### "Pinned" → "Favorites" Rename
|
||||
|
||||
**Scope:** User-facing labels, function names, store names, MCP tool names, test IDs. The database column `pinned` stays unchanged to avoid a migration.
|
||||
|
||||
**Renames:**
|
||||
| Current | New |
|
||||
|---------|-----|
|
||||
| `pinnedNotes` (store) | `favoriteNotes` |
|
||||
| `unpinnedNotes` (store) | `unfavoritedNotes` |
|
||||
| `togglePin()` | `toggleFavorite()` |
|
||||
| `"Pinned"` label in NotesView | `"Favorites"` |
|
||||
| `"Pin"` / `"Unpin"` tooltips in NoteCard | `"Favorite"` / `"Unfavorite"` |
|
||||
| `pin_note` MCP tool | `favorite_note` |
|
||||
| `pin-btn` / `pin-indicator` test IDs | `favorite-btn` / `favorite-indicator` |
|
||||
|
||||
**Not renamed (internal/DB):**
|
||||
- `pinned` column in `notes` and `noteUserState` tables
|
||||
- `pinned` field in TypeScript types (`Note`, `NoteUpdate`, `NoteChange`)
|
||||
- `pinned` references in CRDT sync logic
|
||||
- `pinned` in server-side sync overlay
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Icon migration (16 files)
|
||||
1. `src/lib/components/Layout/Sidebar.svelte`
|
||||
2. `src/lib/components/Layout/Header.svelte`
|
||||
3. `src/lib/components/FormattingToolbar.svelte`
|
||||
4. `src/lib/components/NoteCard.svelte`
|
||||
5. `src/lib/components/NoteEditor.svelte`
|
||||
6. `src/lib/components/NoteHistory.svelte`
|
||||
7. `src/lib/components/SyncIndicator.svelte`
|
||||
8. `src/lib/components/SearchBar.svelte`
|
||||
9. `src/lib/components/ShareDialog.svelte`
|
||||
10. `src/lib/components/Checklist.svelte`
|
||||
11. `src/lib/components/CollaboratorPopover.svelte`
|
||||
12. `src/lib/components/ImageLightbox.svelte`
|
||||
13. `src/lib/components/ImageUpload.svelte`
|
||||
14. `src/lib/components/NotesView.svelte`
|
||||
15. `src/routes/(app)/settings/+layout.svelte`
|
||||
16. `src/routes/(app)/settings/mcp/+page.svelte`
|
||||
|
||||
### Favorites rename (7 files)
|
||||
1. `src/lib/stores/notes.ts` — store names + `toggleFavorite`
|
||||
2. `src/lib/components/NoteCard.svelte` — tooltips, test IDs, icon
|
||||
3. `src/lib/components/NotesView.svelte` — label, store imports
|
||||
4. `src/lib/server/mcp/server.ts` — tool name + description
|
||||
5. `docs/FEATURES.md` — update feature description
|
||||
6. `tests/e2e/organization.spec.ts` — `pin-btn` test ID + `getByText('Pinned')` → `getByText('Favorites')` + scenario names/comments
|
||||
7. Note: `tests/e2e/sharing.spec.ts` references `pinned` as a data field — does NOT need renaming (DB field stays `pinned`)
|
||||
|
||||
### Cleanup
|
||||
- Remove `lucide-svelte` from `package.json` once only 4 icons remain — or keep it as a dependency for those 4. Decision: keep it, the 4 formatting icons are deeply integrated.
|
||||
|
||||
### Documentation
|
||||
- Update `CLAUDE.md` "Icons" section to document pixelarticons usage
|
||||
- Update `docs/FEATURES.md` if it mentions pinning
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `pnpm check` after migration to catch type errors
|
||||
- Run `pnpm test:unit` to verify sync/CRDT tests still pass (they use `pinned` field which is unchanged)
|
||||
- Run `pnpm test:e2e` to verify UI still works — tests referencing `pin-btn`/`pin-indicator` will need updating
|
||||
- Visual verification: icons render correctly at all sizes (12-20px)
|
||||
|
||||
## Rendering Note
|
||||
|
||||
Pixelarticons are designed for the 24×24 grid and render sharpest at multiples of 24px. At the sizes used in this app (12-20px), they will still look good because `@iconify/svelte` renders them as SVG (vector), but the pixel-art crispness is maximized at 24px. Consider using `image-rendering: pixelated` on icon SVGs if sub-24px rendering looks blurry, though SVG scaling typically handles this well.
|
||||
+3
-10
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.22.3",
|
||||
"version": "0.20.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
@@ -12,8 +12,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "npx tsx scripts/generate-openapi.ts && vite build",
|
||||
"openapi": "npx tsx scripts/generate-openapi.ts",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -33,8 +32,6 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/pixelarticons": "^1.2.4",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
@@ -57,7 +54,6 @@
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"node-diff3": "^3.2.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
@@ -87,9 +83,7 @@
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-istanbul": "^7.2.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-window": "^7.4.0",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -101,8 +95,7 @@
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0",
|
||||
"hono": ">=4.12.7"
|
||||
"cookie": ">=0.7.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
serviceWorkers: 'block'
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
|
||||
Generated
+8
-52
@@ -8,18 +8,11 @@ overrides:
|
||||
serialize-javascript: '>=7.0.3'
|
||||
esbuild@<0.25.0: 0.25.12
|
||||
cookie: '>=0.7.0'
|
||||
hono: '>=4.12.7'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@iconify-json/pixelarticons':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
'@iconify/svelte':
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1(svelte@5.53.7)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.27.1
|
||||
version: 1.27.1(zod@4.3.6)
|
||||
@@ -86,9 +79,6 @@ importers:
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
node-diff3:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
nodemailer:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
@@ -171,15 +161,9 @@ importers:
|
||||
vite-plugin-istanbul:
|
||||
specifier: ^7.2.1
|
||||
version: 7.2.1(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vite-plugin-pwa:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(workbox-build@7.4.0)(workbox-window@7.4.0)
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
workbox-window:
|
||||
specifier: ^7.4.0
|
||||
version: 7.4.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
@@ -1022,18 +1006,7 @@ packages:
|
||||
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: '>=4.12.7'
|
||||
|
||||
'@iconify-json/pixelarticons@1.2.4':
|
||||
resolution: {integrity: sha512-nADJKEI3mlxDSmJMLqGvNuvNl1TGa+VbFJIwrtj/8vUA4m15qPNiwxMyw8YxW3gVokuO9MNU462B8uK1hb4rqw==}
|
||||
|
||||
'@iconify/svelte@5.2.1':
|
||||
resolution: {integrity: sha512-zHmsIPmnIhGd5gc95bNN5FL+GifwMZv7M2rlZEpa7IXYGFJm/XGHdWf6PWQa6OBoC+R69WyiPO9NAj5wjfjbow==}
|
||||
peerDependencies:
|
||||
svelte: '>5.0.0'
|
||||
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
hono: ^4
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
@@ -2529,8 +2502,8 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hono@4.12.7:
|
||||
resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==}
|
||||
hono@4.12.5:
|
||||
resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
@@ -3030,10 +3003,6 @@ packages:
|
||||
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
node-diff3@3.2.0:
|
||||
resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==}
|
||||
engines: {bun: '>=1.3.0', node: '>=18'}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
@@ -4864,20 +4833,9 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@hono/node-server@1.19.11(hono@4.12.7)':
|
||||
'@hono/node-server@1.19.11(hono@4.12.5)':
|
||||
dependencies:
|
||||
hono: 4.12.7
|
||||
|
||||
'@iconify-json/pixelarticons@1.2.4':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify/svelte@5.2.1(svelte@5.53.7)':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
svelte: 5.53.7
|
||||
|
||||
'@iconify/types@2.0.0': {}
|
||||
hono: 4.12.5
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
@@ -4926,7 +4884,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.11(hono@4.12.7)
|
||||
'@hono/node-server': 1.19.11(hono@4.12.5)
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
content-type: 1.0.5
|
||||
@@ -4936,7 +4894,7 @@ snapshots:
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.3.0(express@5.2.1)
|
||||
hono: 4.12.7
|
||||
hono: 4.12.5
|
||||
jose: 6.2.0
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
@@ -6378,7 +6336,7 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hono@4.12.7: {}
|
||||
hono@4.12.5: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
@@ -6821,8 +6779,6 @@ snapshots:
|
||||
|
||||
node-addon-api@8.6.0: {}
|
||||
|
||||
node-diff3@3.2.0: {}
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-preload@0.2.1:
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Auto-generates an OpenAPI spec from the SvelteKit route tree.
|
||||
*
|
||||
* Scans all +server.ts files under src/routes/api/ for exported HTTP methods and
|
||||
* builds a minimal OpenAPI 3.1 spec at static/.well-known/openapi.json.
|
||||
*
|
||||
* Usage: npx tsx scripts/generate-openapi.ts
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
||||
import { resolve, relative, dirname, join } from 'path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const ROUTES_DIR = resolve(ROOT, 'src/routes/api');
|
||||
const OUTPUT = resolve(ROOT, 'static/api/openapi.json');
|
||||
|
||||
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8'));
|
||||
|
||||
function walkDir(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
results.push(...walkDir(full));
|
||||
} else if (entry === '+server.ts') {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
|
||||
const METHOD_RE = new RegExp(`export\\s+const\\s+(${HTTP_METHODS.join('|')})`, 'g');
|
||||
|
||||
function svelteKitPathToOpenAPI(filePath: string): string {
|
||||
const rel = relative(ROUTES_DIR, dirname(filePath));
|
||||
const segments = rel === '' ? [] : rel.split('/');
|
||||
const apiPath =
|
||||
'/api/' +
|
||||
segments
|
||||
.map((s) => {
|
||||
const match = s.match(/^\[(.+)\]$/);
|
||||
return match ? `{${match[1]}}` : s;
|
||||
})
|
||||
.join('/');
|
||||
return apiPath;
|
||||
}
|
||||
|
||||
function extractMethods(filePath: string): string[] {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const methods: string[] = [];
|
||||
let match;
|
||||
while ((match = METHOD_RE.exec(content)) !== null) {
|
||||
methods.push(match[1].toLowerCase());
|
||||
}
|
||||
METHOD_RE.lastIndex = 0;
|
||||
return methods;
|
||||
}
|
||||
|
||||
function inferTag(path: string): string {
|
||||
const first = path.replace('/api/', '').split('/')[0];
|
||||
return first.charAt(0).toUpperCase() + first.slice(1);
|
||||
}
|
||||
|
||||
function extractPathParams(path: string) {
|
||||
const params: { name: string; in: string; required: boolean; schema: { type: string } }[] = [];
|
||||
const re = /\{(\w+)\}/g;
|
||||
let match;
|
||||
while ((match = re.exec(path)) !== null) {
|
||||
params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } });
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const files = walkDir(ROUTES_DIR);
|
||||
const paths: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
for (const fullPath of files) {
|
||||
const apiPath = svelteKitPathToOpenAPI(fullPath);
|
||||
const methods = extractMethods(fullPath);
|
||||
|
||||
if (methods.length === 0) continue;
|
||||
|
||||
const pathParams = extractPathParams(apiPath);
|
||||
const pathEntry: Record<string, unknown> = {};
|
||||
|
||||
if (pathParams.length > 0) {
|
||||
pathEntry.parameters = pathParams;
|
||||
}
|
||||
|
||||
for (const method of methods) {
|
||||
const operation: Record<string, unknown> = {
|
||||
tags: [inferTag(apiPath)],
|
||||
responses: {
|
||||
'200': { description: 'Success' }
|
||||
}
|
||||
};
|
||||
|
||||
if (['post', 'put', 'patch'].includes(method)) {
|
||||
operation.requestBody = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'object' }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pathEntry[method] = operation;
|
||||
}
|
||||
|
||||
paths[apiPath] = pathEntry;
|
||||
}
|
||||
|
||||
const sortedPaths: Record<string, Record<string, unknown>> = {};
|
||||
for (const key of Object.keys(paths).sort()) {
|
||||
sortedPaths[key] = paths[key];
|
||||
}
|
||||
|
||||
const spec = {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: `${pkg.name.charAt(0).toUpperCase() + pkg.name.slice(1)} API`,
|
||||
description: `Auto-generated API spec for ${pkg.name}`,
|
||||
version: pkg.version,
|
||||
contact: {
|
||||
name: 'Bretzel',
|
||||
url: 'https://bretzel.app'
|
||||
}
|
||||
},
|
||||
servers: [{ url: '/', description: 'Current instance' }],
|
||||
security: [{ sessionCookie: [] }],
|
||||
paths: sortedPaths,
|
||||
components: {
|
||||
securitySchemes: {
|
||||
sessionCookie: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'session'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mkdirSync(dirname(OUTPUT), { recursive: true });
|
||||
writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + '\n');
|
||||
console.log(`Generated ${relative(ROOT, OUTPUT)} with ${Object.keys(paths).length} paths`);
|
||||
Vendored
-3
@@ -1,6 +1,3 @@
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
/// <reference types="vite-plugin-pwa/vanillajs" />
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
@@ -120,7 +123,7 @@
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2 py-1.5" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<Icon icon="pixelarticons:drag-and-drop" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -144,7 +147,7 @@
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -155,7 +158,7 @@
|
||||
class="flex items-center gap-2 py-1.5 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
|
||||
<Plus class="h-4 w-4" />
|
||||
Add item
|
||||
</button>
|
||||
|
||||
@@ -166,7 +169,7 @@
|
||||
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-toggle-done"
|
||||
>
|
||||
<Icon icon="pixelarticons:chevron-right" class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
{doneCount} done
|
||||
</button>
|
||||
|
||||
@@ -188,7 +191,7 @@
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
|
||||
interface Props {
|
||||
collaborators: Collaborator[];
|
||||
@@ -42,7 +42,7 @@
|
||||
onclick={handleClick}
|
||||
data-testid="collaborator-indicator"
|
||||
>
|
||||
<Icon icon="pixelarticons:users" class="h-4 w-4" />
|
||||
<Users class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if showPopover}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import {
|
||||
Undo2,
|
||||
Redo2,
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
TextQuote,
|
||||
CodeXml,
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Underline
|
||||
Code,
|
||||
Underline,
|
||||
Link,
|
||||
CornerDownLeft,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown,
|
||||
Table2,
|
||||
BetweenHorizontalEnd,
|
||||
BetweenVerticalEnd,
|
||||
RemoveFormatting
|
||||
} from 'lucide-svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
interface Props {
|
||||
tick?: number;
|
||||
@@ -106,7 +128,7 @@
|
||||
title="Undo"
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" width={iconSize} />
|
||||
<Undo2 size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
@@ -115,7 +137,7 @@
|
||||
title="Redo"
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Icon icon="pixelarticons:redo" width={iconSize} />
|
||||
<Redo2 size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
@@ -164,8 +186,8 @@
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
<Icon icon="pixelarticons:heading" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
@@ -205,8 +227,8 @@
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:list" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
@@ -215,7 +237,7 @@
|
||||
class={dropdownItemClass(isActive('bulletList'))}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:list" width={iconSize} />
|
||||
<List size={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -223,7 +245,7 @@
|
||||
class={dropdownItemClass(isActive('orderedList'))}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:bulletlist" width={iconSize} />
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -231,7 +253,7 @@
|
||||
class={dropdownItemClass(isActive('taskList'))}
|
||||
data-testid="format-task-list"
|
||||
>
|
||||
<Icon icon="pixelarticons:checklist" width={iconSize} />
|
||||
<ListChecks size={iconSize} />
|
||||
<span>Task list</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -246,7 +268,7 @@
|
||||
title="Insert link (Ctrl+K)"
|
||||
data-testid="format-link"
|
||||
>
|
||||
<Icon icon="pixelarticons:link" width={iconSize} />
|
||||
<Link size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'link'}
|
||||
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
@@ -268,7 +290,7 @@
|
||||
title="Apply link"
|
||||
data-testid="format-link-apply"
|
||||
>
|
||||
<Icon icon="pixelarticons:corner-down-left" width={16} />
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
@@ -279,7 +301,7 @@
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
<Icon icon="pixelarticons:external-link" width={16} />
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -289,7 +311,7 @@
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" width={16} />
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -304,8 +326,8 @@
|
||||
title="Table"
|
||||
data-testid="format-table"
|
||||
>
|
||||
<Icon icon="pixelarticons:table" width={iconSize} />
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<Table2 size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'table'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[190px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
@@ -314,7 +336,7 @@
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-insert"
|
||||
>
|
||||
<Icon icon="pixelarticons:table" width={iconSize} />
|
||||
<Table2 size={iconSize} />
|
||||
<span>Insert table</span>
|
||||
</button>
|
||||
{#if isActive('table')}
|
||||
@@ -324,7 +346,7 @@
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-row"
|
||||
>
|
||||
<Icon icon="pixelarticons:add-row" width={iconSize} />
|
||||
<BetweenHorizontalEnd size={iconSize} />
|
||||
<span>Add row</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -332,7 +354,7 @@
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-add-col"
|
||||
>
|
||||
<Icon icon="pixelarticons:add-col" width={iconSize} />
|
||||
<BetweenVerticalEnd size={iconSize} />
|
||||
<span>Add column</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
@@ -341,7 +363,7 @@
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-row"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete row</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -349,7 +371,7 @@
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-table-delete-col"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<Minus size={iconSize} />
|
||||
<span>Delete column</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -357,7 +379,7 @@
|
||||
class={`${dropdownItemClass()} text-[var(--destructive)]`}
|
||||
data-testid="format-table-delete"
|
||||
>
|
||||
<Icon icon="pixelarticons:remove-box" width={iconSize} />
|
||||
<RemoveFormatting size={iconSize} />
|
||||
<span>Delete table</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -374,15 +396,15 @@
|
||||
data-testid="format-align"
|
||||
>
|
||||
{#if isActive({ textAlign: 'center' })}
|
||||
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
|
||||
<AlignCenter size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'right' })}
|
||||
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
|
||||
<AlignRight size={iconSize} />
|
||||
{:else if isActive({ textAlign: 'justify' })}
|
||||
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
|
||||
<AlignJustify size={iconSize} />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
|
||||
<AlignLeft size={iconSize} />
|
||||
{/if}
|
||||
<Icon icon="pixelarticons:chevron-down" width={chevronSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[160px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
@@ -391,7 +413,7 @@
|
||||
class={dropdownItemClass(isActive({ textAlign: 'left' }))}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-left" width={iconSize} />
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Left</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -399,7 +421,7 @@
|
||||
class={dropdownItemClass(isActive({ textAlign: 'center' }))}
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-center" width={iconSize} />
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Center</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -407,7 +429,7 @@
|
||||
class={dropdownItemClass(isActive({ textAlign: 'right' }))}
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-right" width={iconSize} />
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Right</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -415,7 +437,7 @@
|
||||
class={dropdownItemClass(isActive({ textAlign: 'justify' }))}
|
||||
data-testid="format-align-justify"
|
||||
>
|
||||
<Icon icon="pixelarticons:text-align-justify" width={iconSize} />
|
||||
<AlignJustify size={iconSize} />
|
||||
<span>Justify</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -431,7 +453,7 @@
|
||||
title="Inline code"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Icon icon="pixelarticons:braces" width={iconSize} />
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
@@ -439,7 +461,7 @@
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<Icon icon="pixelarticons:quote-text-inline" width={iconSize} />
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
@@ -447,7 +469,7 @@
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<Icon icon="pixelarticons:braces" width={iconSize} />
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
@@ -455,6 +477,6 @@
|
||||
title="Divider"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Icon icon="pixelarticons:minus" width={iconSize} />
|
||||
<Minus size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
@@ -46,7 +46,7 @@
|
||||
aria-label="Close lightbox"
|
||||
data-testid="lightbox-close"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-6 w-6" />
|
||||
<XIcon class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||
@@ -131,7 +133,7 @@
|
||||
</button>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<Icon icon="pixelarticons:reload" class="h-3 w-3 text-white" />
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
</div>
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
@@ -140,7 +142,7 @@
|
||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||
data-testid="toggle-featured"
|
||||
>
|
||||
<Icon icon="pixelarticons:pin" class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
@@ -149,7 +151,7 @@
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-3 w-3" />
|
||||
<XIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle: () => void;
|
||||
@@ -23,7 +24,7 @@
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Icon icon="pixelarticons:menu" class="h-6 w-6 text-[var(--text)]" />
|
||||
<Menu class="h-6 w-6 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -42,7 +43,7 @@
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Icon icon="pixelarticons:search" class="h-5 w-5 text-[var(--text)]" />
|
||||
<Search class="h-5 w-5 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { allTags } from '$lib/stores/notes.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { StickyNote, Archive, Trash2, Tag, Settings } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -17,9 +17,9 @@
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Crumbs', icon: 'pixelarticons:sticky-note', match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: 'pixelarticons:archive', match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: 'pixelarticons:trash', match: (p: string) => p === '/trash' }
|
||||
{ href: '/', label: 'Crumbs', icon: StickyNote, match: (p: string) => p === '/' },
|
||||
{ href: '/archive', label: 'Archive', icon: Archive, match: (p: string) => p === '/archive' },
|
||||
{ href: '/trash', label: 'Trash', icon: Trash2, match: (p: string) => p === '/trash' }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {item.match($page.url.pathname) ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon={item.icon} width={20} />
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
@@ -62,7 +62,7 @@
|
||||
onclick={closeMobile}
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$page.url.pathname === `/tag/${tag}` ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:label" width={16} />
|
||||
<Tag size={16} />
|
||||
#{tag}
|
||||
</a>
|
||||
</li>
|
||||
@@ -78,7 +78,7 @@
|
||||
onclick={closeMobile}
|
||||
data-testid="settings-link"
|
||||
>
|
||||
<Icon icon="pixelarticons:settings-cog" width={20} />
|
||||
<Settings size={20} />
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { toggleFavorite, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import CollaboratorPopover from './CollaboratorPopover.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
@@ -53,6 +58,20 @@
|
||||
};
|
||||
}
|
||||
|
||||
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 -->
|
||||
@@ -104,12 +123,12 @@
|
||||
{/if}
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unfavorite"
|
||||
data-testid="favorite-indicator"
|
||||
title="Unpin"
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Icon icon="pixelarticons:heart" class="h-4 w-4 fill-[var(--primary)]" />
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -125,8 +144,8 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
disabled
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
||||
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
<span class="break-words min-w-0">{item.text}</span>
|
||||
@@ -151,7 +170,7 @@
|
||||
title="Restore"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" class="h-4 w-4" />
|
||||
<Undo2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
@@ -159,17 +178,17 @@
|
||||
title="Delete forever"
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" class="h-4 w-4 text-[var(--destructive)]" />
|
||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||
</button>
|
||||
{:else}
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => toggleFavorite(note.id, note.pinned))}
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Favorite"
|
||||
data-testid="favorite-btn"
|
||||
title="Pin"
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:heart" class="h-4 w-4" />
|
||||
<Bookmark class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $currentFilter === 'archived'}
|
||||
@@ -179,7 +198,7 @@
|
||||
title="Unarchive"
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
|
||||
<ArchiveRestore class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -188,7 +207,7 @@
|
||||
title="Archive"
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:archive" class="h-4 w-4" />
|
||||
<Archive class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if note.isShared && !note.isOwner}
|
||||
@@ -198,7 +217,7 @@
|
||||
title="Leave note"
|
||||
data-testid="leave-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -207,7 +226,7 @@
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" class="h-4 w-4" />
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
@@ -13,7 +12,15 @@
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
@@ -49,15 +56,6 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
// Guard against mobile ghost clicks: on touch devices, a tap on the note card
|
||||
// can produce a synthetic click that lands on editor buttons rendered at the same
|
||||
// coordinates. Suppress pointer events on toolbar controls until mount completes.
|
||||
let toolbarInteractive = $state(false);
|
||||
onMount(() => {
|
||||
const timer = setTimeout(() => { toolbarInteractive = true; }, 150);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -172,19 +170,36 @@
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
||||
// pointer events are never synthetically re-dispatched from touch, so
|
||||
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
||||
// Both pointerdown and pointerup must target the overlay itself to close.
|
||||
let pointerdownOnOverlay = false;
|
||||
// Track whether mousedown started on the overlay (not inside the editor).
|
||||
// Prevents closing when the user selects text inside the editor and
|
||||
// releases the mouse outside — the click event fires on the overlay,
|
||||
// but we only want to close if the press also started there.
|
||||
let mousedownOnOverlay = false;
|
||||
|
||||
function handleOverlayPointerdown(e: PointerEvent) {
|
||||
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||
// On mobile, the tap that opens the editor produces synthetic mouse events
|
||||
// (mousedown → mouseup → click) at the original touch coordinates. If those
|
||||
// coordinates land on the overlay backdrop, the editor would close instantly.
|
||||
// Guard against this by ignoring mousedowns that arrive within the first
|
||||
// animation frame after mount.
|
||||
let overlayReady = false;
|
||||
$effect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
overlayReady = true;
|
||||
});
|
||||
return () => {
|
||||
overlayReady = false;
|
||||
cancelAnimationFrame(id);
|
||||
};
|
||||
});
|
||||
|
||||
function handleOverlayMousedown(e: MouseEvent) {
|
||||
if (!overlayReady) return;
|
||||
mousedownOnOverlay = e.target === e.currentTarget;
|
||||
}
|
||||
|
||||
function handleOverlayPointerup(e: PointerEvent) {
|
||||
if (pointerdownOnOverlay && e.target === e.currentTarget) saveAndClose();
|
||||
pointerdownOnOverlay = false;
|
||||
function handleOverlayClick() {
|
||||
if (mousedownOnOverlay) saveAndClose();
|
||||
mousedownOnOverlay = false;
|
||||
}
|
||||
|
||||
async function saveAndClose() {
|
||||
@@ -248,8 +263,8 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onpointerdown={handleOverlayPointerdown}
|
||||
onpointerup={handleOverlayPointerup}
|
||||
onmousedown={handleOverlayMousedown}
|
||||
onclick={handleOverlayClick}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
@@ -257,6 +272,7 @@
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
data-testid="note-editor"
|
||||
>
|
||||
@@ -318,16 +334,11 @@
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<div style={toolbarInteractive ? '' : 'pointer-events: none'}>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
</div>
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2"
|
||||
style={toolbarInteractive ? '' : 'pointer-events: none'}
|
||||
>
|
||||
<div class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Color picker toggle -->
|
||||
<div class="relative">
|
||||
@@ -337,7 +348,7 @@
|
||||
title="Background color"
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<Icon icon="pixelarticons:colors-swatch" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
{#if showColorPicker}
|
||||
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
|
||||
@@ -354,9 +365,9 @@
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
{#if checklistMode}
|
||||
<Icon icon="pixelarticons:art-text" class="h-5 w-5" />
|
||||
<Type class="h-5 w-5" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:checkbox-on" class="h-5 w-5" />
|
||||
<SquareCheck class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -367,7 +378,7 @@
|
||||
title="Image attachments"
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<Icon icon="pixelarticons:image" class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
@@ -379,9 +390,9 @@
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
<Icon icon="pixelarticons:file-text" class="h-5 w-5" />
|
||||
<FileText class="h-5 w-5" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:script" class="h-5 w-5" />
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -394,14 +405,14 @@
|
||||
data-testid="share-toggle"
|
||||
>
|
||||
{#if isShared}
|
||||
<Icon icon="pixelarticons:users" class="h-5 w-5 text-[var(--primary)]" />
|
||||
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:user-plus" class="h-5 w-5" />
|
||||
<UserPlus class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isShared}
|
||||
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||
<Icon icon="pixelarticons:users" class="h-5 w-5" />
|
||||
<Users class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -413,7 +424,7 @@
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<Icon icon="pixelarticons:timeline" class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface Props {
|
||||
@@ -109,7 +111,7 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="pixelarticons:clock" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<Clock class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -118,7 +120,7 @@
|
||||
title="Close history"
|
||||
data-testid="close-history-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<X class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +190,7 @@
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
data-testid="confirm-restore-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
|
||||
</button>
|
||||
<button
|
||||
@@ -205,7 +207,7 @@
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:undo" class="h-3.5 w-3.5" />
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
Restore this version
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { favoriteNotes, unfavoritedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Note, NoteFilter } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
interface Props {
|
||||
filter: NoteFilter;
|
||||
@@ -60,7 +60,7 @@
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" class="h-4 w-4" />
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@
|
||||
aria-label="Add a crumb"
|
||||
data-testid="new-note-fab"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" class="h-5 w-5" />
|
||||
<Plus class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -86,22 +86,22 @@
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">#{tag}</h2>
|
||||
{/if}
|
||||
|
||||
{#if $favoriteNotes.length > 0}
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$favoriteNotes} label="Favorites" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NoteGrid
|
||||
notes={$unfavoritedNotes}
|
||||
label={$favoriteNotes.length > 0 ? 'Others' : ''}
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $favoriteNotes.length === 0 && $unfavoritedNotes.length === 0}
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
@@ -57,10 +59,10 @@
|
||||
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
|
||||
{#if onClose}
|
||||
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
|
||||
<Icon icon="pixelarticons:arrow-left" class="h-5 w-5" />
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:search" class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
<Search class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
@@ -73,7 +75,7 @@
|
||||
/>
|
||||
{#if query}
|
||||
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
|
||||
<Icon icon="pixelarticons:close" class="h-5 w-5" />
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Collaborator } from '$lib/types/index.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface UserResult {
|
||||
@@ -117,14 +119,14 @@
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
title="Close"
|
||||
>
|
||||
<Icon icon="pixelarticons:close" class="h-4 w-4" />
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="relative px-4 pt-3">
|
||||
<div class="relative">
|
||||
<Icon icon="pixelarticons:search" class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
@@ -179,7 +181,7 @@
|
||||
title="Remove"
|
||||
data-testid="remove-collaborator-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:user-minus" class="h-4 w-4" />
|
||||
<UserMinus class="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSyncStatus, onSyncStatusChange, sync, type SyncStatus } from '$lib/sync/client.js';
|
||||
import Icon from '@iconify/svelte';
|
||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
|
||||
|
||||
let status: SyncStatus = $state(getSyncStatus());
|
||||
let displayStatus: SyncStatus = $state(getSyncStatus());
|
||||
@@ -48,12 +51,12 @@
|
||||
data-testid="sync-indicator"
|
||||
>
|
||||
{#if displayStatus === 'synced'}
|
||||
<Icon icon="pixelarticons:cloud-done" class="h-5 w-5 text-[var(--primary)]" />
|
||||
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<Icon icon="pixelarticons:sync" class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
<CloudSync class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
<CloudOff class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:cloud" class="h-5 w-5 text-[var(--destructive)]" />
|
||||
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -228,12 +228,6 @@ export function getUser(userId: number): User | null {
|
||||
|
||||
export async function deleteUser(userId: number): Promise<void> {
|
||||
// Cascade: delete user's data in dependency order
|
||||
sqlite.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM api_keys WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM note_user_state WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
.prepare('DELETE FROM note_collaborators WHERE user_id = ? OR added_by = ?')
|
||||
.run(userId, userId);
|
||||
sqlite.prepare('DELETE FROM sync_log WHERE user_id = ?').run(userId);
|
||||
sqlite.prepare('DELETE FROM attachments WHERE user_id = ?').run(userId);
|
||||
sqlite
|
||||
|
||||
+15
-252
@@ -65,125 +65,6 @@ export async function sendShareNotification(
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome email to a newly created user.
|
||||
*/
|
||||
export async function sendWelcomeEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Welcome to Crumbs';
|
||||
const html = buildWelcomeEmailHtml(toName, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a password reset notification to a user (triggered by admin reset).
|
||||
*/
|
||||
export async function sendPasswordResetEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs password has been reset';
|
||||
const html = buildPasswordResetEmailHtml(toName, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an account deletion confirmation email.
|
||||
*/
|
||||
export async function sendAccountDeletedEmail(toEmail: string, toName: string): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs account has been deleted';
|
||||
const html = buildAccountDeletedEmailHtml(toName);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the old email when the account email address changes.
|
||||
*/
|
||||
export async function sendEmailChangedEmail(
|
||||
oldEmail: string,
|
||||
toName: string,
|
||||
newEmail: string
|
||||
): Promise<void> {
|
||||
if (!oldEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs email address has been changed';
|
||||
const html = buildEmailChangedEmailHtml(toName, newEmail);
|
||||
await sendEmail(oldEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a role change notification to a user.
|
||||
*/
|
||||
export async function sendRoleChangedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
newRole: 'admin' | 'user',
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Your Crumbs account role has been updated';
|
||||
const html = buildRoleChangedEmailHtml(toName, newRole, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a collaborator that they have been removed from a note.
|
||||
*/
|
||||
export async function sendCollaboratorRemovedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
ownerName: string,
|
||||
noteTitle: string,
|
||||
appUrl: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = `You've been removed from a shared note`;
|
||||
const html = buildCollaboratorRemovedEmailHtml(toName, ownerName, noteTitle, appUrl);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to collaborators that a shared note has been permanently deleted.
|
||||
*/
|
||||
export async function sendNotePermanentlyDeletedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
noteTitle: string
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = `A shared note has been deleted`;
|
||||
const html = buildNotePermanentlyDeletedEmailHtml(toName, noteTitle);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a security alert to a user when their account is locked due to too many failed logins.
|
||||
*/
|
||||
export async function sendAccountLockedEmail(
|
||||
toEmail: string,
|
||||
toName: string,
|
||||
retryAfterMinutes: number
|
||||
): Promise<void> {
|
||||
if (!toEmail) return;
|
||||
|
||||
const subject = 'Crumbs account temporarily locked';
|
||||
const html = buildAccountLockedEmailHtml(toName, retryAfterMinutes);
|
||||
await sendEmail(toEmail, subject, html);
|
||||
}
|
||||
|
||||
function buildShareEmailHtml(
|
||||
toName: string,
|
||||
fromName: string,
|
||||
@@ -193,18 +74,7 @@ function buildShareEmailHtml(
|
||||
const displayName = toName || 'there';
|
||||
const escapedTitle = escapeHtml(noteTitle);
|
||||
const escapedFrom = escapeHtml(fromName || 'Someone');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${escapeHtml(displayName)},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
<strong>${escapedFrom}</strong> shared a note with you: <strong>“${escapedTitle}”</strong>
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because someone shared a note with you on Crumbs.');
|
||||
}
|
||||
|
||||
function buildEmailLayout(content: string, footer: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
@@ -214,8 +84,21 @@ function buildEmailLayout(content: string, footer: string): string {
|
||||
<table width="500" cellpadding="0" cellspacing="0" style="background-color:#faf5eb;border:1px solid #d4cabb;box-shadow:2px 2px 0px #d4cabb;">
|
||||
<tr><td style="padding:32px;">
|
||||
<p style="margin:0 0 8px;font-size:11px;color:#C8860A;text-transform:uppercase;letter-spacing:2px;">Crumbs</p>
|
||||
${content}
|
||||
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">${footer}</p>
|
||||
<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Note shared with you</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${escapeHtml(displayName)},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
<strong>${escapedFrom}</strong> shared a note with you: <strong>“${escapedTitle}”</strong>
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
|
||||
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">Open Crumbs</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
<p style="margin:0;font-size:11px;color:#6b6272;line-height:1.5;">
|
||||
You’re receiving this because someone shared a note with you on Crumbs.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -224,126 +107,6 @@ function buildEmailLayout(content: string, footer: string): string {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildOpenButton(appUrl: string, label = 'Open Crumbs'): string {
|
||||
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||
<tr><td style="background-color:#C8860A;box-shadow:2px 2px 0px #1a1a2e;">
|
||||
<a href="${escapeHtml(appUrl)}" style="display:inline-block;padding:10px 24px;color:#faf5eb;font-size:13px;font-weight:bold;text-decoration:none;font-family:'JetBrains Mono',monospace;">${escapeHtml(label)}</a>
|
||||
</td></tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function buildWelcomeEmailHtml(toName: string, appUrl: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Welcome to Crumbs</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
An admin has created an account for you on Crumbs. You can log in and start taking notes right away.
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because an admin created an account for you on Crumbs.');
|
||||
}
|
||||
|
||||
function buildPasswordResetEmailHtml(toName: string, appUrl: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Password reset</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
An admin has reset your Crumbs password. Please log in and change it to something only you know.
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because an admin reset your password on Crumbs. If you didn’t expect this, please contact your admin.');
|
||||
}
|
||||
|
||||
function buildAccountDeletedEmailHtml(toName: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account deleted</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Your Crumbs account and all associated data have been permanently deleted.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this as confirmation that your Crumbs account was deleted.');
|
||||
}
|
||||
|
||||
function buildEmailChangedEmailHtml(toName: string, newEmail: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Email address changed</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Your Crumbs account email address has been changed to <strong>${escapeHtml(newEmail)}</strong>. If you did not make this change, please contact your admin immediately.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this security notice because your Crumbs account email was updated.');
|
||||
}
|
||||
|
||||
function buildRoleChangedEmailHtml(toName: string, newRole: 'admin' | 'user', appUrl: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const roleLabel = newRole === 'admin' ? 'Admin' : 'User';
|
||||
const detail = newRole === 'admin'
|
||||
? 'You now have admin privileges, including the ability to manage users and settings.'
|
||||
: 'Your account no longer has admin privileges.';
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Role updated: ${roleLabel}</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Your Crumbs account role has been updated to <strong>${escapeHtml(roleLabel)}</strong>. ${escapeHtml(detail)}
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because your account role was changed on Crumbs.');
|
||||
}
|
||||
|
||||
function buildCollaboratorRemovedEmailHtml(
|
||||
toName: string,
|
||||
ownerName: string,
|
||||
noteTitle: string,
|
||||
appUrl: string
|
||||
): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const escapedOwner = escapeHtml(ownerName || 'Someone');
|
||||
const escapedTitle = escapeHtml(noteTitle);
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Removed from shared note</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
<strong>${escapedOwner}</strong> has removed you from the shared note <strong>“${escapedTitle}”</strong>. You no longer have access to this note.
|
||||
</p>
|
||||
${buildOpenButton(appUrl)}`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because you were removed from a shared note on Crumbs.');
|
||||
}
|
||||
|
||||
function buildNotePermanentlyDeletedEmailHtml(toName: string, noteTitle: string): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const escapedTitle = escapeHtml(noteTitle);
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Shared note deleted</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
The shared note <strong>“${escapedTitle}”</strong> has been permanently deleted by its owner. You no longer have access to it.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this because a shared note you had access to was deleted on Crumbs.');
|
||||
}
|
||||
|
||||
function buildAccountLockedEmailHtml(toName: string, retryAfterMinutes: number): string {
|
||||
const displayName = escapeHtml(toName || 'there');
|
||||
const content = `<h1 style="margin:0 0 24px;font-size:18px;color:#1a1a2e;font-weight:bold;">Account temporarily locked</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Hey ${displayName},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#1a1a2e;line-height:1.6;">
|
||||
Too many failed login attempts were made on your Crumbs account. For your security, login has been temporarily blocked for <strong>${retryAfterMinutes} minute${retryAfterMinutes === 1 ? '' : 's'}</strong>. If this wasn’t you, please change your password once you regain access.
|
||||
</p>`;
|
||||
return buildEmailLayout(content, 'You’re receiving this security alert because of repeated failed login attempts on your Crumbs account.');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
|
||||
@@ -239,8 +239,8 @@ export function createMcpServer(userId: number): McpServer {
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'favorite_note',
|
||||
'Favorite or unfavorite a note',
|
||||
'pin_note',
|
||||
'Pin or unpin a note',
|
||||
{
|
||||
id: z.string().describe('Note ID'),
|
||||
pinned: z.boolean().describe('Whether to pin (true) or unpin (false)')
|
||||
@@ -257,7 +257,7 @@ export function createMcpServer(userId: number): McpServer {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Note "${result.title}" ${pinned ? 'favorited' : 'unfavorited'}`
|
||||
text: `Note "${result.title}" ${pinned ? 'pinned' : 'unpinned'}`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, users } from './db/schema.js';
|
||||
import { notes, noteCollaborators, noteUserState, users } from './db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import type { Db } from './db/index.js';
|
||||
import {
|
||||
@@ -233,31 +233,6 @@ describe('updateNote', () => {
|
||||
expect(updated!.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should create a snapshot when content changes', () => {
|
||||
seedNote('n1', { title: 'Original', content: 'Hello', color: 'default', checklistMode: false });
|
||||
updateNote(db, OWNER_ID, 'n1', { title: 'Changed' });
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not create a snapshot when no content field actually changed', () => {
|
||||
seedNote('n1', { title: 'Same', content: 'Same', color: 'default', checklistMode: false });
|
||||
// Send the same values — nothing actually changes
|
||||
updateNote(db, OWNER_ID, 'n1', { title: 'Same', content: 'Same' });
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not create a snapshot when only non-content fields change (pinned, archived)', () => {
|
||||
seedNote('n1', { title: 'Note', pinned: false });
|
||||
updateNote(db, OWNER_ID, 'n1', { pinned: true });
|
||||
|
||||
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
|
||||
expect(snapshots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should extract and sync tags on title/content change', () => {
|
||||
seedNote('n1', { title: 'Original' });
|
||||
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Db } from './db/index.js';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
|
||||
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
|
||||
import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
|
||||
@@ -8,7 +8,6 @@ import { fetchAttachmentsForNotes } from './attachments.js';
|
||||
import { fetchCollaboratorsForNotes } from './collaborators.js';
|
||||
import { canAccessNote } from './api-utils.js';
|
||||
import { createSnapshot } from './versions-service.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface NoteRow {
|
||||
@@ -206,7 +205,6 @@ export interface UpdateNoteInput {
|
||||
trashed?: boolean;
|
||||
checklistMode?: boolean;
|
||||
sortOrder?: number;
|
||||
baseVersion?: number;
|
||||
}
|
||||
|
||||
/** Per-user fields that go to noteUserState for collaborators */
|
||||
@@ -234,33 +232,7 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
let hasSharedUpdates = false;
|
||||
|
||||
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
|
||||
if (input.content !== undefined) {
|
||||
// 3-way merge to preserve concurrent edits from other users
|
||||
if (input.content !== existing.content) {
|
||||
const baseSnapshot = input.baseVersion !== undefined
|
||||
? db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get()
|
||||
: db.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, id))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (baseSnapshot && baseSnapshot.content !== existing.content) {
|
||||
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
} else {
|
||||
sharedUpdates.content = input.content;
|
||||
}
|
||||
hasSharedUpdates = true;
|
||||
}
|
||||
if (input.content !== undefined) { sharedUpdates.content = input.content; hasSharedUpdates = true; }
|
||||
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
|
||||
if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; }
|
||||
|
||||
@@ -322,21 +294,24 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
|
||||
}
|
||||
}
|
||||
|
||||
// Create a version snapshot only when at least one content field actually changed
|
||||
const hasActualContentChange =
|
||||
(input.title !== undefined && input.title !== existing.title) ||
|
||||
(input.content !== undefined && input.content !== existing.content) ||
|
||||
(input.color !== undefined && input.color !== existing.color) ||
|
||||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
|
||||
// Create a version snapshot whenever content-related fields are saved
|
||||
const hasContentUpdate =
|
||||
input.title !== undefined ||
|
||||
input.content !== undefined ||
|
||||
input.color !== undefined ||
|
||||
input.checklistMode !== undefined;
|
||||
|
||||
if (hasActualContentChange) {
|
||||
createSnapshot(db, id, {
|
||||
version: existing.version,
|
||||
title: existing.title,
|
||||
content: existing.content,
|
||||
checklistMode: existing.checklistMode,
|
||||
color: existing.color
|
||||
});
|
||||
if (hasContentUpdate) {
|
||||
const saved = db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
if (saved) {
|
||||
createSnapshot(db, id, {
|
||||
version: saved.version,
|
||||
title: saved.title,
|
||||
content: saved.content,
|
||||
checklistMode: saved.checklistMode,
|
||||
color: saved.color
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return getNote(db, userId, id);
|
||||
|
||||
@@ -9,7 +9,7 @@ const CONTENT_PREVIEW_LENGTH = 80;
|
||||
|
||||
/**
|
||||
* Create a snapshot of a note's current state.
|
||||
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||
* Called on every save. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
||||
*/
|
||||
export function createSnapshot(
|
||||
db: Db,
|
||||
|
||||
+5
-13
@@ -35,11 +35,11 @@ function sortNotes(list: Note[]): Note[] {
|
||||
});
|
||||
}
|
||||
|
||||
export const favoriteNotes = derived(filteredNotes, ($notes) =>
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => n.pinned))
|
||||
);
|
||||
|
||||
export const unfavoritedNotes = derived(filteredNotes, ($notes) =>
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned))
|
||||
);
|
||||
|
||||
@@ -142,19 +142,11 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
|
||||
}
|
||||
|
||||
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
||||
// Read current version for 3-way merge base on the server
|
||||
let baseVersion: number | undefined;
|
||||
notes.update((list) => {
|
||||
const current = list.find((n) => n.id === id);
|
||||
if (current) baseVersion = current.version;
|
||||
return list;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...updates, baseVersion })
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: Note = await res.json();
|
||||
@@ -181,7 +173,7 @@ export async function updateNote(id: string, updates: NoteUpdate): Promise<Note
|
||||
);
|
||||
if (optimistic) {
|
||||
await putNote(optimistic);
|
||||
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now(), baseVersion });
|
||||
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
}
|
||||
return optimistic;
|
||||
@@ -231,7 +223,7 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { archived: false });
|
||||
}
|
||||
|
||||
export async function toggleFavorite(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,13 @@ describe('preferences defaults', () => {
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open',
|
||||
notifyOnShare: true,
|
||||
notifyOnCollabRemoved: true,
|
||||
notifyOnNoteDeleted: true
|
||||
notifyOnShare: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should identify boolean preference keys', () => {
|
||||
expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('notifyOnShare')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('notifyOnCollabRemoved')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('notifyOnNoteDeleted')).toBe(true);
|
||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false);
|
||||
expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false);
|
||||
expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false);
|
||||
|
||||
+1
-17
@@ -100,26 +100,10 @@ async function pullChanges(): Promise<Note[]> {
|
||||
const localNotes = await getAllNotes();
|
||||
const localMap = new Map(localNotes.map((n) => [n.id, n]));
|
||||
|
||||
// Build pending-fields map from sync queue to protect unpushed local changes
|
||||
let pendingFieldsMap = new Map<string, Set<string>>();
|
||||
try {
|
||||
const queue = await getSyncQueue();
|
||||
for (const item of queue) {
|
||||
if (item.data) {
|
||||
const fields = pendingFieldsMap.get(item.noteId) || new Set<string>();
|
||||
Object.keys(item.data).forEach((k) => fields.add(k));
|
||||
pendingFieldsMap.set(item.noteId, fields);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Queue unavailable — merge without protection
|
||||
}
|
||||
|
||||
for (const remote of remoteNotes) {
|
||||
const local = localMap.get(remote.id);
|
||||
if (local) {
|
||||
const pendingFields = pendingFieldsMap.get(remote.id);
|
||||
const merged = mergeNotes(local, remote, pendingFields);
|
||||
const merged = mergeNotes(local, remote);
|
||||
await putNote(merged);
|
||||
} else {
|
||||
await putNote(remote);
|
||||
|
||||
+14
-24
@@ -1,39 +1,29 @@
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
/** Fields that can be individually merged between local and remote notes */
|
||||
const MUTABLE_FIELDS = ['title', 'content', 'color', 'pinned', 'archived', 'trashed', 'checklistMode', 'sortOrder'] as const;
|
||||
|
||||
/**
|
||||
* Last-Write-Wins (LWW) merge strategy with pending-field protection.
|
||||
*
|
||||
* When remote is newer, takes remote values for all fields EXCEPT those
|
||||
* listed in `pendingFields` — these represent locally-queued changes that
|
||||
* haven't been pushed yet and must be preserved.
|
||||
* Last-Write-Wins (LWW) merge strategy per field.
|
||||
* For a single-user app with multiple devices, this is sufficient.
|
||||
* Each field is resolved by comparing updatedAt timestamps.
|
||||
*/
|
||||
export function mergeNotes(local: Note, remote: Note, pendingFields?: Set<string>): Note {
|
||||
export function mergeNotes(local: Note, remote: Note): Note {
|
||||
// If remote is newer overall, take remote
|
||||
const localTime = new Date(local.updatedAt).getTime();
|
||||
const remoteTime = new Date(remote.updatedAt).getTime();
|
||||
|
||||
const remoteWins = remoteTime > localTime ||
|
||||
(remoteTime === localTime && remote.version > local.version);
|
||||
|
||||
if (!remoteWins) {
|
||||
return { ...local };
|
||||
}
|
||||
|
||||
// Remote wins — take remote as base, but preserve pending local fields
|
||||
if (!pendingFields || pendingFields.size === 0) {
|
||||
if (remoteTime > localTime) {
|
||||
return { ...remote };
|
||||
}
|
||||
|
||||
const merged = { ...remote };
|
||||
for (const field of MUTABLE_FIELDS) {
|
||||
if (pendingFields.has(field)) {
|
||||
(merged as unknown as Record<string, unknown>)[field] = (local as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
if (localTime > remoteTime) {
|
||||
return { ...local };
|
||||
}
|
||||
|
||||
return merged;
|
||||
// Same timestamp - prefer higher version number
|
||||
if (remote.version > local.version) {
|
||||
return { ...remote };
|
||||
}
|
||||
|
||||
return { ...local };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface SyncQueueItem {
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
data?: Partial<Note>;
|
||||
timestamp: number;
|
||||
baseVersion?: number;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
|
||||
|
||||
@@ -224,69 +224,21 @@ describe('processSyncPush — shared notes', () => {
|
||||
expect(note.trashed).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply field changes even when timestamp is older (no timestamp gate)', async () => {
|
||||
it('should reject changes with older timestamp than server note', async () => {
|
||||
const t2 = new Date('2024-01-02T00:00:00Z');
|
||||
seedSharedNote('n1', { updatedAt: t2, createdAt: new Date('2024-01-01T00:00:00Z') });
|
||||
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt.
|
||||
// With field-level sync, the title change should still be applied because
|
||||
// the sync data only contains fields the client explicitly modified.
|
||||
// Collaborator's change has a timestamp BEFORE the server's updatedAt
|
||||
const t1 = new Date('2024-01-01T12:00:00Z').getTime();
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t1,
|
||||
data: { title: 'Offline edit' }
|
||||
data: { title: 'Stale edit' }
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
expect(note.title).toBe('Offline edit');
|
||||
// updatedAt should be max of change timestamp and server timestamp
|
||||
expect(note.updatedAt.getTime()).toBe(t2.getTime());
|
||||
});
|
||||
|
||||
it('should merge checklist content when collaborator pushes concurrent content change', async () => {
|
||||
const t1 = new Date('2024-01-01T00:00:00Z');
|
||||
seedSharedNote('n1', {
|
||||
updatedAt: t1,
|
||||
createdAt: t1,
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true
|
||||
});
|
||||
|
||||
// Simulate owner editing content on server (checking Bread)
|
||||
const t2 = new Date('2024-01-01T12:00:00Z');
|
||||
db.update(notes)
|
||||
.set({ content: '- [ ] Milk\n- [x] Bread', updatedAt: t2, version: 2 })
|
||||
.where(eq(notes.id, 'n1'))
|
||||
.run();
|
||||
|
||||
// Create a version snapshot for the base content (version 1)
|
||||
const { noteVersions } = await import('../server/db/schema.js');
|
||||
db.insert(noteVersions).values({
|
||||
id: 'snap-1',
|
||||
noteId: 'n1',
|
||||
version: 1,
|
||||
title: 'Shared Note',
|
||||
content: '- [ ] Milk\n- [ ] Bread',
|
||||
checklistMode: true,
|
||||
color: 'default',
|
||||
createdAt: t1
|
||||
}).run();
|
||||
|
||||
// Collaborator was offline since version 1, checked Milk
|
||||
const t3 = new Date('2024-01-01T13:00:00Z').getTime();
|
||||
await processSyncPush(db, [{
|
||||
noteId: 'n1',
|
||||
operation: 'update',
|
||||
timestamp: t3,
|
||||
data: { content: '- [x] Milk\n- [ ] Bread' },
|
||||
baseVersion: 1
|
||||
}], COLLAB_ID);
|
||||
|
||||
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
|
||||
// Both changes should be merged: Milk checked by collab, Bread checked by owner
|
||||
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
|
||||
expect(note.title).toBe('Shared Note'); // Unchanged
|
||||
});
|
||||
|
||||
it('should not allow non-collaborator to update the note', async () => {
|
||||
|
||||
+50
-88
@@ -1,37 +1,10 @@
|
||||
import type { Db } from '$lib/server/db/index.js';
|
||||
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
|
||||
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt, and, or, sql } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import { canAccessNote } from '$lib/server/api-utils.js';
|
||||
import { mergeContent } from '$lib/utils/content-merge.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Find the base content for 3-way merge from version snapshots.
|
||||
* Returns the content from the snapshot at or before the given version,
|
||||
* or the current note content if no snapshot is found.
|
||||
*/
|
||||
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
|
||||
if (baseVersion !== undefined) {
|
||||
const snapshot = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (snapshot) return snapshot.content;
|
||||
}
|
||||
// Fallback: get the most recent snapshot before the current version
|
||||
const latest = tx.select({ content: noteVersions.content })
|
||||
.from(noteVersions)
|
||||
.where(eq(noteVersions.noteId, noteId))
|
||||
.orderBy(desc(noteVersions.version))
|
||||
.limit(1)
|
||||
.get();
|
||||
return latest?.content ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
@@ -58,70 +31,59 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
|
||||
|
||||
if (!isOwner && !isCollaborator) continue;
|
||||
|
||||
// Apply field-level updates unconditionally (no timestamp gate).
|
||||
// Each sync change only contains the fields the client explicitly modified,
|
||||
// so applying them won't overwrite other users' changes to different fields.
|
||||
const sharedData: Record<string, unknown> = {
|
||||
updatedAt: new Date(Math.max(change.timestamp, note.updatedAt.getTime())),
|
||||
version: note.version + 1
|
||||
};
|
||||
if (change.data.title !== undefined) sharedData.title = change.data.title;
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
if (change.timestamp > note.updatedAt.getTime()) {
|
||||
// Shared fields: title, content, color, checklistMode
|
||||
const sharedData: Record<string, unknown> = {
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: note.version + 1
|
||||
};
|
||||
if (change.data.title !== undefined) sharedData.title = change.data.title;
|
||||
if (change.data.content !== undefined) sharedData.content = change.data.content;
|
||||
if (change.data.color !== undefined) sharedData.color = change.data.color;
|
||||
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
|
||||
|
||||
// Content: 3-way merge to preserve concurrent edits from other users
|
||||
if (change.data.content !== undefined && change.data.content !== note.content) {
|
||||
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
|
||||
if (baseContent !== null && baseContent !== note.content) {
|
||||
// 3-way merge: base, incoming (local), server current (remote)
|
||||
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
|
||||
} else {
|
||||
// No base or base matches server — take incoming content
|
||||
sharedData.content = change.data.content;
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table
|
||||
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
|
||||
if (change.data.trashed !== undefined) {
|
||||
sharedData.trashed = change.data.trashed;
|
||||
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
|
||||
}
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
}
|
||||
|
||||
if (isOwner) {
|
||||
// Owner: per-user fields go to notes table
|
||||
if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) sharedData.archived = change.data.archived;
|
||||
if (change.data.trashed !== undefined) {
|
||||
sharedData.trashed = change.data.trashed;
|
||||
sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null;
|
||||
}
|
||||
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
|
||||
}
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.run();
|
||||
|
||||
tx.update(notes)
|
||||
.set(sharedData)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.run();
|
||||
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUser = change.data.pinned !== undefined ||
|
||||
change.data.archived !== undefined ||
|
||||
change.data.sortOrder !== undefined;
|
||||
if (hasPerUser) {
|
||||
const existing = tx.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.get();
|
||||
if (existing) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) updates.archived = change.data.archived;
|
||||
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
|
||||
tx.update(noteUserState).set(updates)
|
||||
// Collaborator: per-user fields go to noteUserState
|
||||
if (!isOwner) {
|
||||
const hasPerUser = change.data.pinned !== undefined ||
|
||||
change.data.archived !== undefined ||
|
||||
change.data.sortOrder !== undefined;
|
||||
if (hasPerUser) {
|
||||
const existing = tx.select().from(noteUserState)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(noteUserState).values({
|
||||
noteId: change.noteId,
|
||||
userId,
|
||||
pinned: change.data.pinned ?? false,
|
||||
archived: change.data.archived ?? false,
|
||||
sortOrder: change.data.sortOrder ?? 0
|
||||
}).run();
|
||||
.get();
|
||||
if (existing) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (change.data.pinned !== undefined) updates.pinned = change.data.pinned;
|
||||
if (change.data.archived !== undefined) updates.archived = change.data.archived;
|
||||
if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder;
|
||||
tx.update(noteUserState).set(updates)
|
||||
.where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(noteUserState).values({
|
||||
noteId: change.noteId,
|
||||
userId,
|
||||
pinned: change.data.pinned ?? false,
|
||||
archived: change.data.archived ?? false,
|
||||
sortOrder: change.data.sortOrder ?? 0
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,71 +55,6 @@ describe('mergeNotes (LWW)', () => {
|
||||
const result = mergeNotes(local, remote);
|
||||
expect(result.title).toBe('Local');
|
||||
});
|
||||
|
||||
it('should preserve pending local fields when remote is newer', () => {
|
||||
const local = makeNote({
|
||||
title: 'Local title',
|
||||
content: 'Local content',
|
||||
color: 'coral',
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
version: 1
|
||||
});
|
||||
const remote = makeNote({
|
||||
title: 'Remote title',
|
||||
content: 'Remote content',
|
||||
color: 'default',
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
version: 2
|
||||
});
|
||||
|
||||
// Content has pending local changes; title and color do not
|
||||
const pendingFields = new Set(['content']);
|
||||
const result = mergeNotes(local, remote, pendingFields);
|
||||
|
||||
// Remote wins overall, but content is preserved from local
|
||||
expect(result.title).toBe('Remote title');
|
||||
expect(result.content).toBe('Local content');
|
||||
expect(result.color).toBe('default'); // remote wins for non-pending field
|
||||
expect(result.version).toBe(2); // metadata from remote
|
||||
});
|
||||
|
||||
it('should take remote values for non-pending fields when remote is newer', () => {
|
||||
const local = makeNote({
|
||||
pinned: true,
|
||||
archived: true,
|
||||
updatedAt: new Date('2024-01-01')
|
||||
});
|
||||
const remote = makeNote({
|
||||
pinned: false,
|
||||
archived: false,
|
||||
updatedAt: new Date('2024-01-02')
|
||||
});
|
||||
|
||||
const pendingFields = new Set(['pinned']);
|
||||
const result = mergeNotes(local, remote, pendingFields);
|
||||
|
||||
expect(result.pinned).toBe(true); // pending — preserved
|
||||
expect(result.archived).toBe(false); // not pending — remote wins
|
||||
});
|
||||
|
||||
it('should ignore pendingFields when local is newer', () => {
|
||||
const local = makeNote({
|
||||
title: 'Local',
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
version: 2
|
||||
});
|
||||
const remote = makeNote({
|
||||
title: 'Remote',
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
version: 1
|
||||
});
|
||||
|
||||
const pendingFields = new Set(['title']);
|
||||
const result = mergeNotes(local, remote, pendingFields);
|
||||
|
||||
// Local wins entirely since it's newer
|
||||
expect(result.title).toBe('Local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
|
||||
@@ -6,8 +6,6 @@ export interface UserPreferences {
|
||||
hideFooter: boolean;
|
||||
sidebarDefaultState: 'open' | 'collapsed';
|
||||
notifyOnShare: boolean;
|
||||
notifyOnCollabRemoved: boolean;
|
||||
notifyOnNoteDeleted: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -15,14 +13,10 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
defaultNoteColor: 'default',
|
||||
hideFooter: false,
|
||||
sidebarDefaultState: 'open',
|
||||
notifyOnShare: true,
|
||||
notifyOnCollabRemoved: true,
|
||||
notifyOnNoteDeleted: true
|
||||
notifyOnShare: true
|
||||
};
|
||||
|
||||
export const BOOLEAN_PREF_KEYS: ReadonlySet<keyof UserPreferences> = new Set([
|
||||
'hideFooter',
|
||||
'notifyOnShare',
|
||||
'notifyOnCollabRemoved',
|
||||
'notifyOnNoteDeleted'
|
||||
'notifyOnShare'
|
||||
]);
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
|
||||
|
||||
describe('mergeContent (3-way)', () => {
|
||||
it('should return remote when local is unchanged', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread';
|
||||
const local = '- [ ] Milk\n- [ ] Bread';
|
||||
const remote = '- [x] Milk\n- [ ] Bread';
|
||||
|
||||
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
|
||||
});
|
||||
|
||||
it('should return local when remote is unchanged', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread';
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread';
|
||||
|
||||
expect(mergeContent(base, local, remote)).toBe('- [x] Milk\n- [ ] Bread');
|
||||
});
|
||||
|
||||
it('should merge when User A checks an item and User B adds an item', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter'; // User B adds Butter
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toBe('- [x] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter');
|
||||
});
|
||||
|
||||
it('should merge when both users check different items', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
const local = '- [x] Milk\n- [ ] Bread\n- [ ] Eggs'; // User A checks Milk
|
||||
const remote = '- [ ] Milk\n- [x] Bread\n- [ ] Eggs'; // User B checks Bread
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toBe('- [x] Milk\n- [x] Bread\n- [ ] Eggs');
|
||||
});
|
||||
|
||||
it('should LWW (prefer remote) when both users edit the same line', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread';
|
||||
const local = '- [ ] Whole Milk\n- [ ] Bread'; // User A renames Milk
|
||||
const remote = '- [x] Milk\n- [ ] Bread'; // User B checks Milk
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
// Both changed line 1 — conflict resolved by preferring remote
|
||||
expect(result).toBe('- [x] Milk\n- [ ] Bread');
|
||||
});
|
||||
|
||||
it('should handle line additions by both sides', () => {
|
||||
const base = '- [ ] Milk';
|
||||
const local = '- [ ] Milk\n- [ ] Bread'; // User A adds Bread at end
|
||||
const remote = '- [ ] Eggs\n- [ ] Milk'; // User B adds Eggs at beginning
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
// Both additions should be preserved
|
||||
expect(result).toContain('- [ ] Milk');
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
expect(result).toContain('- [ ] Eggs');
|
||||
});
|
||||
|
||||
it('should handle line deletion by one side', () => {
|
||||
const base = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
const local = '- [ ] Milk\n- [ ] Eggs'; // User A removes Bread
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'; // User B unchanged
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toBe('- [ ] Milk\n- [ ] Eggs');
|
||||
});
|
||||
|
||||
it('should work with non-checklist markdown (prose paragraphs)', () => {
|
||||
const base = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nNothing special.';
|
||||
const local = '# Shopping\n\nBuy groceries today.\n\n## Notes\n\nRemember coupons.'; // User A edits notes
|
||||
const remote = '# Shopping List\n\nBuy groceries today.\n\n## Notes\n\nNothing special.'; // User B edits title
|
||||
|
||||
const result = mergeContent(base, local, remote);
|
||||
expect(result).toContain('# Shopping List');
|
||||
expect(result).toContain('Remember coupons.');
|
||||
});
|
||||
|
||||
it('should handle empty content', () => {
|
||||
expect(mergeContent('', '', '')).toBe('');
|
||||
expect(mergeContent('', 'hello', '')).toBe('hello');
|
||||
expect(mergeContent('', '', 'hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should return remote when local equals remote', () => {
|
||||
const content = '- [ ] Milk\n- [ ] Bread';
|
||||
expect(mergeContent('old', content, content)).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeContentTwoWay', () => {
|
||||
it('should return remote when both are identical', () => {
|
||||
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should attempt merge when contents differ', () => {
|
||||
const local = '- [x] Milk\n- [ ] Bread';
|
||||
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
|
||||
|
||||
const result = mergeContentTwoWay(local, remote);
|
||||
// Without a base, conflict resolution prefers remote
|
||||
expect(result).toContain('- [ ] Bread');
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { diff3Merge } from 'node-diff3';
|
||||
|
||||
/**
|
||||
* Resolve a conflict region by comparing each line against the base.
|
||||
* If only one side changed a line, take that change.
|
||||
* If both sides changed the same line, prefer remote (LWW).
|
||||
* Handles different-length regions by appending extra lines.
|
||||
*/
|
||||
function resolveConflict(
|
||||
aLines: string[],
|
||||
oLines: string[],
|
||||
bLines: string[]
|
||||
): string[] {
|
||||
const maxLen = Math.max(aLines.length, bLines.length, oLines.length);
|
||||
const result: string[] = [];
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = i < aLines.length ? aLines[i] : undefined;
|
||||
const o = i < oLines.length ? oLines[i] : undefined;
|
||||
const b = i < bLines.length ? bLines[i] : undefined;
|
||||
|
||||
const aChanged = a !== o;
|
||||
const bChanged = b !== o;
|
||||
|
||||
if (aChanged && !bChanged && a !== undefined) {
|
||||
// Only local changed this line — take local
|
||||
result.push(a);
|
||||
} else if (bChanged && !aChanged && b !== undefined) {
|
||||
// Only remote changed this line — take remote
|
||||
result.push(b);
|
||||
} else if (aChanged && bChanged) {
|
||||
// Both changed — LWW: prefer remote
|
||||
if (b !== undefined) result.push(b);
|
||||
// If remote deleted (undefined) but local has it, skip (remote wins)
|
||||
} else {
|
||||
// Neither changed, or both are the same as base
|
||||
if (o !== undefined) result.push(o);
|
||||
else if (b !== undefined) result.push(b);
|
||||
else if (a !== undefined) result.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-way line-level merge for note content.
|
||||
* Takes a base (common ancestor), local (incoming), and remote (server current).
|
||||
* For conflict regions, resolves line-by-line: if only one side changed a line,
|
||||
* take that change. If both changed the same line, prefer remote (LWW).
|
||||
*/
|
||||
export function mergeContent(base: string, local: string, remote: string): string {
|
||||
// If any two are identical, short-circuit
|
||||
if (local === remote) return remote;
|
||||
if (local === base) return remote;
|
||||
if (remote === base) return local;
|
||||
|
||||
const baseLines = base.split('\n');
|
||||
const localLines = local.split('\n');
|
||||
const remoteLines = remote.split('\n');
|
||||
|
||||
const regions = diff3Merge(localLines, baseLines, remoteLines);
|
||||
const merged: string[] = [];
|
||||
|
||||
for (const region of regions) {
|
||||
if (region.ok) {
|
||||
merged.push(...region.ok);
|
||||
} else if (region.conflict) {
|
||||
merged.push(...resolveConflict(
|
||||
region.conflict.a,
|
||||
region.conflict.o,
|
||||
region.conflict.b
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return merged.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 2-way merge fallback when no base is available.
|
||||
* Uses an empty string as the base, so all lines from both sides appear
|
||||
* as additions. For conflicts (same region), prefers remote.
|
||||
*/
|
||||
export function mergeContentTwoWay(local: string, remote: string): string {
|
||||
if (local === remote) return remote;
|
||||
return mergeContent('', local, remote);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { User, Cpu, ShieldCheck, SlidersHorizontal, Info } from 'lucide-svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let pathname = $derived(page.url.pathname);
|
||||
@@ -23,7 +23,7 @@
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/preferences' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
data-testid="settings-nav-preferences"
|
||||
>
|
||||
<Icon icon="pixelarticons:sliders" width={16} />
|
||||
<SlidersHorizontal size={16} />
|
||||
Preferences
|
||||
</a>
|
||||
</li>
|
||||
@@ -32,7 +32,7 @@
|
||||
href="/settings/profile"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/profile' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:user" width={16} />
|
||||
<User size={16} />
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
@@ -41,7 +41,7 @@
|
||||
href="/settings/mcp"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/mcp' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:cpu" width={16} />
|
||||
<Cpu size={16} />
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
@@ -51,7 +51,7 @@
|
||||
href="/settings/users"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/users' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:shield" width={16} />
|
||||
<ShieldCheck size={16} />
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
@@ -61,7 +61,7 @@
|
||||
href="/settings/about"
|
||||
class="flex items-center gap-2 rounded-sm px-3 py-2 text-sm whitespace-nowrap transition-colors {pathname === '/settings/about' ? 'bg-[var(--primary)]/15 text-[var(--primary)] font-medium' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Icon icon="pixelarticons:info-box" width={16} />
|
||||
<Info size={16} />
|
||||
About
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
href="https://bretzel.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">bretzel.app</a
|
||||
> universe.
|
||||
class="text-[var(--primary)] underline hover:text-[var(--primary-hover)]">Bretzel</a
|
||||
> family of apps.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { Key, Copy, Trash2, Plus, Check } from 'lucide-svelte';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
@@ -118,7 +118,7 @@ ${mcpJsonConfig}`);
|
||||
<!-- API Keys -->
|
||||
<section class="mb-6 rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-6 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Icon icon="pixelarticons:lock" width={16} class="text-[var(--primary)]" />
|
||||
<Key size={16} class="text-[var(--primary)]" />
|
||||
<h2 class="text-lg font-semibold text-[var(--text)]">API Keys</h2>
|
||||
</div>
|
||||
<p class="mb-4 text-xs text-[var(--text-muted)]">
|
||||
@@ -140,7 +140,7 @@ ${mcpJsonConfig}`);
|
||||
class="flex items-center gap-1 rounded-sm bg-[var(--primary)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="create-api-key-btn"
|
||||
>
|
||||
<Icon icon="pixelarticons:plus" width={14} />
|
||||
<Plus size={14} />
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
@@ -167,9 +167,9 @@ ${mcpJsonConfig}`);
|
||||
data-testid="copy-key-btn"
|
||||
>
|
||||
{#if copiedKey}
|
||||
<Icon icon="pixelarticons:check" width={14} class="text-[var(--success-text,#3a5a40)]" />
|
||||
<Check size={14} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:copy" width={14} />
|
||||
<Copy size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@ ${mcpJsonConfig}`);
|
||||
data-testid="delete-api-key-btn"
|
||||
title="Revoke key"
|
||||
>
|
||||
<Icon icon="pixelarticons:trash" width={14} />
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -253,9 +253,9 @@ ${mcpJsonConfig}`);
|
||||
title="Copy JSON config"
|
||||
>
|
||||
{#if copiedJson}
|
||||
<Icon icon="pixelarticons:check" width={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:copy" width={12} />
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -279,9 +279,9 @@ ${mcpJsonConfig}`);
|
||||
title="Copy LLM prompt"
|
||||
>
|
||||
{#if copiedPrompt}
|
||||
<Icon icon="pixelarticons:check" width={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
<Check size={12} class="text-[var(--success-text,#3a5a40)]" />
|
||||
{:else}
|
||||
<Icon icon="pixelarticons:copy" width={12} />
|
||||
<Copy size={12} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -66,40 +66,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Email notifications -->
|
||||
<div class="space-y-3">
|
||||
<span class="block text-sm font-medium text-[var(--text)]">Email notifications</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-share"
|
||||
checked={prefs.notifyOnShare}
|
||||
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-notify-on-share"
|
||||
/>
|
||||
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-collab-removed"
|
||||
checked={prefs.notifyOnCollabRemoved}
|
||||
onchange={() => updatePreference('notifyOnCollabRemoved', !prefs.notifyOnCollabRemoved)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-notify-on-collab-removed"
|
||||
/>
|
||||
<label for="notify-on-collab-removed" class="text-sm text-[var(--text)]">Email me when I'm removed from a shared note</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-note-deleted"
|
||||
checked={prefs.notifyOnNoteDeleted}
|
||||
onchange={() => updatePreference('notifyOnNoteDeleted', !prefs.notifyOnNoteDeleted)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-notify-on-note-deleted"
|
||||
/>
|
||||
<label for="notify-on-note-deleted" class="text-sm text-[var(--text)]">Email me when a shared note I have access to is deleted</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="notify-on-share"
|
||||
checked={prefs.notifyOnShare}
|
||||
onchange={() => updatePreference('notifyOnShare', !prefs.notifyOnShare)}
|
||||
class="h-4 w-4 rounded-sm"
|
||||
data-testid="pref-notify-on-share"
|
||||
/>
|
||||
<label for="notify-on-share" class="text-sm text-[var(--text)]">Email me when someone shares a note with me</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(async () => {
|
||||
if (pwaInfo) {
|
||||
const { registerSW } = await import('virtual:pwa-register');
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onRegistered(r: ServiceWorkerRegistration | undefined) {
|
||||
console.log('SW Registered:', r);
|
||||
},
|
||||
onRegisterError(error: Error) {
|
||||
console.log('SW registration error', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{@html webManifest}
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||
import { listUsers, createUser } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendWelcomeEmail } from '$lib/server/email.js';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
@@ -22,12 +21,5 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
|
||||
}
|
||||
|
||||
const user = await createUser(email, displayName || email.split('@')[0], password || null, role || 'user');
|
||||
|
||||
// Send welcome email (fire-and-forget)
|
||||
if (isEmailConfigured() && user.email) {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
sendWelcomeEmail(user.email, user.displayName, origin).catch(() => {});
|
||||
}
|
||||
|
||||
return json(user, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { requireAdmin } from '$lib/server/api-utils.js';
|
||||
import { getUser, deleteUser, updateUserRole, resetPassword, revokeAllSessions } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendPasswordResetEmail, sendRoleChangedEmail } from '$lib/server/email.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const admin = requireAdmin(event);
|
||||
@@ -12,19 +11,12 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
const user = getUser(userId);
|
||||
if (!user) throw error(404, 'User not found');
|
||||
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
|
||||
if (body.role !== undefined) {
|
||||
// Prevent admin from demoting themselves
|
||||
if (userId === admin.id && body.role !== 'admin') {
|
||||
throw error(400, 'Cannot change your own role');
|
||||
}
|
||||
updateUserRole(userId, body.role);
|
||||
|
||||
// Notify user of role change (fire-and-forget)
|
||||
if (isEmailConfigured() && user.email) {
|
||||
sendRoleChangedEmail(user.email, user.displayName, body.role, origin).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.newPassword !== undefined) {
|
||||
@@ -32,11 +24,6 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
|
||||
throw error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
await resetPassword(userId, body.newPassword);
|
||||
|
||||
// Notify user of password reset (fire-and-forget)
|
||||
if (isEmailConfigured() && user.email) {
|
||||
sendPasswordResetEmail(user.email, user.displayName, origin).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.revokeSessions) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { verifyPassword, deleteUser, deleteSession, getUser } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendAccountDeletedEmail } from '$lib/server/email.js';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ request, cookies, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -18,9 +17,6 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
||||
if (!verified) throw error(401, 'Invalid password');
|
||||
}
|
||||
|
||||
// Capture email/name before deletion
|
||||
const { email: userEmail, displayName: userName } = user;
|
||||
|
||||
// Delete user and all associated data
|
||||
await deleteUser(userId);
|
||||
|
||||
@@ -31,10 +27,5 @@ export const DELETE: RequestHandler = async ({ request, cookies, ...event }) =>
|
||||
cookies.delete('session', { path: '/' });
|
||||
}
|
||||
|
||||
// Send deletion confirmation email (fire-and-forget)
|
||||
if (isEmailConfigured() && userEmail) {
|
||||
sendAccountDeletedEmail(userEmail, userName).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -3,9 +3,6 @@ import type { RequestHandler } from './$types.js';
|
||||
import { verifyPassword, createSession } from '$lib/server/auth.js';
|
||||
import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isEmailConfigured, sendAccountLockedEmail } from '$lib/server/email.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
||||
const ip = getClientAddress();
|
||||
@@ -26,27 +23,6 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
const user = await verifyPassword(email, password);
|
||||
if (!user) {
|
||||
recordLoginAttempt(db, ip, email, false);
|
||||
|
||||
// Check if this failure just triggered a lockout — if so, notify the user
|
||||
if (isEmailConfigured()) {
|
||||
const newCheck = checkRateLimit(db, ip, email);
|
||||
if (!newCheck.allowed) {
|
||||
const retryAfterMinutes = Math.ceil((newCheck.retryAfter ?? 900) / 60);
|
||||
const accountUser = db
|
||||
.select({ email: users.email, displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
if (accountUser?.email) {
|
||||
sendAccountLockedEmail(
|
||||
accountUser.email,
|
||||
accountUser.displayName,
|
||||
retryAfterMinutes
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error(401, 'Invalid email or password');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { getUserId } from '$lib/server/api-utils.js';
|
||||
import { updateUserProfile, getUser } from '$lib/server/auth.js';
|
||||
import { isEmailConfigured, sendEmailChangedEmail } from '$lib/server/email.js';
|
||||
import { updateUserProfile } from '$lib/server/auth.js';
|
||||
|
||||
export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -12,15 +11,6 @@ export const PUT: RequestHandler = async ({ request, ...event }) => {
|
||||
throw error(400, 'Email cannot be empty');
|
||||
}
|
||||
|
||||
// Capture old email before update to notify on change
|
||||
const existingUser = email !== undefined ? getUser(userId) : null;
|
||||
|
||||
updateUserProfile(userId, { displayName, email });
|
||||
|
||||
// Send notification to old email when email address changes
|
||||
if (isEmailConfigured() && existingUser?.email && email && existingUser.email !== email) {
|
||||
sendEmailChangedEmail(existingUser.email, existingUser.displayName, email).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -3,9 +3,6 @@ import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js';
|
||||
import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js';
|
||||
import { fetchCollaboratorsForNotes } from '$lib/server/collaborators.js';
|
||||
import { isEmailConfigured, sendNotePermanentlyDeletedEmail } from '$lib/server/email.js';
|
||||
import { getPreferences } from '$lib/server/preferences.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
@@ -28,32 +25,8 @@ export const DELETE: RequestHandler = async ({ params, ...event }) => {
|
||||
const userId = getUserId(event);
|
||||
requireNoteOwnership(db, params.id, userId);
|
||||
|
||||
// Fetch note and collaborators before deletion to send notifications
|
||||
let collaboratorsToNotify: Array<{ email: string; displayName: string }> = [];
|
||||
let noteTitle = 'Untitled';
|
||||
|
||||
if (isEmailConfigured()) {
|
||||
const note = getNote(db, userId, params.id);
|
||||
if (note) {
|
||||
noteTitle = note.title || 'Untitled';
|
||||
const collabMap = fetchCollaboratorsForNotes(db, [params.id]);
|
||||
const collaborators = collabMap.get(params.id) ?? [];
|
||||
for (const collab of collaborators) {
|
||||
const prefs = getPreferences(db, collab.userId);
|
||||
if (prefs.notifyOnNoteDeleted !== 'false' && collab.email) {
|
||||
collaboratorsToNotify.push({ email: collab.email, displayName: collab.displayName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleted = deleteNote(db, userId, params.id);
|
||||
if (!deleted) throw error(404, 'Note not found');
|
||||
|
||||
// Notify collaborators about the deleted note (fire-and-forget)
|
||||
for (const collab of collaboratorsToNotify) {
|
||||
sendNotePermanentlyDeletedEmail(collab.email, collab.displayName, noteTitle).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -5,11 +5,7 @@ import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { users, notes, noteCollaborators } from '$lib/server/db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
isEmailConfigured,
|
||||
sendShareNotification,
|
||||
sendCollaboratorRemovedEmail
|
||||
} from '$lib/server/email.js';
|
||||
import { isEmailConfigured, sendShareNotification } from '$lib/server/email.js';
|
||||
import { getPreferences } from '$lib/server/preferences.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, ...event }) => {
|
||||
@@ -106,50 +102,7 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
|
||||
throw error(403, 'Only the owner can remove other collaborators');
|
||||
}
|
||||
|
||||
// Fetch collaborator info and note title before removal for email notification
|
||||
let removedUserEmail: string | undefined;
|
||||
let removedUserName: string | undefined;
|
||||
let noteTitle: string | undefined;
|
||||
let ownerName: string | undefined;
|
||||
|
||||
if (isEmailConfigured() && isOwner && targetUserId !== userId) {
|
||||
const targetPrefs = getPreferences(db, targetUserId);
|
||||
if (targetPrefs.notifyOnCollabRemoved !== 'false') {
|
||||
const target = db
|
||||
.select({ email: users.email, displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.get();
|
||||
const noteData = db
|
||||
.select({ title: notes.title })
|
||||
.from(notes)
|
||||
.where(eq(notes.id, params.id))
|
||||
.get();
|
||||
const owner = db
|
||||
.select({ displayName: users.displayName })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.get();
|
||||
removedUserEmail = target?.email ?? undefined;
|
||||
removedUserName = target?.displayName ?? undefined;
|
||||
noteTitle = noteData?.title ?? undefined;
|
||||
ownerName = owner?.displayName ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
removeCollaborator(db, params.id, targetUserId);
|
||||
|
||||
// Send notification to removed collaborator (fire-and-forget)
|
||||
if (removedUserEmail) {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:3000';
|
||||
sendCollaboratorRemovedEmail(
|
||||
removedUserEmail,
|
||||
removedUserName || '',
|
||||
ownerName || 'Someone',
|
||||
noteTitle || 'Untitled',
|
||||
origin
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
@@ -1,796 +0,0 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Crumbs API",
|
||||
"description": "Auto-generated API spec for crumbs",
|
||||
"version": "0.22.3",
|
||||
"contact": {
|
||||
"name": "Bretzel",
|
||||
"url": "https://bretzel.app"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/",
|
||||
"description": "Current instance"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"sessionCookie": []
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/admin/users": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/users/{id}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/account": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/oauth/{provider}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/oauth/{provider}/callback": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/password": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/profile": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/sessions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/setup": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/mcp": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Mcp"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Mcp"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Mcp"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/reorder": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/attachments": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/collaborators": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/versions": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/versions/{versionId}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "versionId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes/{id}/versions/{versionId}/restore": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "versionId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"tags": [
|
||||
"Notes"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/preferences": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Preferences"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Preferences"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/search": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Search"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/settings/api-keys": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/settings/api-keys/{id}": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/sync": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Sync"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Sync"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tags": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/search": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"sessionCookie": {
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "session"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Crumbs by Bretzel",
|
||||
"short_name": "Crumbs",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#f9f4ea",
|
||||
"background_color": "#f9f4ea",
|
||||
"display": "standalone"
|
||||
}
|
||||
+1
-4
@@ -5,10 +5,7 @@ const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build'
|
||||
}),
|
||||
serviceWorker: {
|
||||
register: false
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -62,11 +62,8 @@ test.describe('Offline Support', () => {
|
||||
await expect(page.getByText('Survive Reload')).toBeVisible();
|
||||
|
||||
// When the network is restored and sync completes
|
||||
const syncResponse = page.waitForResponse((resp) => resp.url().includes('/api/sync'));
|
||||
const syncResponse = page.waitForResponse('**/api/sync');
|
||||
await page.context().setOffline(false);
|
||||
// Manually dispatch the online event as a safety net — in CI/headless
|
||||
// environments, setOffline(false) doesn't always emit it reliably
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||
await syncResponse;
|
||||
|
||||
// And the user reloads the page
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Organization Features', () => {
|
||||
test('Scenario: Favorited note appears under the Favorites section', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Favorite Me" exists
|
||||
await createNote(page, 'Favorite Me');
|
||||
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
|
||||
// Given a note titled "Pin Me" exists
|
||||
await createNote(page, 'Pin Me');
|
||||
|
||||
// When the user favorites the note
|
||||
const favCard = noteCard(page, 'Favorite Me');
|
||||
await favCard.hover();
|
||||
await favCard.getByTestId('favorite-btn').first().click({ force: true });
|
||||
// When the user pins the note
|
||||
const pinCard = noteCard(page, 'Pin Me');
|
||||
await pinCard.hover();
|
||||
await pinCard.getByTestId('pin-btn').first().click({ force: true });
|
||||
|
||||
// Then the "Favorites" section is visible
|
||||
await expect(page.getByText('Favorites')).toBeVisible();
|
||||
// Then the "Pinned" section is visible
|
||||
await expect(page.getByText('Pinned')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => {
|
||||
|
||||
@@ -124,37 +124,6 @@ test.describe('Note Sharing', () => {
|
||||
expect(syncedNote.content).toBe('Owner sync update');
|
||||
});
|
||||
|
||||
test('Scenario: Both users\' edits to different fields are preserved after concurrent PATCH', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
}) => {
|
||||
// Given a shared checklist note exists
|
||||
const note = await createSharedNote(
|
||||
page,
|
||||
collabPage,
|
||||
'Shopping List',
|
||||
'- [ ] Milk\n- [ ] Bread\n- [ ] Eggs'
|
||||
);
|
||||
|
||||
// When the owner checks Milk
|
||||
await page.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: '- [x] Milk\n- [ ] Bread\n- [ ] Eggs', baseVersion: note.version }
|
||||
});
|
||||
|
||||
// And the collaborator adds Butter (based on the same original version)
|
||||
await collabPage.request.patch(`/api/notes/${note.id}`, {
|
||||
data: { content: '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs\n- [ ] Butter', baseVersion: note.version }
|
||||
});
|
||||
|
||||
// Then both changes are preserved via content merge
|
||||
const res = await page.request.get(`/api/notes/${note.id}`);
|
||||
const merged = await res.json();
|
||||
expect(merged.content).toContain('- [x] Milk');
|
||||
expect(merged.content).toContain('- [ ] Butter');
|
||||
expect(merged.content).toContain('- [ ] Bread');
|
||||
expect(merged.content).toContain('- [ ] Eggs');
|
||||
});
|
||||
|
||||
test('Scenario: Collaborator sees leave action and can leave a shared note', async ({
|
||||
authenticatedPage: page,
|
||||
collabPage
|
||||
|
||||
+5
-26
@@ -23,8 +23,6 @@ export default defineConfig({
|
||||
]
|
||||
: []),
|
||||
SvelteKitPWA({
|
||||
scope: '/',
|
||||
buildBase: '/',
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Crumbs by Bretzel',
|
||||
@@ -36,26 +34,16 @@ export default defineConfig({
|
||||
theme_color: '#C8860A',
|
||||
icons: [
|
||||
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
|
||||
navigateFallback: null,
|
||||
navigateFallback: '/',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ request }) => request.mode === 'navigate',
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'pages-cache',
|
||||
networkTimeoutSeconds: 3,
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request, url }) =>
|
||||
request.method === 'GET' &&
|
||||
/\/api\/notes\/.*\/attachments\?attachmentId=/.test(url.href),
|
||||
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'attachment-cache',
|
||||
@@ -63,21 +51,12 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request, url }) =>
|
||||
request.method === 'GET' && /\/api\//.test(url.pathname),
|
||||
urlPattern: /^https?:\/\/.*\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https?:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-cache',
|
||||
expiration: { maxEntries: 10, maxAgeSeconds: 365 * 24 * 60 * 60 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-28
@@ -166,33 +166,6 @@
|
||||
<h3>OAuth & SSO</h3>
|
||||
<p>Sign in with Google, GitHub, or any OIDC provider. Or just use a password — your choice.</p>
|
||||
</div>
|
||||
|
||||
<!-- Note Sharing -->
|
||||
<div class="feature-card animate-in delay-4">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
</div>
|
||||
<h3>Note Sharing</h3>
|
||||
<p>Share notes with other users on your instance. Full collaboration with per-user pins and archives.</p>
|
||||
</div>
|
||||
|
||||
<!-- Version History -->
|
||||
<div class="feature-card animate-in delay-5">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<h3>Version History</h3>
|
||||
<p>Browse and restore previous versions of any note. Never lose important edits again.</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Notifications -->
|
||||
<div class="feature-card animate-in delay-6">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
</div>
|
||||
<h3>Email Alerts</h3>
|
||||
<p>Optional SMTP notifications for shares, security alerts, and account events. Opt out per user.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -280,7 +253,7 @@
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||
</ul>
|
||||
<span class="footer-version">v0.22.3</span>
|
||||
<span class="footer-version">v0.20.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user