mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
Compare commits
@@ -51,7 +51,6 @@ jobs:
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-check, unit-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -86,16 +85,17 @@ jobs:
|
||||
CI: true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
path: |
|
||||
playwright-report/
|
||||
test-results/
|
||||
retention-days: 30
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-and-check, unit-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -37,3 +37,4 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Local notes
|
||||
todo.md
|
||||
.worktrees/
|
||||
|
||||
@@ -124,6 +124,12 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
| `make release-minor` | — | Bump minor version, tag, and push |
|
||||
| `make release-major` | — | Bump major version, tag, and push |
|
||||
|
||||
## Git conventions
|
||||
|
||||
- **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes
|
||||
- **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff
|
||||
- **Feature branches**: Use `feat/<name>` branches with worktrees for isolation
|
||||
|
||||
## CI/CD
|
||||
|
||||
GitHub Actions with two workflows:
|
||||
|
||||
@@ -41,7 +41,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
|
||||
- Dark mode with system detection
|
||||
- PWA — installable, works offline via IndexedDB + LWW CRDT sync
|
||||
- Single-user password auth (Argon2)
|
||||
- Docker deployment with a single command
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
image: ghcr.io/bretzel-app/crumbs:0.4.3
|
||||
image: ghcr.io/bretzel-app/crumbs:0.7.1
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
@@ -49,6 +49,14 @@ For a single-user app with multiple devices:
|
||||
- If timestamps match: higher version wins
|
||||
- If both match: local version is preferred
|
||||
|
||||
### Attachment Data Flow
|
||||
1. User drops/picks image in NoteEditor
|
||||
2. Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
|
||||
3. **Online**: POST optimized + thumbnail to `/api/notes/{id}/attachments` → server saves to `data/attachments/` + DB
|
||||
4. **Offline**: store blobs in IDB `pendingAttachments` store → display via `URL.createObjectURL()` → on reconnect, sync pushes to server
|
||||
5. **Display**: NoteCard uses `&thumb=1` URL (200px WebP), editor shows full-size
|
||||
6. **SW Caching**: `CacheFirst` rule caches attachment URLs — previously-viewed images available offline
|
||||
|
||||
## Tech Stack Rationale
|
||||
|
||||
### SvelteKit
|
||||
@@ -89,7 +97,7 @@ Key tables:
|
||||
- `notes` - Core note data with version tracking
|
||||
- `tags` - Unique tag names
|
||||
- `note_tags` - Many-to-many note ↔ tag
|
||||
- `attachments` - Image file metadata
|
||||
- `attachments` - Image file metadata (path + thumbnailPath)
|
||||
- `sync_log` - Sync operation history
|
||||
|
||||
## File Organization
|
||||
|
||||
+11
-12
@@ -42,7 +42,13 @@
|
||||
- Upload images via file picker or drag-and-drop
|
||||
- Supports JPEG, PNG, GIF, WebP, SVG
|
||||
- Max file size: 10MB
|
||||
- Thumbnail previews in note cards
|
||||
- **Client-side optimization**: images resized to fit 1920×1920, compressed to WebP (~80% quality), transparent PNGs preserved
|
||||
- **Auto-generated thumbnails**: 200×200 WebP at 60% quality for fast card previews
|
||||
- Thumbnail strip on note cards (up to 3 images, "+N" badge for more)
|
||||
- Full-size images in editor, thumbnails on cards (`&thumb=1`)
|
||||
- **Offline support**: optimized blobs stored in IndexedDB, displayed via blob URLs, auto-synced on reconnect
|
||||
- Pending upload indicator on offline-queued images
|
||||
- Service worker CacheFirst caching for previously-viewed images
|
||||
- Remove attachments from editor
|
||||
|
||||
### Tag Organization
|
||||
@@ -56,7 +62,7 @@
|
||||
12 colors matching Google Keep's palette:
|
||||
- Default, Coral, Peach, Sand, Mint, Sage
|
||||
- Fog, Storm, Dusk, Blossom, Clay, Chalk
|
||||
- Colors adapt to light/dark mode
|
||||
- Colors adapt to note theme
|
||||
|
||||
### Pin Notes
|
||||
- Pin important notes to always appear at top
|
||||
@@ -80,13 +86,6 @@
|
||||
- Clear search to restore original view
|
||||
- Case-insensitive matching
|
||||
|
||||
### Dark Mode
|
||||
- System preference detection (auto)
|
||||
- Manual toggle override
|
||||
- Preference persisted in localStorage
|
||||
- All components adapt to theme
|
||||
- Note colors adjust for dark backgrounds
|
||||
|
||||
### Single-User Auth
|
||||
- First-run setup: create password (min 8 chars)
|
||||
- Argon2 password hashing (industry standard)
|
||||
@@ -97,9 +96,9 @@
|
||||
### PWA / Offline-First
|
||||
- Installable as standalone app
|
||||
- Web app manifest with icons
|
||||
- Service worker for asset caching
|
||||
- IndexedDB for local data storage
|
||||
- Background sync every 30 seconds
|
||||
- Service worker for asset caching (CacheFirst for attachments, NetworkFirst for API)
|
||||
- IndexedDB for local data storage (notes + pending attachments)
|
||||
- Background sync every 30 seconds (notes + offline attachments)
|
||||
- LWW conflict resolution for multi-device use
|
||||
- Sync status indicator (synced/syncing/offline/error)
|
||||
|
||||
|
||||
@@ -185,6 +185,12 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: thumb
|
||||
in: query
|
||||
description: Set to "1" to return the thumbnail instead of the full image (requires attachmentId)
|
||||
schema:
|
||||
type: string
|
||||
enum: ['1']
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment list (without attachmentId) or file content (with attachmentId)
|
||||
@@ -209,6 +215,10 @@ paths:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file (max 10MB)
|
||||
thumbnail:
|
||||
type: string
|
||||
format: binary
|
||||
description: Pre-generated thumbnail (WebP, ~200px)
|
||||
responses:
|
||||
'201':
|
||||
description: Attachment metadata
|
||||
@@ -368,6 +378,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attachment'
|
||||
|
||||
NoteInput:
|
||||
type: object
|
||||
@@ -415,6 +429,10 @@ components:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
thumbnailPath:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Server path to thumbnail file (null if no thumbnail)
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE `attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text NOT NULL,
|
||||
`size` integer NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`thumbnail_path` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `note_tags` (
|
||||
`note_id` text NOT NULL,
|
||||
`tag_id` integer NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `note_tags_note_id_idx` ON `note_tags` (`note_id`);--> statement-breakpoint
|
||||
CREATE INDEX `note_tags_tag_id_idx` ON `note_tags` (`tag_id`);--> statement-breakpoint
|
||||
CREATE TABLE `notes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`title` text DEFAULT '' NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`color` text DEFAULT 'default' NOT NULL,
|
||||
`pinned` integer DEFAULT false NOT NULL,
|
||||
`archived` integer DEFAULT false NOT NULL,
|
||||
`trashed` integer DEFAULT false NOT NULL,
|
||||
`trashed_at` integer,
|
||||
`checklist_mode` integer DEFAULT false NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
|
||||
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sync_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`timestamp` integer NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
|
||||
CREATE TABLE `tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,465 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "682b02aa-a28d-4904-8996-cbc4d740f626",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"attachments": {
|
||||
"name": "attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"attachments_note_id_notes_id_fk": {
|
||||
"name": "attachments_note_id_notes_id_fk",
|
||||
"tableFrom": "attachments",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_tags": {
|
||||
"name": "note_tags",
|
||||
"columns": {
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"note_tags_note_id_idx": {
|
||||
"name": "note_tags_note_id_idx",
|
||||
"columns": [
|
||||
"note_id"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"note_tags_tag_id_idx": {
|
||||
"name": "note_tags_tag_id_idx",
|
||||
"columns": [
|
||||
"tag_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"note_tags_note_id_notes_id_fk": {
|
||||
"name": "note_tags_note_id_notes_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"note_tags_tag_id_tags_id_fk": {
|
||||
"name": "note_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "note_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"pinned": {
|
||||
"name": "pinned",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed": {
|
||||
"name": "trashed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"trashed_at": {
|
||||
"name": "trashed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checklist_mode": {
|
||||
"name": "checklist_mode",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notes_trashed_archived_idx": {
|
||||
"name": "notes_trashed_archived_idx",
|
||||
"columns": [
|
||||
"trashed",
|
||||
"archived"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"notes_updated_at_idx": {
|
||||
"name": "notes_updated_at_idx",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions": {
|
||||
"name": "sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sync_log": {
|
||||
"name": "sync_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation": {
|
||||
"name": "operation",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sync_log_timestamp_idx": {
|
||||
"name": "sync_log_timestamp_idx",
|
||||
"columns": [
|
||||
"timestamp"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"sync_log_note_id_notes_id_fk": {
|
||||
"name": "sync_log_note_id_notes_id_fk",
|
||||
"tableFrom": "sync_log",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": [
|
||||
"note_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1773000424830,
|
||||
"tag": "0000_wooden_vargas",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.2",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
@@ -45,6 +45,7 @@
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
|
||||
Generated
+12
@@ -61,6 +61,9 @@ importers:
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
svelte-dnd-action:
|
||||
specifier: ^0.9.69
|
||||
version: 0.9.69(svelte@5.53.7)
|
||||
tiptap-markdown:
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0(@tiptap/core@3.20.1(@tiptap/pm@3.20.1))
|
||||
@@ -2900,6 +2903,11 @@ packages:
|
||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
svelte-dnd-action@0.9.69:
|
||||
resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==}
|
||||
peerDependencies:
|
||||
svelte: '>=3.23.0 || ^5.0.0-next.0'
|
||||
|
||||
svelte@5.53.7:
|
||||
resolution: {integrity: sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6037,6 +6045,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-dnd-action@0.9.69(svelte@5.53.7):
|
||||
dependencies:
|
||||
svelte: 5.53.7
|
||||
|
||||
svelte@5.53.7:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
@@ -15,14 +20,24 @@
|
||||
let { content, onChange }: Props = $props();
|
||||
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
|
||||
let activeItems = $derived(items.filter((i) => !i.checked));
|
||||
let doneItems = $derived(items.filter((i) => i.checked));
|
||||
let doneCount = $derived(doneItems.length);
|
||||
|
||||
function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ text: '', checked: false }];
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
|
||||
return { text: line, checked: false };
|
||||
if (line.startsWith('- [x] ')) return { id: generateId(), text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { id: generateId(), text: line.slice(6), checked: false };
|
||||
return { id: generateId(), text: line, checked: false };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,78 +51,107 @@
|
||||
onChange(serializeChecklist(items));
|
||||
}
|
||||
|
||||
function toggleItem(index: number) {
|
||||
items[index].checked = !items[index].checked;
|
||||
emitChange();
|
||||
function toggleItem(id: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.checked = !item.checked;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function updateText(index: number, text: string) {
|
||||
items[index].text = text;
|
||||
emitChange();
|
||||
function updateText(id: string, text: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.text = text;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function addItem(afterIndex: number) {
|
||||
items.splice(afterIndex + 1, 0, { text: '', checked: false });
|
||||
const newItem = { id: generateId(), text: '', checked: false };
|
||||
items.splice(afterIndex + 1, 0, newItem);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
// Focus new input on next tick
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[afterIndex + 1]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
function removeItem(id: string) {
|
||||
if (items.length <= 1) return;
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
||||
function handleKeydown(e: KeyboardEvent, id: string) {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem(index);
|
||||
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
||||
e.preventDefault();
|
||||
removeItem(index);
|
||||
removeItem(id);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDndConsider(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
// Merge reordered active items with done items
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
}
|
||||
|
||||
function handleDndFinalize(e: CustomEvent<DndEvent<ChecklistItem>>) {
|
||||
items = [...e.detail.items, ...items.filter((i) => i.checked)];
|
||||
emitChange();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1" data-testid="checklist">
|
||||
{#each items as item, index}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(index)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, index)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeItem(index)}
|
||||
class="opacity-0 hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<section
|
||||
use:dragHandleZone={{ items: activeItems, flipDurationMs, dropTargetStyle: {} }}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-1"
|
||||
>
|
||||
{#each activeItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
||||
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(item.id, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, item.id)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<button
|
||||
onclick={() => addItem(items.length - 1)}
|
||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
@@ -116,4 +160,42 @@
|
||||
<Plus class="h-4 w-4" />
|
||||
Add item
|
||||
</button>
|
||||
|
||||
{#if doneCount > 0}
|
||||
<div class="mt-2 border-t border-[var(--border-subtle)] pt-2">
|
||||
<button
|
||||
onclick={() => (doneExpanded = !doneExpanded)}
|
||||
class="flex items-center gap-1 text-xs text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-toggle-done"
|
||||
>
|
||||
<ChevronRight class="h-3 w-3 transition-transform duration-150 {doneExpanded ? 'rotate-90' : ''}" />
|
||||
{doneCount} done
|
||||
</button>
|
||||
|
||||
{#if doneExpanded}
|
||||
<div class="mt-1 space-y-1 pl-5" data-testid="checklist-done-section">
|
||||
{#each doneItems as item (item.id)}
|
||||
<div class="group flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(item.id)}
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-done-checkbox"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||
<button
|
||||
onclick={() => removeItem(item.id)}
|
||||
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-done-remove"
|
||||
>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { src, alt, onClose }: Props = $props();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackgroundClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={handleBackgroundClick}
|
||||
data-testid="image-lightbox"
|
||||
>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="absolute right-4 top-4 rounded-sm p-2 text-white hover:bg-white/10"
|
||||
aria-label="Close lightbox"
|
||||
data-testid="lightbox-close"
|
||||
>
|
||||
<XIcon class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
{src}
|
||||
{alt}
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain rounded-sm animate-[pop-in_150ms_ease-out]"
|
||||
data-testid="lightbox-image"
|
||||
/>
|
||||
</div>
|
||||
@@ -1,28 +1,82 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Star from 'lucide-svelte/icons/star';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||
import { browser } from '$app/environment';
|
||||
import { addPendingAttachment, type PendingAttachment } from '$lib/sync/idb.js';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
attachments: Attachment[];
|
||||
onUpload: (attachment: Attachment) => void;
|
||||
onRemove: (attachmentId: string) => void;
|
||||
onToggleFeatured?: (attachmentId: string, featured: boolean) => void;
|
||||
}
|
||||
|
||||
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
|
||||
let { noteId, attachments = [], onUpload, onRemove, onToggleFeatured }: Props = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let optimizing = $state(false);
|
||||
let dragOver = $state(false);
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || !noteId) return;
|
||||
|
||||
uploading = true;
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
|
||||
optimizing = true;
|
||||
let optimized: Blob;
|
||||
let thumbnail: Blob;
|
||||
try {
|
||||
const result = await optimizeImage(file);
|
||||
optimized = result.optimized;
|
||||
thumbnail = result.thumbnail;
|
||||
} catch (err) {
|
||||
console.error('Optimization failed, using original:', err);
|
||||
optimized = file;
|
||||
thumbnail = file;
|
||||
}
|
||||
optimizing = false;
|
||||
|
||||
// Offline: store in IDB and display via blob URL
|
||||
if (browser && !navigator.onLine) {
|
||||
const tempId = crypto.randomUUID();
|
||||
const pending: PendingAttachment = {
|
||||
id: tempId,
|
||||
noteId,
|
||||
optimized,
|
||||
thumbnail,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
await addPendingAttachment(pending);
|
||||
|
||||
const blobUrl = URL.createObjectURL(optimized);
|
||||
onUpload({
|
||||
id: tempId,
|
||||
noteId,
|
||||
filename: file.name,
|
||||
mimeType: optimized.type || file.type,
|
||||
size: optimized.size,
|
||||
path: blobUrl,
|
||||
pending: true,
|
||||
createdAt: new Date()
|
||||
} as Attachment & { pending: boolean });
|
||||
continue;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
|
||||
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
||||
@@ -32,12 +86,16 @@
|
||||
if (res.ok) {
|
||||
const attachment = await res.json();
|
||||
onUpload(attachment);
|
||||
} else {
|
||||
console.error('Upload failed:', res.status, await res.text());
|
||||
showToast('Failed to upload image', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
showToast('Failed to upload image', 'error');
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
@@ -57,16 +115,34 @@
|
||||
{#if attachments.length > 0}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each attachments as attachment}
|
||||
{@const isPending = 'pending' in attachment && attachment.pending}
|
||||
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
|
||||
<div class="group relative">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
||||
src={imgSrc}
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 rounded-sm object-cover"
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
|
||||
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
{#if isPending}
|
||||
<div class="absolute bottom-0.5 left-0.5 rounded-sm bg-black/50 p-0.5" title="Pending sync">
|
||||
<RefreshCw class="h-3 w-3 text-white" />
|
||||
</div>
|
||||
{:else if onToggleFeatured}
|
||||
<button
|
||||
onclick={() => onToggleFeatured(attachment.id, !attachment.featured)}
|
||||
class="absolute bottom-0.5 right-0.5 rounded-full bg-black/50 p-0.5 {attachment.featured ? 'block' : 'hidden group-hover:block'}"
|
||||
aria-label={attachment.featured ? 'Unfeature image' : 'Feature image'}
|
||||
data-testid="toggle-featured"
|
||||
>
|
||||
<Star class="h-3 w-3 {attachment.featured ? 'fill-[var(--primary)] text-[var(--primary)]' : 'text-white'}" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
@@ -87,7 +163,9 @@
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if uploading}
|
||||
{#if optimizing}
|
||||
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
|
||||
{:else if uploading}
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
@@ -104,3 +182,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
@@ -11,14 +11,22 @@
|
||||
|
||||
let { open, onClose, appVersion }: Props = $props();
|
||||
|
||||
function closeMobile() {
|
||||
if (window.matchMedia('(max-width: 1023px)').matches) {
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
loadNotes(filter);
|
||||
closeMobile();
|
||||
}
|
||||
|
||||
function selectTag(tag: string | null) {
|
||||
selectedTag.set(tag);
|
||||
loadNotes('all');
|
||||
closeMobile();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
@@ -18,9 +19,10 @@
|
||||
interface Props {
|
||||
note: Note;
|
||||
onEdit: (note: Note) => void;
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
let { note, onEdit }: Props = $props();
|
||||
let { note, onEdit, fullHeight = false }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[note.color];
|
||||
@@ -28,6 +30,8 @@
|
||||
});
|
||||
|
||||
let cardStyle = $state('');
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
const renderedContent = $derived(renderMarkdown(note.content));
|
||||
|
||||
@@ -40,6 +44,12 @@
|
||||
: []
|
||||
);
|
||||
|
||||
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
|
||||
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
|
||||
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
|
||||
|
||||
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
|
||||
|
||||
function stop(fn: () => void) {
|
||||
return (e: Event) => {
|
||||
e.stopPropagation();
|
||||
@@ -47,19 +57,16 @@
|
||||
};
|
||||
}
|
||||
|
||||
function toggleChecklistItem(index: number) {
|
||||
function toggleChecklistItem(item: ChecklistItem) {
|
||||
const lines = note.content.split('\n');
|
||||
let checkIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith('- [x] ') || line.startsWith('- [ ] ')) {
|
||||
if (checkIndex === index) {
|
||||
lines[i] = line.startsWith('- [x] ')
|
||||
? `- [ ] ${line.slice(6)}`
|
||||
: `- [x] ${line.slice(6)}`;
|
||||
break;
|
||||
}
|
||||
checkIndex++;
|
||||
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') });
|
||||
@@ -67,7 +74,7 @@
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)]"
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] {fullHeight ? 'h-full' : ''}"
|
||||
style={cardStyle}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
@@ -76,6 +83,30 @@
|
||||
data-testid="note-card"
|
||||
data-note-id={note.id}
|
||||
>
|
||||
<!-- Thumbnail strip (featured images only) -->
|
||||
{#if featuredAttachments.length > 0}
|
||||
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
|
||||
{#each featuredAttachments.slice(0, 3) as attachment}
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
|
||||
alt={attachment.filename}
|
||||
class="h-24 w-full cursor-pointer object-cover"
|
||||
loading="lazy"
|
||||
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="card-thumbnail"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if featuredAttachments.length > 3}
|
||||
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
|
||||
+{featuredAttachments.length - 3}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
@@ -93,12 +124,12 @@
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-1" data-testid="note-checklist-preview">
|
||||
{#each checklistItems.slice(0, 8) as item, index}
|
||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(index); }}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
||||
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
@@ -124,7 +155,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons - show on hover -->
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
@@ -183,6 +214,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
import Checklist from './Checklist.svelte';
|
||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ImageLightbox from './ImageLightbox.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor } from '$lib/types/index.js';
|
||||
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import ImageIcon from 'lucide-svelte/icons/image';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
@@ -30,10 +33,68 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let checklistMode = $state(note?.checklistMode ?? false);
|
||||
let showColorPicker = $state(false);
|
||||
let rawMarkdownMode = $state(false);
|
||||
let showImageUpload = $state(false);
|
||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let tiptapEditor: Editor | undefined = $state();
|
||||
let editorTick = $state(0);
|
||||
let lightboxSrc = $state<string | null>(null);
|
||||
let lightboxAlt = $state('');
|
||||
|
||||
// Mutable note identity — allows transitioning from new → saved without closing
|
||||
// svelte-ignore state_referenced_locally
|
||||
let noteId = $state<string | null>(note?.id ?? null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentlyNew = $state(isNew);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
fetch(`/api/notes/${noteId}/attachments`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: Attachment[]) => {
|
||||
if (data.length > 0) attachmentsList = data;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
function handleAttachmentUpload(attachment: Attachment) {
|
||||
attachmentsList = [...attachmentsList, attachment];
|
||||
}
|
||||
|
||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||
if (!noteId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ featured })
|
||||
});
|
||||
if (res.ok) {
|
||||
attachmentsList = attachmentsList.map((a) =>
|
||||
a.id === attachmentId ? { ...a, featured } : a
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle featured:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachmentRemove(attachmentId: string) {
|
||||
if (!noteId) return;
|
||||
try {
|
||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove attachment:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let bgStyle = $state('');
|
||||
$effect(() => {
|
||||
@@ -41,23 +102,48 @@
|
||||
bgStyle = `background-color: ${colors.bg}`;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
async function saveAndClose() {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
if (currentlyNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (note) {
|
||||
await updateNote(note.id, { title, content, color, checklistMode });
|
||||
} else if (noteId) {
|
||||
await updateNote(noteId, { title, content, color, checklistMode });
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
/** Auto-save a new note without closing, returns the new note ID */
|
||||
async function autoSave(): Promise<string | null> {
|
||||
if (!currentlyNew) return noteId;
|
||||
const created = await createNote({
|
||||
title: title || 'Untitled',
|
||||
content,
|
||||
color,
|
||||
checklistMode
|
||||
});
|
||||
if (created) {
|
||||
noteId = created.id;
|
||||
currentlyNew = false;
|
||||
return created.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function toggleImageUpload() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
if (!id) return;
|
||||
}
|
||||
showImageUpload = !showImageUpload;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
save();
|
||||
saveAndClose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +163,7 @@
|
||||
<!-- 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]"
|
||||
onclick={save}
|
||||
onclick={saveAndClose}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
@@ -124,6 +210,38 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Image attachments -->
|
||||
{#if noteId && showImageUpload}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<ImageUpload
|
||||
noteId={noteId}
|
||||
attachments={attachmentsList}
|
||||
onUpload={handleAttachmentUpload}
|
||||
onRemove={handleAttachmentRemove}
|
||||
onToggleFeatured={handleToggleFeatured}
|
||||
/>
|
||||
</div>
|
||||
{:else if noteId && attachmentsList.length > 0}
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each attachmentsList as attachment}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 cursor-pointer rounded-sm object-cover"
|
||||
onclick={() => { lightboxSrc = `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lightboxSrc}
|
||||
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||
{/if}
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||
@@ -163,11 +281,22 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Image attachment toggle -->
|
||||
<button
|
||||
onclick={toggleImageUpload}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Image attachments"
|
||||
data-testid="image-toggle"
|
||||
>
|
||||
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
disabled={checklistMode}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
{#if rawMarkdownMode}
|
||||
@@ -179,7 +308,7 @@
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={save}
|
||||
onclick={saveAndClose}
|
||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
<script lang="ts">
|
||||
import NoteCard from './NoteCard.svelte';
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
label?: string;
|
||||
onEdit: (note: Note) => void;
|
||||
draggable?: boolean;
|
||||
dndType?: string;
|
||||
onReorder?: (noteIds: string[]) => void;
|
||||
}
|
||||
|
||||
let { notes, label = '', onEdit }: Props = $props();
|
||||
let { notes, label = '', onEdit, draggable = false, dndType = 'notes', onReorder }: Props = $props();
|
||||
|
||||
let localItems = $state<Note[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
localItems = [...notes];
|
||||
});
|
||||
|
||||
const flipDurationMs = 150;
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
}
|
||||
|
||||
function handleFinalize(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
onReorder?.(localItems.map((n) => n.id));
|
||||
}
|
||||
|
||||
let displayItems = $derived(draggable ? localItems : notes);
|
||||
</script>
|
||||
|
||||
{#if notes.length > 0}
|
||||
{#if displayItems.length > 0}
|
||||
{#if label}
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each notes as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if draggable}
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
data-testid="note-grid"
|
||||
use:dragHandleZone={{ items: localItems, flipDurationMs, type: dndType, dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each localItems as note (note.id)}
|
||||
<div class="relative h-full" animate:flip={{ duration: flipDurationMs }}>
|
||||
<div
|
||||
use:dragHandle
|
||||
aria-label="drag handle for {note.title || 'note'}"
|
||||
class="absolute top-2 right-2 z-10 cursor-grab rounded-sm p-1 text-[var(--text-muted)] opacity-60 hover:opacity-100 hover:bg-[var(--border-subtle)]/50 transition-opacity duration-150"
|
||||
data-testid="note-drag-handle"
|
||||
>
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
<NoteCard {note} {onEdit} fullHeight />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each displayItems as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
|
||||
import Hand from 'lucide-svelte/icons/hand';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
|
||||
{ value: 'updated', label: 'Updated', icon: Clock },
|
||||
{ value: 'created', label: 'Created', icon: CalendarPlus },
|
||||
{ value: 'custom', label: 'Custom', icon: Hand }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
|
||||
{#each modes as mode}
|
||||
<button
|
||||
onclick={() => sortMode.set(mode.value)}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
|
||||
{$sortMode === mode.value
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
|
||||
data-testid="sort-{mode.value}"
|
||||
aria-label="Sort by {mode.label}"
|
||||
>
|
||||
<mode.icon class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -4,7 +4,8 @@ import { join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from './db/index.js';
|
||||
import { attachments } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
||||
@@ -16,7 +17,8 @@ if (!existsSync(ATTACHMENTS_DIR)) {
|
||||
|
||||
export async function saveAttachment(
|
||||
noteId: string,
|
||||
file: File
|
||||
file: File,
|
||||
thumbnail?: File | Blob | null
|
||||
): Promise<typeof attachments.$inferSelect> {
|
||||
const id = uuidv4();
|
||||
const ext = file.name.split('.').pop() || 'bin';
|
||||
@@ -26,6 +28,14 @@ export async function saveAttachment(
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
let thumbnailPath: string | null = null;
|
||||
if (thumbnail) {
|
||||
const thumbFilename = `${id}_thumb.webp`;
|
||||
thumbnailPath = join(ATTACHMENTS_DIR, thumbFilename);
|
||||
const thumbBuffer = Buffer.from(await thumbnail.arrayBuffer());
|
||||
await writeFile(thumbnailPath, thumbBuffer);
|
||||
}
|
||||
|
||||
const [attachment] = await db
|
||||
.insert(attachments)
|
||||
.values({
|
||||
@@ -35,6 +45,7 @@ export async function saveAttachment(
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
path: filePath,
|
||||
thumbnailPath,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning();
|
||||
@@ -42,6 +53,31 @@ export async function saveAttachment(
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch attachments for multiple notes in a single query.
|
||||
* Returns a Map of noteId → Attachment[].
|
||||
*/
|
||||
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
.select()
|
||||
.from(attachments)
|
||||
.where(inArray(attachments.noteId, noteIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, Attachment[]>();
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.noteId);
|
||||
if (existing) {
|
||||
existing.push(row as Attachment);
|
||||
} else {
|
||||
map.set(row.noteId, [row as Attachment]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
||||
}
|
||||
@@ -50,6 +86,15 @@ export async function getAttachmentsByNote(noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function updateAttachment(id: string, data: { featured: boolean }) {
|
||||
const [updated] = await db
|
||||
.update(attachments)
|
||||
.set({ featured: data.featured })
|
||||
.where(eq(attachments.id, id))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
const attachment = await getAttachment(id);
|
||||
if (attachment) {
|
||||
@@ -58,6 +103,13 @@ export async function deleteAttachment(id: string) {
|
||||
} catch {
|
||||
// File may already be deleted
|
||||
}
|
||||
if (attachment.thumbnailPath) {
|
||||
try {
|
||||
await unlink(attachment.thumbnailPath);
|
||||
} catch {
|
||||
// Thumbnail may already be deleted
|
||||
}
|
||||
}
|
||||
await db.delete(attachments).where(eq(attachments.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ sqlite.exec(`
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -84,5 +85,17 @@ sqlite.exec(`
|
||||
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
||||
`);
|
||||
|
||||
// Idempotent migrations: add columns that may be missing from older DBs
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE attachments ADD COLUMN thumbnail_path TEXT;`);
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE attachments ADD COLUMN featured INTEGER NOT NULL DEFAULT 0;`);
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { sqlite };
|
||||
|
||||
@@ -71,6 +71,8 @@ export const attachments = sqliteTable('attachments', {
|
||||
mimeType: text('mime_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
path: text('path').notNull(),
|
||||
thumbnailPath: text('thumbnail_path'),
|
||||
featured: integer('featured', { mode: 'boolean' }).default(false).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
|
||||
+65
-10
@@ -3,6 +3,7 @@ import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.
|
||||
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
@@ -27,17 +28,32 @@ export const filteredNotes = derived(
|
||||
}
|
||||
);
|
||||
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => n.pinned)
|
||||
function sortNotes(list: Note[], mode: SortMode): Note[] {
|
||||
return [...list].sort((a, b) => {
|
||||
if (mode === 'created') {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
}
|
||||
if (mode === 'custom') {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
// default: updated
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export const pinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => n.pinned), $sortMode)
|
||||
);
|
||||
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => !n.pinned)
|
||||
export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) =>
|
||||
sortNotes($notes.filter((n) => !n.pinned), $sortMode)
|
||||
);
|
||||
|
||||
export const allTags = derived(notes, ($notes) => {
|
||||
const tagSet = new Set<string>();
|
||||
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
$notes
|
||||
.filter((n) => !n.archived && !n.trashed)
|
||||
.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
@@ -47,9 +63,13 @@ function isNetworkError(err: unknown): boolean {
|
||||
|
||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
// Load from IDB first for instant display
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.set(cached);
|
||||
try {
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.set(cached);
|
||||
}
|
||||
} catch {
|
||||
// IDB unavailable — continue with server fetch
|
||||
}
|
||||
currentFilter.set(filter);
|
||||
notesLoaded.set(true);
|
||||
@@ -63,8 +83,16 @@ export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
currentFilter.set(filter);
|
||||
|
||||
// Update IDB with server state
|
||||
await clearIdbNotes();
|
||||
await Promise.all(data.map((n) => putNote(n)));
|
||||
try {
|
||||
await clearIdbNotes();
|
||||
await Promise.all(data.map((n) => putNote(n)));
|
||||
} catch {
|
||||
// IDB write failed — server data is still in the store
|
||||
}
|
||||
} else if (res.status === 401) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
showToast('Failed to load notes', 'error');
|
||||
}
|
||||
} catch {
|
||||
// Offline — IDB data already loaded above
|
||||
@@ -84,6 +112,11 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
|
||||
await putNote(created);
|
||||
return created;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
showToast('Failed to save note', 'error');
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
@@ -197,3 +230,25 @@ export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
||||
// Optimistic update
|
||||
notes.update((list) =>
|
||||
list.map((n) => {
|
||||
const order = orders.find((o) => o.id === n.id);
|
||||
return order ? { ...n, sortOrder: order.sortOrder } : n;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/notes/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
// Offline — optimistic update stands
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type SortMode = 'updated' | 'created' | 'custom';
|
||||
|
||||
const STORAGE_KEY = 'crumbs-sort-mode';
|
||||
|
||||
function getInitial(): SortMode {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'updated' || stored === 'created' || stored === 'custom') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
export const sortMode = writable<SortMode>(getInitial());
|
||||
|
||||
if (browser) {
|
||||
sortMode.subscribe((value) => {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
});
|
||||
}
|
||||
+55
-14
@@ -1,6 +1,6 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta, getPendingAttachments, removePendingAttachment } from './idb.js';
|
||||
import { mergeNotes } from './crdt.js';
|
||||
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -29,7 +29,12 @@ export function getSyncStatus(): SyncStatus {
|
||||
* Push local changes to server.
|
||||
*/
|
||||
async function pushChanges(): Promise<boolean> {
|
||||
const queue = await getSyncQueue();
|
||||
let queue;
|
||||
try {
|
||||
queue = await getSyncQueue();
|
||||
} catch {
|
||||
return true; // IDB unavailable — nothing to push
|
||||
}
|
||||
if (queue.length === 0) return true;
|
||||
|
||||
try {
|
||||
@@ -49,31 +54,66 @@ async function pushChanges(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push pending offline attachments to server.
|
||||
*/
|
||||
async function pushPendingAttachments(): Promise<void> {
|
||||
const pending = await getPendingAttachments();
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([item.optimized], item.filename, { type: item.mimeType }));
|
||||
formData.append('thumbnail', new File([item.thumbnail], `${item.filename}_thumb.webp`, { type: 'image/webp' }));
|
||||
|
||||
const res = await fetch(`/api/notes/${item.noteId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await removePendingAttachment(item.id);
|
||||
}
|
||||
} catch {
|
||||
// Will retry on next sync cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull remote changes and merge with local state.
|
||||
*/
|
||||
async function pullChanges(): Promise<Note[]> {
|
||||
const lastSync = (await getMeta('lastSyncTimestamp')) || '0';
|
||||
let lastSync = '0';
|
||||
try {
|
||||
lastSync = (await getMeta('lastSyncTimestamp')) || '0';
|
||||
} catch {
|
||||
// IDB unavailable — pull everything
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sync?since=${lastSync}`);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const remoteNotes: Note[] = await res.json();
|
||||
const localNotes = await getAllNotes();
|
||||
const localMap = new Map(localNotes.map((n) => [n.id, n]));
|
||||
|
||||
for (const remote of remoteNotes) {
|
||||
const local = localMap.get(remote.id);
|
||||
if (local) {
|
||||
const merged = mergeNotes(local, remote);
|
||||
await putNote(merged);
|
||||
} else {
|
||||
await putNote(remote);
|
||||
try {
|
||||
const localNotes = await getAllNotes();
|
||||
const localMap = new Map(localNotes.map((n) => [n.id, n]));
|
||||
|
||||
for (const remote of remoteNotes) {
|
||||
const local = localMap.get(remote.id);
|
||||
if (local) {
|
||||
const merged = mergeNotes(local, remote);
|
||||
await putNote(merged);
|
||||
} else {
|
||||
await putNote(remote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await setMeta('lastSyncTimestamp', String(Date.now()));
|
||||
await setMeta('lastSyncTimestamp', String(Date.now()));
|
||||
} catch {
|
||||
// IDB write failed — server data still returned for store use
|
||||
}
|
||||
return remoteNotes;
|
||||
} catch {
|
||||
return [];
|
||||
@@ -98,6 +138,7 @@ export async function sync(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
await pushPendingAttachments();
|
||||
await pullChanges();
|
||||
setStatus('synced');
|
||||
|
||||
|
||||
+43
-1
@@ -2,7 +2,17 @@ import { openDB, type IDBPDatabase } from 'idb';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
const DB_NAME = 'crumbs';
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
noteId: string;
|
||||
optimized: Blob;
|
||||
thumbnail: Blob;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CrumbsDB {
|
||||
notes: {
|
||||
@@ -23,6 +33,13 @@ interface CrumbsDB {
|
||||
key: string;
|
||||
value: { key: string; value: string };
|
||||
};
|
||||
pendingAttachments: {
|
||||
key: string;
|
||||
value: PendingAttachment;
|
||||
indexes: {
|
||||
'by-noteId': string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
@@ -58,6 +75,12 @@ export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
db.createObjectStore('meta', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
// Pending attachments (offline uploads)
|
||||
if (!db.objectStoreNames.contains('pendingAttachments')) {
|
||||
const pendingStore = db.createObjectStore('pendingAttachments', { keyPath: 'id' });
|
||||
pendingStore.createIndex('by-noteId', 'noteId');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -122,3 +145,22 @@ export async function setMeta(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('meta', { key, value });
|
||||
}
|
||||
|
||||
// Pending attachment operations
|
||||
export async function addPendingAttachment(item: PendingAttachment): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('pendingAttachments', item);
|
||||
}
|
||||
|
||||
export async function getPendingAttachments(noteId?: string): Promise<PendingAttachment[]> {
|
||||
const db = await getDb();
|
||||
if (noteId) {
|
||||
return db.getAllFromIndex('pendingAttachments', 'by-noteId', noteId);
|
||||
}
|
||||
return db.getAll('pendingAttachments');
|
||||
}
|
||||
|
||||
export async function removePendingAttachment(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('pendingAttachments', id);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface Attachment {
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
thumbnailPath?: string | null;
|
||||
featured: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const MAX_DIMENSION = 1920;
|
||||
const THUMB_SIZE = 200;
|
||||
const QUALITY = 0.8;
|
||||
const THUMB_QUALITY = 0.6;
|
||||
const SKIP_THRESHOLD = 200 * 1024; // 200KB
|
||||
|
||||
interface OptimizeResult {
|
||||
optimized: Blob;
|
||||
thumbnail: Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PNG has transparency by examining the alpha channel.
|
||||
* Reads a sample of pixels to determine if any are non-opaque.
|
||||
*/
|
||||
function hasTransparency(canvas: HTMLCanvasElement | OffscreenCanvas, ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, width: number, height: number): boolean {
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
// Check every 4th value (alpha channel) with stride for performance
|
||||
const stride = Math.max(1, Math.floor(data.length / (4 * 10000))) * 4;
|
||||
for (let i = 3; i < data.length; i += stride) {
|
||||
if (data[i] < 255) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createCanvas(width: number, height: number): { canvas: HTMLCanvasElement | OffscreenCanvas; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D } {
|
||||
if (typeof OffscreenCanvas !== 'undefined') {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
async function canvasToBlob(canvas: HTMLCanvasElement | OffscreenCanvas, type: string, quality: number): Promise<Blob> {
|
||||
if (canvas instanceof OffscreenCanvas) {
|
||||
return canvas.convertToBlob({ type, quality });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob!), type, quality);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImage(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function fitDimensions(width: number, height: number, maxDim: number): { width: number; height: number } {
|
||||
if (width <= maxDim && height <= maxDim) return { width, height };
|
||||
const ratio = Math.min(maxDim / width, maxDim / height);
|
||||
return {
|
||||
width: Math.round(width * ratio),
|
||||
height: Math.round(height * ratio)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize an image file for upload:
|
||||
* - Resize to fit within 1920x1920 (preserve aspect ratio)
|
||||
* - Skip optimization if already < 200KB and within size limits
|
||||
* - Compress JPEG/WebP to ~80% quality
|
||||
* - Convert non-transparent PNG/BMP to WebP; keep transparent PNG as PNG
|
||||
* - Pass GIF/SVG through unmodified
|
||||
* - Generate a 200x200 thumbnail at 60% quality WebP
|
||||
*/
|
||||
export async function optimizeImage(file: File): Promise<OptimizeResult> {
|
||||
const type = file.type;
|
||||
|
||||
// GIF and SVG pass through unmodified (but still generate thumbnail for GIF)
|
||||
if (type === 'image/svg+xml') {
|
||||
return { optimized: file, thumbnail: file };
|
||||
}
|
||||
|
||||
const img = await loadImage(file);
|
||||
const { width, height } = img;
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbDims = fitDimensions(width, height, THUMB_SIZE);
|
||||
const { canvas: thumbCanvas, ctx: thumbCtx } = createCanvas(thumbDims.width, thumbDims.height);
|
||||
thumbCtx.drawImage(img, 0, 0, thumbDims.width, thumbDims.height);
|
||||
const thumbnail = await canvasToBlob(thumbCanvas, 'image/webp', THUMB_QUALITY);
|
||||
|
||||
// GIF: pass original through but use generated thumbnail
|
||||
if (type === 'image/gif') {
|
||||
return { optimized: file, thumbnail };
|
||||
}
|
||||
|
||||
// Check if optimization can be skipped
|
||||
const fittedDims = fitDimensions(width, height, MAX_DIMENSION);
|
||||
const needsResize = fittedDims.width !== width || fittedDims.height !== height;
|
||||
|
||||
if (!needsResize && file.size < SKIP_THRESHOLD) {
|
||||
return { optimized: file, thumbnail };
|
||||
}
|
||||
|
||||
// Draw at target size
|
||||
const { canvas, ctx } = createCanvas(fittedDims.width, fittedDims.height);
|
||||
ctx.drawImage(img, 0, 0, fittedDims.width, fittedDims.height);
|
||||
|
||||
// Determine output format
|
||||
let outputType = 'image/webp';
|
||||
let quality = QUALITY;
|
||||
|
||||
if (type === 'image/png' && hasTransparency(canvas, ctx, fittedDims.width, fittedDims.height)) {
|
||||
outputType = 'image/png';
|
||||
quality = 1; // PNG is lossless, quality param ignored but set for clarity
|
||||
}
|
||||
|
||||
const optimized = await canvasToBlob(canvas, outputType, quality);
|
||||
return { optimized, thumbnail };
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
Crumbs by Bretzel — made with 🥨 in Strasbourg
|
||||
Crumbs by <a href="https://bretzel.app" target="_blank" rel="noopener noreferrer" class="hover:text-[var(--primary)] transition-colors">Bretzel</a> — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
|
||||
<Toast />
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded } from '$lib/stores/notes.js';
|
||||
import SortSelector from '$lib/components/SortSelector.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, updateSortOrders } from '$lib/stores/notes.js';
|
||||
import { sortMode } from '$lib/stores/sort.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
@@ -22,6 +24,11 @@
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
function handleReorder(noteIds: string[]) {
|
||||
const orders = noteIds.map((id, index) => ({ id, sortOrder: index }));
|
||||
updateSortOrders(orders);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
@@ -53,9 +60,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{#if $currentFilter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentFilter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
@@ -65,7 +79,7 @@
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} />
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} dndType="pinned-notes" onReorder={handleReorder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -73,6 +87,9 @@
|
||||
notes={$unpinnedNotes}
|
||||
label={$pinnedNotes.length > 0 ? 'Others' : ''}
|
||||
onEdit={openEditor}
|
||||
draggable={$sortMode === 'custom'}
|
||||
dndType="unpinned-notes"
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq, and, desc } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const filter = url.searchParams.get('filter') || 'all';
|
||||
@@ -29,10 +30,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||
.all();
|
||||
|
||||
const tagMap = fetchTagsForNotes(result.map((n) => n.id));
|
||||
const noteIds = result.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const notesWithTags = result.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(notesWithTags);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
@@ -14,8 +15,13 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([note.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([note.id]);
|
||||
|
||||
return json({ ...note, tags: tagMap.get(note.id) ?? [] });
|
||||
return json({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
@@ -55,8 +61,13 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
}
|
||||
|
||||
const tagMap = fetchTagsForNotes([params.id]);
|
||||
const attachmentMap = fetchAttachmentsForNotes([params.id]);
|
||||
|
||||
return json({ ...updated, tags: tagMap.get(params.id) ?? [] });
|
||||
return json({
|
||||
...updated,
|
||||
tags: tagMap.get(params.id) ?? [],
|
||||
attachments: attachmentMap.get(params.id) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment } from '$lib/server/attachments.js';
|
||||
import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
@@ -10,11 +10,16 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const attachment = await getAttachment(attachmentId);
|
||||
if (!attachment) throw error(404, 'Attachment not found');
|
||||
|
||||
const buffer = await readFile(attachment.path);
|
||||
const wantThumb = url.searchParams.get('thumb') === '1';
|
||||
const filePath = wantThumb && attachment.thumbnailPath ? attachment.thumbnailPath : attachment.path;
|
||||
const mimeType = wantThumb && attachment.thumbnailPath ? 'image/webp' : attachment.mimeType;
|
||||
|
||||
const buffer = await readFile(filePath);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`
|
||||
'Content-Type': mimeType,
|
||||
'Content-Disposition': `inline; filename="${attachment.filename}"`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -40,10 +45,24 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
throw error(400, 'File too large (max 10MB)');
|
||||
}
|
||||
|
||||
const attachment = await saveAttachment(params.id, file);
|
||||
const thumbnail = formData.get('thumbnail') as File | null;
|
||||
const attachment = await saveAttachment(params.id, file, thumbnail);
|
||||
return json(attachment, { status: 201 });
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ url, request }) => {
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
const body = await request.json();
|
||||
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
|
||||
|
||||
const updated = await updateAttachment(attachmentId, { featured: body.featured });
|
||||
if (!updated) throw error(404, 'Attachment not found');
|
||||
|
||||
return json(updated);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ url }) => {
|
||||
const attachmentId = url.searchParams.get('attachmentId');
|
||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const orders: { id: string; sortOrder: number }[] = body.orders;
|
||||
|
||||
if (!Array.isArray(orders)) {
|
||||
return json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const { id, sortOrder } of orders) {
|
||||
tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run();
|
||||
}
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { changes } = await request.json();
|
||||
@@ -18,10 +19,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
const since = parseInt(url.searchParams.get('since') || '0', 10);
|
||||
const changes = await getChangesSince(since);
|
||||
|
||||
const tagMap = fetchTagsForNotes(changes.map((n) => n.id));
|
||||
const noteIds = changes.map((n) => n.id);
|
||||
const tagMap = fetchTagsForNotes(noteIds);
|
||||
const attachmentMap = fetchAttachmentsForNotes(noteIds);
|
||||
const changesWithTags = changes.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
tags: tagMap.get(note.id) ?? [],
|
||||
attachments: attachmentMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(changesWithTags);
|
||||
|
||||
@@ -1,11 +1,172 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_IMAGE_PATH = join(__dirname, 'helpers', 'test-image.png');
|
||||
|
||||
/** Helper: create a note, close editor, reopen it */
|
||||
async function createAndReopenNote(page: import('@playwright/test').Page, title: string) {
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill(title);
|
||||
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||
await editor.click();
|
||||
await editor.pressSequentially('Test content');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, title)).toBeVisible();
|
||||
|
||||
// Reopen for attachment work (note must be saved first)
|
||||
await noteCard(page, title).click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Image Attachments', () => {
|
||||
test('Scenario: Note editor is available for creating a new note', async ({ authenticatedPage: page }) => {
|
||||
// When the user starts creating a new note
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
test('Scenario: Uploaded image appears as thumbnail in the editor', async ({ authenticatedPage: page }) => {
|
||||
// Given a saved note exists
|
||||
await createAndReopenNote(page, 'Attachment Test');
|
||||
|
||||
// Then the note editor is displayed
|
||||
// When the user opens the image panel and uploads an image
|
||||
await page.getByTestId('image-toggle').click();
|
||||
const fileInput = page.getByTestId('file-input');
|
||||
await fileInput.setInputFiles(TEST_IMAGE_PATH);
|
||||
|
||||
// Then a thumbnail appears in the editor
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Image persists after closing and reopening a note', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Persist Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user closes and reopens the note
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await noteCard(page, 'Persist Test').click();
|
||||
|
||||
// Then the image is still visible in the editor
|
||||
await expect(page.getByTestId('note-editor').locator('img')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Uploaded images are hidden from card header by default', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Hidden Default Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user closes the editor without featuring the image
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the card does NOT show a thumbnail strip
|
||||
const card = noteCard(page, 'Hidden Default Test');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByTestId('card-thumbnails')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Featuring an image makes it appear on the card header', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Feature Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user features the image by clicking the star
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
|
||||
// And closes the editor
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the card shows the featured image in its header
|
||||
const card = noteCard(page, 'Feature Test');
|
||||
await expect(card.getByTestId('card-thumbnails')).toBeVisible();
|
||||
await expect(card.getByTestId('card-thumbnail')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Unfeaturing an image removes it from the card header', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with a featured image exists
|
||||
await createAndReopenNote(page, 'Unfeature Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// Feature the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
const card = noteCard(page, 'Unfeature Test');
|
||||
await expect(card.getByTestId('card-thumbnails')).toBeVisible();
|
||||
|
||||
// When the user unfeatures the image
|
||||
await card.getByText('Unfeature Test').click();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('toggle-featured').click();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the card no longer shows the thumbnail strip
|
||||
await expect(card.getByTestId('card-thumbnails')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Removed image disappears from editor and card', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Remove Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
|
||||
|
||||
// When the user removes the image
|
||||
await page.getByTestId('attachment-thumbnail').hover();
|
||||
await page.getByTestId('remove-attachment').click();
|
||||
|
||||
// Then the thumbnail is gone from the editor
|
||||
await expect(page.getByTestId('attachment-thumbnail')).not.toBeVisible();
|
||||
|
||||
// And after closing, no thumbnail strip on the card
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
const card = noteCard(page, 'Remove Test');
|
||||
await expect(card.getByTestId('card-thumbnail')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Clicking an image thumbnail opens a full-size lightbox', async ({ authenticatedPage: page }) => {
|
||||
// Given a note with an uploaded image exists
|
||||
await createAndReopenNote(page, 'Lightbox Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
|
||||
await expect(page.getByTestId('attachment-thumbnail').first()).toBeVisible();
|
||||
|
||||
// When the user clicks the thumbnail
|
||||
await page.getByTestId('attachment-thumbnail').first().click();
|
||||
|
||||
// Then a full-size lightbox overlay is displayed
|
||||
await expect(page.getByTestId('image-lightbox')).toBeVisible();
|
||||
await expect(page.getByTestId('lightbox-image')).toBeVisible();
|
||||
|
||||
// When the user presses Escape
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Then the lightbox closes but the editor remains open
|
||||
await expect(page.getByTestId('image-lightbox')).not.toBeVisible();
|
||||
await expect(page.getByTestId('note-editor')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => {
|
||||
// When the user opens a new note and clicks the image toggle
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Auto-Save Test');
|
||||
await page.getByTestId('image-toggle').click();
|
||||
|
||||
// Then the upload panel is visible (note was auto-saved)
|
||||
await expect(page.getByTestId('image-upload')).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// And the note exists in the list
|
||||
await expect(noteCard(page, 'Auto-Save Test')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,12 +44,15 @@ test.describe('Checklist', () => {
|
||||
|
||||
// When the user reopens the note and checks the item
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await page.getByTestId('checklist-checkbox').first().check();
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the item moves to the done section
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then reopening the note shows the item is still checked
|
||||
// And reopening the note shows the item in the done section (expanded by default)
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await expect(page.getByTestId('checklist-checkbox').first()).toBeChecked();
|
||||
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
|
||||
});
|
||||
|
||||
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => {
|
||||
@@ -80,6 +83,29 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => {
|
||||
// Given a checklist note with a completed item and an uncompleted item
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Hide Done Test');
|
||||
await page.getByTestId('checklist-toggle').click();
|
||||
await page.getByTestId('checklist-input').first().fill('Done task');
|
||||
await page.getByTestId('checklist-input').first().press('Enter');
|
||||
await page.getByTestId('checklist-input').nth(1).fill('Pending task');
|
||||
await page.getByTestId('checklist-checkbox').first().click();
|
||||
|
||||
// Then the completed item is separated from the active list
|
||||
await expect(page.getByTestId('checklist-input')).toHaveCount(1);
|
||||
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
|
||||
|
||||
// And the "done" toggle shows the count
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
|
||||
|
||||
// Then the completed items are shown in the done section (expanded by default)
|
||||
await expect(page.getByTestId('checklist-done-section')).toBeVisible();
|
||||
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
|
||||
});
|
||||
|
||||
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => {
|
||||
// Given the user has checklist mode enabled
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -68,3 +68,64 @@ test.describe('Organization Features', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Note Sorting', () => {
|
||||
test('Scenario: Notes can be sorted by last updated', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist with different update times
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-First');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-First')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Sort-Second');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Sort-Second')).toBeVisible();
|
||||
|
||||
// When the user selects "Updated" sort mode
|
||||
await page.getByTestId('sort-updated').click();
|
||||
|
||||
// Then Sort-Second (most recently updated) appears before Sort-First
|
||||
const sortFirst = noteCard(page, 'Sort-First');
|
||||
const sortSecond = noteCard(page, 'Sort-Second');
|
||||
const firstBox = await sortSecond.boundingBox();
|
||||
const secondBox = await sortFirst.boundingBox();
|
||||
expect(firstBox!.y).toBeLessThanOrEqual(secondBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Notes can be sorted by creation date', async ({ authenticatedPage: page }) => {
|
||||
// Given two notes exist
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Older');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Older')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
await page.getByTestId('note-title-input').fill('Created-Newer');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
await expect(noteCard(page, 'Created-Newer')).toBeVisible();
|
||||
|
||||
// When the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// Then Created-Newer appears before Created-Older
|
||||
const newer = noteCard(page, 'Created-Newer');
|
||||
const older = noteCard(page, 'Created-Older');
|
||||
const newerBox = await newer.boundingBox();
|
||||
const olderBox = await older.boundingBox();
|
||||
expect(newerBox!.y).toBeLessThanOrEqual(olderBox!.y);
|
||||
});
|
||||
|
||||
test('Scenario: Sort preference persists across page reload', async ({ authenticatedPage: page }) => {
|
||||
// Given the user selects "Created" sort mode
|
||||
await page.getByTestId('sort-created').click();
|
||||
|
||||
// When the page is reloaded
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Then the "Created" sort mode is still active
|
||||
const createdBtn = page.getByTestId('sort-created');
|
||||
await expect(createdBtn).toHaveClass(/bg-\[var\(--primary\)\]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,14 @@ export default defineConfig({
|
||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
|
||||
navigateFallback: '/',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'attachment-cache',
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 365 * 24 * 60 * 60 }
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https?:\/\/.*\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>404 - Page not found | Crumbs</title>
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
.error-page {
|
||||
|
||||
+7
-6
@@ -13,6 +13,7 @@
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="assets/favicon-96x96.png">
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
||||
<script defer src="https://umami.bretzel.app/script.js" data-website-id="5969ad34-0b49-4af4-bddf-b026358a97af"></script>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -87,7 +88,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</div>
|
||||
<h3>Rich Editor</h3>
|
||||
<p>Tiptap-powered WYSIWYG with full Markdown support. Bold, lists, code blocks, and more.</p>
|
||||
<p>Tiptap-powered WYSIWYG with full Markdown support. Bold, lists, code blocks, checklists, and more.</p>
|
||||
</div>
|
||||
|
||||
<!-- Offline-first PWA -->
|
||||
@@ -117,13 +118,13 @@
|
||||
<p>Organize with tags, 12 note colors, and pinned notes. Your notes, your way.</p>
|
||||
</div>
|
||||
|
||||
<!-- Checklists -->
|
||||
<!-- Image Attachments -->
|
||||
<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"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
</div>
|
||||
<h3>Checklists</h3>
|
||||
<p>Built-in task lists with checkbox support. Track to-dos right inside your notes.</p>
|
||||
<h3>Image Attachments</h3>
|
||||
<p>Drag-and-drop images with auto-optimization. Works offline.</p>
|
||||
</div>
|
||||
|
||||
<!-- Full-text search -->
|
||||
@@ -221,7 +222,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.5.0</span>
|
||||
<span class="footer-version">v0.7.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user