mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
feat: add image attachment backend — batch fetch, API responses, optimization, thumbnails
- Add fetchAttachmentsForNotes() batch helper mirroring fetchTagsForNotes pattern - Include attachments in all note API responses (list, single, sync) - Fix ImageUpload.svelte URL bug (path-based → query param) - Add client-side image optimization (resize, compress, WebP conversion) - Add thumbnailPath column to attachments schema with migration - Update saveAttachment/deleteAttachment for thumbnail file handling - Update attachment API: accept thumbnail in POST, serve via &thumb=1 in GET - Add Cache-Control immutable header for served attachments Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
a279b2f406
commit
854725c7ba
@@ -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,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import XIcon from 'lucide-svelte/icons/x';
|
import XIcon from 'lucide-svelte/icons/x';
|
||||||
import type { Attachment } from '$lib/types/index.js';
|
import type { Attachment } from '$lib/types/index.js';
|
||||||
|
import { optimizeImage } from '$lib/utils/image-optimize.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
noteId: string;
|
noteId: string;
|
||||||
@@ -12,17 +13,33 @@
|
|||||||
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
|
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
|
||||||
|
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
|
let optimizing = $state(false);
|
||||||
let dragOver = $state(false);
|
let dragOver = $state(false);
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
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`, {
|
||||||
@@ -36,8 +53,8 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload failed:', err);
|
console.error('Upload failed:', err);
|
||||||
}
|
}
|
||||||
|
uploading = false;
|
||||||
}
|
}
|
||||||
uploading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
function handleDrop(e: DragEvent) {
|
||||||
@@ -59,14 +76,14 @@
|
|||||||
{#each attachments as attachment}
|
{#each attachments as attachment}
|
||||||
<div class="group relative">
|
<div class="group relative">
|
||||||
<img
|
<img
|
||||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
|
||||||
alt={attachment.filename}
|
alt={attachment.filename}
|
||||||
class="h-20 w-20 rounded-sm object-cover"
|
class="h-20 w-20 rounded-sm object-cover"
|
||||||
data-testid="attachment-thumbnail"
|
data-testid="attachment-thumbnail"
|
||||||
/>
|
/>
|
||||||
<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 +104,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">
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -58,6 +94,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ 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'),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface Attachment {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
thumbnailPath?: string | null;
|
||||||
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 };
|
||||||
|
}
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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,7 +45,8 @@ 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 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user