mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-14 12:28:18 +02:00
Compare commits
@@ -85,11 +85,13 @@ jobs:
|
|||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: failure()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: playwright-report/
|
path: |
|
||||||
retention-days: 7
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
name: Docker Build
|
name: Docker Build
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Local notes
|
# Local notes
|
||||||
todo.md
|
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-minor` | — | Bump minor version, tag, and push |
|
||||||
| `make release-major` | — | Bump major 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
|
## CI/CD
|
||||||
|
|
||||||
GitHub Actions with two workflows:
|
GitHub Actions with two workflows:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
crumbs:
|
crumbs:
|
||||||
image: ghcr.io/bretzel-app/crumbs:0.5.0
|
image: ghcr.io/bretzel-app/crumbs:0.7.0
|
||||||
container_name: crumbs
|
container_name: crumbs
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ For a single-user app with multiple devices:
|
|||||||
- If timestamps match: higher version wins
|
- If timestamps match: higher version wins
|
||||||
- If both match: local version is preferred
|
- 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
|
## Tech Stack Rationale
|
||||||
|
|
||||||
### SvelteKit
|
### SvelteKit
|
||||||
@@ -89,7 +97,7 @@ Key tables:
|
|||||||
- `notes` - Core note data with version tracking
|
- `notes` - Core note data with version tracking
|
||||||
- `tags` - Unique tag names
|
- `tags` - Unique tag names
|
||||||
- `note_tags` - Many-to-many note ↔ tag
|
- `note_tags` - Many-to-many note ↔ tag
|
||||||
- `attachments` - Image file metadata
|
- `attachments` - Image file metadata (path + thumbnailPath)
|
||||||
- `sync_log` - Sync operation history
|
- `sync_log` - Sync operation history
|
||||||
|
|
||||||
## File Organization
|
## File Organization
|
||||||
|
|||||||
+10
-4
@@ -42,7 +42,13 @@
|
|||||||
- Upload images via file picker or drag-and-drop
|
- Upload images via file picker or drag-and-drop
|
||||||
- Supports JPEG, PNG, GIF, WebP, SVG
|
- Supports JPEG, PNG, GIF, WebP, SVG
|
||||||
- Max file size: 10MB
|
- 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
|
- Remove attachments from editor
|
||||||
|
|
||||||
### Tag Organization
|
### Tag Organization
|
||||||
@@ -90,9 +96,9 @@
|
|||||||
### PWA / Offline-First
|
### PWA / Offline-First
|
||||||
- Installable as standalone app
|
- Installable as standalone app
|
||||||
- Web app manifest with icons
|
- Web app manifest with icons
|
||||||
- Service worker for asset caching
|
- Service worker for asset caching (CacheFirst for attachments, NetworkFirst for API)
|
||||||
- IndexedDB for local data storage
|
- IndexedDB for local data storage (notes + pending attachments)
|
||||||
- Background sync every 30 seconds
|
- Background sync every 30 seconds (notes + offline attachments)
|
||||||
- LWW conflict resolution for multi-device use
|
- LWW conflict resolution for multi-device use
|
||||||
- Sync status indicator (synced/syncing/offline/error)
|
- Sync status indicator (synced/syncing/offline/error)
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Attachment list (without attachmentId) or file content (with attachmentId)
|
description: Attachment list (without attachmentId) or file content (with attachmentId)
|
||||||
@@ -209,6 +215,10 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
format: binary
|
format: binary
|
||||||
description: Image file (max 10MB)
|
description: Image file (max 10MB)
|
||||||
|
thumbnail:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
description: Pre-generated thumbnail (WebP, ~200px)
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Attachment metadata
|
description: Attachment metadata
|
||||||
@@ -368,6 +378,10 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
attachments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Attachment'
|
||||||
|
|
||||||
NoteInput:
|
NoteInput:
|
||||||
type: object
|
type: object
|
||||||
@@ -415,6 +429,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
size:
|
size:
|
||||||
type: integer
|
type: integer
|
||||||
|
thumbnailPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Server path to thumbnail file (null if no thumbnail)
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "crumbs",
|
"name": "crumbs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.1",
|
"version": "0.7.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bretzel (https://bretzel.app)",
|
"author": "Bretzel (https://bretzel.app)",
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
>
|
>
|
||||||
{#each activeItems as item (item.id)}
|
{#each activeItems as item (item.id)}
|
||||||
<div class="group flex items-center gap-2" animate:flip={{ duration: flipDurationMs }}>
|
<div class="group flex items-center gap-2" animate:flip={{ duration: flipDurationMs }}>
|
||||||
<div use:dragHandle aria-label="drag handle" class="drag-handle cursor-grab opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150" data-testid="checklist-drag-handle">
|
<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)]" />
|
<GripVertical class="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={() => removeItem(item.id)}
|
onclick={() => removeItem(item.id)}
|
||||||
class="opacity-0 group-hover:opacity-100 focus:opacity-100"
|
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||||
aria-label="Remove item"
|
aria-label="Remove item"
|
||||||
data-testid="checklist-remove"
|
data-testid="checklist-remove"
|
||||||
>
|
>
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
<span class="flex-1 text-sm text-[var(--text-muted)] line-through">{item.text}</span>
|
||||||
<button
|
<button
|
||||||
onclick={() => removeItem(item.id)}
|
onclick={() => removeItem(item.id)}
|
||||||
class="opacity-0 group-hover:opacity-100 focus:opacity-100"
|
class="max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100"
|
||||||
aria-label="Remove item"
|
aria-label="Remove item"
|
||||||
data-testid="checklist-done-remove"
|
data-testid="checklist-done-remove"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import XIcon from 'lucide-svelte/icons/x';
|
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 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 {
|
interface Props {
|
||||||
noteId: string;
|
noteId: string;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
onUpload: (attachment: Attachment) => void;
|
onUpload: (attachment: Attachment) => void;
|
||||||
onRemove: (attachmentId: string) => 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 uploading = $state(false);
|
||||||
|
let optimizing = $state(false);
|
||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
|
let lightboxSrc = $state<string | null>(null);
|
||||||
|
let lightboxAlt = $state('');
|
||||||
|
|
||||||
async function handleFiles(files: FileList | null) {
|
async function handleFiles(files: FileList | null) {
|
||||||
if (!files || !noteId) return;
|
if (!files || !noteId) return;
|
||||||
|
|
||||||
uploading = true;
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.type.startsWith('image/')) continue;
|
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();
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
||||||
@@ -32,13 +86,17 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const attachment = await res.json();
|
const attachment = await res.json();
|
||||||
onUpload(attachment);
|
onUpload(attachment);
|
||||||
|
} else {
|
||||||
|
console.error('Upload failed:', res.status, await res.text());
|
||||||
|
showToast('Failed to upload image', 'error');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload failed:', err);
|
console.error('Upload failed:', err);
|
||||||
}
|
showToast('Failed to upload image', 'error');
|
||||||
}
|
}
|
||||||
uploading = false;
|
uploading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
function handleDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -57,16 +115,34 @@
|
|||||||
{#if attachments.length > 0}
|
{#if attachments.length > 0}
|
||||||
<div class="mb-2 flex flex-wrap gap-2">
|
<div class="mb-2 flex flex-wrap gap-2">
|
||||||
{#each attachments as attachment}
|
{#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">
|
<div class="group relative">
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
|
||||||
<img
|
<img
|
||||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
src={imgSrc}
|
||||||
alt={attachment.filename}
|
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"
|
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
|
<button
|
||||||
onclick={() => onRemove(attachment.id)}
|
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"
|
aria-label="Remove attachment"
|
||||||
data-testid="remove-attachment"
|
data-testid="remove-attachment"
|
||||||
>
|
>
|
||||||
@@ -87,7 +163,9 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
data-testid="upload-dropzone"
|
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>
|
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||||
{:else}
|
{:else}
|
||||||
<label class="cursor-pointer">
|
<label class="cursor-pointer">
|
||||||
@@ -104,3 +182,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if lightboxSrc}
|
||||||
|
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
||||||
import TagChip from './TagChip.svelte';
|
import TagChip from './TagChip.svelte';
|
||||||
|
import ImageLightbox from './ImageLightbox.svelte';
|
||||||
import type { Note } from '$lib/types/index.js';
|
import type { Note } from '$lib/types/index.js';
|
||||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||||
@@ -29,6 +30,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let cardStyle = $state('');
|
let cardStyle = $state('');
|
||||||
|
let lightboxSrc = $state<string | null>(null);
|
||||||
|
let lightboxAlt = $state('');
|
||||||
|
|
||||||
const renderedContent = $derived(renderMarkdown(note.content));
|
const renderedContent = $derived(renderMarkdown(note.content));
|
||||||
|
|
||||||
@@ -45,6 +48,8 @@
|
|||||||
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
|
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
|
||||||
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
|
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
|
||||||
|
|
||||||
|
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
|
||||||
|
|
||||||
function stop(fn: () => void) {
|
function stop(fn: () => void) {
|
||||||
return (e: Event) => {
|
return (e: Event) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -78,6 +83,30 @@
|
|||||||
data-testid="note-card"
|
data-testid="note-card"
|
||||||
data-note-id={note.id}
|
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}
|
{#if note.pinned}
|
||||||
<button
|
<button
|
||||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||||
@@ -185,6 +214,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if lightboxSrc}
|
||||||
|
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
import Checklist from './Checklist.svelte';
|
import Checklist from './Checklist.svelte';
|
||||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||||
import TiptapEditor from './TiptapEditor.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 { updateNote, createNote } from '$lib/stores/notes.js';
|
||||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||||
import type { Editor } from '@tiptap/core';
|
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 Palette from 'lucide-svelte/icons/palette';
|
||||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||||
|
import ImageIcon from 'lucide-svelte/icons/image';
|
||||||
import Type from 'lucide-svelte/icons/type';
|
import Type from 'lucide-svelte/icons/type';
|
||||||
import FileCode from 'lucide-svelte/icons/file-code';
|
import FileCode from 'lucide-svelte/icons/file-code';
|
||||||
import FileText from 'lucide-svelte/icons/file-text';
|
import FileText from 'lucide-svelte/icons/file-text';
|
||||||
@@ -30,10 +33,68 @@
|
|||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let checklistMode = $state(note?.checklistMode ?? false);
|
let checklistMode = $state(note?.checklistMode ?? false);
|
||||||
let showColorPicker = $state(false);
|
let showColorPicker = $state(false);
|
||||||
|
let showImageUpload = $state(false);
|
||||||
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
let rawMarkdownMode = $state(note?.checklistMode ?? false);
|
||||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||||
let tiptapEditor: Editor | undefined = $state();
|
let tiptapEditor: Editor | undefined = $state();
|
||||||
let editorTick = $state(0);
|
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('');
|
let bgStyle = $state('');
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -41,23 +102,48 @@
|
|||||||
bgStyle = `background-color: ${colors.bg}`;
|
bgStyle = `background-color: ${colors.bg}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function saveAndClose() {
|
||||||
if (!title.trim() && !content.trim()) {
|
if (!title.trim() && !content.trim()) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNew) {
|
if (currentlyNew) {
|
||||||
await createNote({ title, content, color, checklistMode });
|
await createNote({ title, content, color, checklistMode });
|
||||||
} else if (note) {
|
} else if (noteId) {
|
||||||
await updateNote(note.id, { title, content, color, checklistMode });
|
await updateNote(noteId, { title, content, color, checklistMode });
|
||||||
}
|
}
|
||||||
onClose();
|
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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
save();
|
saveAndClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +163,7 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
class="fixed 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}
|
onkeydown={handleKeydown}
|
||||||
data-testid="note-editor-overlay"
|
data-testid="note-editor-overlay"
|
||||||
>
|
>
|
||||||
@@ -124,6 +210,38 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- Formatting toolbar -->
|
||||||
{#if !rawMarkdownMode && !checklistMode}
|
{#if !rawMarkdownMode && !checklistMode}
|
||||||
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
|
||||||
@@ -163,6 +281,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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 -->
|
<!-- Raw markdown mode toggle -->
|
||||||
<button
|
<button
|
||||||
onclick={toggleMarkdownMode}
|
onclick={toggleMarkdownMode}
|
||||||
@@ -180,7 +308,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={save}
|
onclick={saveAndClose}
|
||||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||||
data-testid="close-editor-btn"
|
data-testid="close-editor-btn"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { join } from 'path';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { db } from './db/index.js';
|
import { db } from './db/index.js';
|
||||||
import { attachments } from './db/schema.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 DATA_DIR = process.env.DATA_DIR || './data';
|
||||||
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
||||||
@@ -16,7 +17,8 @@ if (!existsSync(ATTACHMENTS_DIR)) {
|
|||||||
|
|
||||||
export async function saveAttachment(
|
export async function saveAttachment(
|
||||||
noteId: string,
|
noteId: string,
|
||||||
file: File
|
file: File,
|
||||||
|
thumbnail?: File | Blob | null
|
||||||
): Promise<typeof attachments.$inferSelect> {
|
): Promise<typeof attachments.$inferSelect> {
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const ext = file.name.split('.').pop() || 'bin';
|
const ext = file.name.split('.').pop() || 'bin';
|
||||||
@@ -26,6 +28,14 @@ export async function saveAttachment(
|
|||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await writeFile(filePath, buffer);
|
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
|
const [attachment] = await db
|
||||||
.insert(attachments)
|
.insert(attachments)
|
||||||
.values({
|
.values({
|
||||||
@@ -35,6 +45,7 @@ export async function saveAttachment(
|
|||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
|
thumbnailPath,
|
||||||
createdAt: new Date()
|
createdAt: new Date()
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -42,6 +53,31 @@ export async function saveAttachment(
|
|||||||
return attachment;
|
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) {
|
export async function getAttachment(id: string) {
|
||||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
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));
|
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) {
|
export async function deleteAttachment(id: string) {
|
||||||
const attachment = await getAttachment(id);
|
const attachment = await getAttachment(id);
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
@@ -58,6 +103,13 @@ export async function deleteAttachment(id: string) {
|
|||||||
} catch {
|
} catch {
|
||||||
// File may already be deleted
|
// 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));
|
await db.delete(attachments).where(eq(attachments.id, id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ sqlite.exec(`
|
|||||||
mime_type TEXT NOT NULL,
|
mime_type TEXT NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
|
thumbnail_path TEXT,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,5 +85,17 @@ sqlite.exec(`
|
|||||||
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
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 const db = drizzle(sqlite, { schema });
|
||||||
export { sqlite };
|
export { sqlite };
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export const attachments = sqliteTable('attachments', {
|
|||||||
mimeType: text('mime_type').notNull(),
|
mimeType: text('mime_type').notNull(),
|
||||||
size: integer('size').notNull(),
|
size: integer('size').notNull(),
|
||||||
path: text('path').notNull(),
|
path: text('path').notNull(),
|
||||||
|
thumbnailPath: text('thumbnail_path'),
|
||||||
|
featured: integer('featured', { mode: 'boolean' }).default(false).notNull(),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -1,6 +1,6 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import type { Note } from '$lib/types/index.js';
|
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 { mergeNotes } from './crdt.js';
|
||||||
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
|
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -49,6 +49,31 @@ 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.
|
* Pull remote changes and merge with local state.
|
||||||
*/
|
*/
|
||||||
@@ -98,6 +123,7 @@ export async function sync(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pushPendingAttachments();
|
||||||
await pullChanges();
|
await pullChanges();
|
||||||
setStatus('synced');
|
setStatus('synced');
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -2,7 +2,17 @@ import { openDB, type IDBPDatabase } from 'idb';
|
|||||||
import type { Note } from '$lib/types/index.js';
|
import type { Note } from '$lib/types/index.js';
|
||||||
|
|
||||||
const DB_NAME = 'crumbs';
|
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 {
|
interface CrumbsDB {
|
||||||
notes: {
|
notes: {
|
||||||
@@ -23,6 +33,13 @@ interface CrumbsDB {
|
|||||||
key: string;
|
key: string;
|
||||||
value: { key: string; value: string };
|
value: { key: string; value: string };
|
||||||
};
|
};
|
||||||
|
pendingAttachments: {
|
||||||
|
key: string;
|
||||||
|
value: PendingAttachment;
|
||||||
|
indexes: {
|
||||||
|
'by-noteId': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncQueueItem {
|
export interface SyncQueueItem {
|
||||||
@@ -58,6 +75,12 @@ export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
|
|||||||
if (!db.objectStoreNames.contains('meta')) {
|
if (!db.objectStoreNames.contains('meta')) {
|
||||||
db.createObjectStore('meta', { keyPath: 'key' });
|
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();
|
const db = await getDb();
|
||||||
await db.put('meta', { key, value });
|
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;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
thumbnailPath?: string | null;
|
||||||
|
featured: boolean;
|
||||||
createdAt: Date;
|
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>
|
</main>
|
||||||
|
|
||||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
<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>
|
</footer>
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { eq, and, desc } from 'drizzle-orm';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { extractTags } from '$lib/utils/tags.js';
|
import { extractTags } from '$lib/utils/tags.js';
|
||||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||||
|
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const filter = url.searchParams.get('filter') || 'all';
|
const filter = url.searchParams.get('filter') || 'all';
|
||||||
@@ -29,10 +30,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||||
.all();
|
.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) => ({
|
const notesWithTags = result.map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
tags: tagMap.get(note.id) ?? []
|
tags: tagMap.get(note.id) ?? [],
|
||||||
|
attachments: attachmentMap.get(note.id) ?? []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return json(notesWithTags);
|
return json(notesWithTags);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { notes } from '$lib/server/db/schema.js';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { extractTags } from '$lib/utils/tags.js';
|
import { extractTags } from '$lib/utils/tags.js';
|
||||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||||
|
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
|
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 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 }) => {
|
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||||
@@ -55,8 +61,13 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tagMap = fetchTagsForNotes([params.id]);
|
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 }) => {
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types.js';
|
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';
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url }) => {
|
export const GET: RequestHandler = async ({ params, url }) => {
|
||||||
@@ -10,11 +10,16 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
|||||||
const attachment = await getAttachment(attachmentId);
|
const attachment = await getAttachment(attachmentId);
|
||||||
if (!attachment) throw error(404, 'Attachment not found');
|
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, {
|
return new Response(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': attachment.mimeType,
|
'Content-Type': mimeType,
|
||||||
'Content-Disposition': `inline; filename="${attachment.filename}"`
|
'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)');
|
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 });
|
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 }) => {
|
export const DELETE: RequestHandler = async ({ url }) => {
|
||||||
const attachmentId = url.searchParams.get('attachmentId');
|
const attachmentId = url.searchParams.get('attachmentId');
|
||||||
if (!attachmentId) throw error(400, 'attachmentId required');
|
if (!attachmentId) throw error(400, 'attachmentId required');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types.js';
|
import type { RequestHandler } from './$types.js';
|
||||||
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
||||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||||
|
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const { changes } = await request.json();
|
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 since = parseInt(url.searchParams.get('since') || '0', 10);
|
||||||
const changes = await getChangesSince(since);
|
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) => ({
|
const changesWithTags = changes.map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
tags: tagMap.get(note.id) ?? []
|
tags: tagMap.get(note.id) ?? [],
|
||||||
|
attachments: attachmentMap.get(note.id) ?? []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return json(changesWithTags);
|
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.describe('Image Attachments', () => {
|
||||||
test('Scenario: Note editor is available for creating a new note', async ({ authenticatedPage: page }) => {
|
test('Scenario: Uploaded image appears as thumbnail in the editor', async ({ authenticatedPage: page }) => {
|
||||||
// When the user starts creating a new note
|
// Given a saved note exists
|
||||||
await page.getByTestId('new-note-btn').click();
|
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();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -27,6 +27,14 @@ export default defineConfig({
|
|||||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
|
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'],
|
||||||
navigateFallback: '/',
|
navigateFallback: '/',
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /\/api\/notes\/.*\/attachments\?attachmentId=/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'attachment-cache',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 365 * 24 * 60 * 60 }
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /^https?:\/\/.*\/api\//,
|
urlPattern: /^https?:\/\/.*\/api\//,
|
||||||
handler: 'NetworkFirst',
|
handler: 'NetworkFirst',
|
||||||
|
|||||||
+6
-6
@@ -88,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>
|
<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>
|
</div>
|
||||||
<h3>Rich Editor</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Offline-first PWA -->
|
<!-- Offline-first PWA -->
|
||||||
@@ -118,13 +118,13 @@
|
|||||||
<p>Organize with tags, 12 note colors, and pinned notes. Your notes, your way.</p>
|
<p>Organize with tags, 12 note colors, and pinned notes. Your notes, your way.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checklists -->
|
<!-- Image Attachments -->
|
||||||
<div class="feature-card animate-in delay-5">
|
<div class="feature-card animate-in delay-5">
|
||||||
<div class="feature-icon">
|
<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>
|
</div>
|
||||||
<h3>Checklists</h3>
|
<h3>Image Attachments</h3>
|
||||||
<p>Built-in task lists with checkbox support. Track to-dos right inside your notes.</p>
|
<p>Drag-and-drop images with auto-optimization. Works offline.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Full-text search -->
|
<!-- Full-text search -->
|
||||||
@@ -222,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/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>
|
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<span class="footer-version">v0.6.1</span>
|
<span class="footer-version">v0.7.1</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user