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:
Frederic Cabassut
2026-03-08 22:37:50 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 175c821e8d
commit f7e7aaa97d
12 changed files with 776 additions and 18 deletions
+67
View File
@@ -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
);
+465
View File
@@ -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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1773000424830,
"tag": "0000_wooden_vargas",
"breakpoints": true
}
]
}
+25 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import type { Attachment } from '$lib/types/index.js';
import { optimizeImage } from '$lib/utils/image-optimize.js';
interface Props {
noteId: string;
@@ -12,17 +13,33 @@
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
let uploading = $state(false);
let optimizing = $state(false);
let dragOver = $state(false);
async function handleFiles(files: FileList | null) {
if (!files || !noteId) return;
uploading = true;
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
optimizing = true;
let optimized: Blob;
let thumbnail: Blob;
try {
const result = await optimizeImage(file);
optimized = result.optimized;
thumbnail = result.thumbnail;
} catch (err) {
console.error('Optimization failed, using original:', err);
optimized = file;
thumbnail = file;
}
optimizing = false;
uploading = true;
const formData = new FormData();
formData.append('file', file);
formData.append('file', new File([optimized], file.name, { type: optimized.type || file.type }));
formData.append('thumbnail', new File([thumbnail], `${file.name}_thumb.webp`, { type: 'image/webp' }));
try {
const res = await fetch(`/api/notes/${noteId}/attachments`, {
@@ -36,8 +53,8 @@
} catch (err) {
console.error('Upload failed:', err);
}
uploading = false;
}
uploading = false;
}
function handleDrop(e: DragEvent) {
@@ -59,14 +76,14 @@
{#each attachments as attachment}
<div class="group relative">
<img
src="/api/notes/{noteId}/attachments/{attachment.id}"
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded-sm object-cover"
data-testid="attachment-thumbnail"
/>
<button
onclick={() => onRemove(attachment.id)}
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
class="absolute -right-1 -top-1 hidden rounded-full bg-[var(--destructive)] p-0.5 text-white group-hover:block"
aria-label="Remove attachment"
data-testid="remove-attachment"
>
@@ -87,7 +104,9 @@
tabindex="0"
data-testid="upload-dropzone"
>
{#if uploading}
{#if optimizing}
<p class="text-sm text-[var(--text-muted)]">Optimizing...</p>
{:else if uploading}
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
{:else}
<label class="cursor-pointer">
+45 -2
View File
@@ -4,7 +4,8 @@ import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js';
import { attachments } from './db/schema.js';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js';
const DATA_DIR = process.env.DATA_DIR || './data';
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
@@ -16,7 +17,8 @@ if (!existsSync(ATTACHMENTS_DIR)) {
export async function saveAttachment(
noteId: string,
file: File
file: File,
thumbnail?: File | Blob | null
): Promise<typeof attachments.$inferSelect> {
const id = uuidv4();
const ext = file.name.split('.').pop() || 'bin';
@@ -26,6 +28,14 @@ export async function saveAttachment(
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
let thumbnailPath: string | null = null;
if (thumbnail) {
const thumbFilename = `${id}_thumb.webp`;
thumbnailPath = join(ATTACHMENTS_DIR, thumbFilename);
const thumbBuffer = Buffer.from(await thumbnail.arrayBuffer());
await writeFile(thumbnailPath, thumbBuffer);
}
const [attachment] = await db
.insert(attachments)
.values({
@@ -35,6 +45,7 @@ export async function saveAttachment(
mimeType: file.type,
size: file.size,
path: filePath,
thumbnailPath,
createdAt: new Date()
})
.returning();
@@ -42,6 +53,31 @@ export async function saveAttachment(
return attachment;
}
/**
* Batch fetch attachments for multiple notes in a single query.
* Returns a Map of noteId → Attachment[].
*/
export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachment[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select()
.from(attachments)
.where(inArray(attachments.noteId, noteIds))
.all();
const map = new Map<string, Attachment[]>();
for (const row of rows) {
const existing = map.get(row.noteId);
if (existing) {
existing.push(row as Attachment);
} else {
map.set(row.noteId, [row as Attachment]);
}
}
return map;
}
export async function getAttachment(id: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get();
}
@@ -58,6 +94,13 @@ export async function deleteAttachment(id: string) {
} catch {
// File may already be deleted
}
if (attachment.thumbnailPath) {
try {
await unlink(attachment.thumbnailPath);
} catch {
// Thumbnail may already be deleted
}
}
await db.delete(attachments).where(eq(attachments.id, id));
}
}
+1
View File
@@ -71,6 +71,7 @@ export const attachments = sqliteTable('attachments', {
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
path: text('path').notNull(),
thumbnailPath: text('thumbnail_path'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
+1
View File
@@ -42,6 +42,7 @@ export interface Attachment {
mimeType: string;
size: number;
path: string;
thumbnailPath?: string | null;
createdAt: Date;
}
+124
View File
@@ -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 -2
View File
@@ -6,6 +6,7 @@ import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
export const GET: RequestHandler = async ({ url }) => {
const filter = url.searchParams.get('filter') || 'all';
@@ -29,10 +30,13 @@ export const GET: RequestHandler = async ({ url }) => {
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
const tagMap = fetchTagsForNotes(result.map((n) => n.id));
const noteIds = result.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const notesWithTags = result.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
return json(notesWithTags);
+13 -2
View File
@@ -5,6 +5,7 @@ import { notes } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
export const GET: RequestHandler = async ({ params }) => {
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
@@ -14,8 +15,13 @@ export const GET: RequestHandler = async ({ params }) => {
}
const tagMap = fetchTagsForNotes([note.id]);
const attachmentMap = fetchAttachmentsForNotes([note.id]);
return json({ ...note, tags: tagMap.get(note.id) ?? [] });
return json({
...note,
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
});
};
export const PATCH: RequestHandler = async ({ params, request }) => {
@@ -55,8 +61,13 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
}
const tagMap = fetchTagsForNotes([params.id]);
const attachmentMap = fetchAttachmentsForNotes([params.id]);
return json({ ...updated, tags: tagMap.get(params.id) ?? [] });
return json({
...updated,
tags: tagMap.get(params.id) ?? [],
attachments: attachmentMap.get(params.id) ?? []
});
};
export const DELETE: RequestHandler = async ({ params }) => {
@@ -10,11 +10,16 @@ export const GET: RequestHandler = async ({ params, url }) => {
const attachment = await getAttachment(attachmentId);
if (!attachment) throw error(404, 'Attachment not found');
const buffer = await readFile(attachment.path);
const wantThumb = url.searchParams.get('thumb') === '1';
const filePath = wantThumb && attachment.thumbnailPath ? attachment.thumbnailPath : attachment.path;
const mimeType = wantThumb && attachment.thumbnailPath ? 'image/webp' : attachment.mimeType;
const buffer = await readFile(filePath);
return new Response(buffer, {
headers: {
'Content-Type': attachment.mimeType,
'Content-Disposition': `inline; filename="${attachment.filename}"`
'Content-Type': mimeType,
'Content-Disposition': `inline; filename="${attachment.filename}"`,
'Cache-Control': 'public, max-age=31536000, immutable'
}
});
}
@@ -40,7 +45,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
throw error(400, 'File too large (max 10MB)');
}
const attachment = await saveAttachment(params.id, file);
const thumbnail = formData.get('thumbnail') as File | null;
const attachment = await saveAttachment(params.id, file, thumbnail);
return json(attachment, { status: 201 });
};
+6 -2
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
import { fetchTagsForNotes } from '$lib/server/tags.js';
import { fetchAttachmentsForNotes } from '$lib/server/attachments.js';
export const POST: RequestHandler = async ({ request }) => {
const { changes } = await request.json();
@@ -18,10 +19,13 @@ export const GET: RequestHandler = async ({ url }) => {
const since = parseInt(url.searchParams.get('since') || '0', 10);
const changes = await getChangesSince(since);
const tagMap = fetchTagsForNotes(changes.map((n) => n.id));
const noteIds = changes.map((n) => n.id);
const tagMap = fetchTagsForNotes(noteIds);
const attachmentMap = fetchAttachmentsForNotes(noteIds);
const changesWithTags = changes.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
tags: tagMap.get(note.id) ?? [],
attachments: attachmentMap.get(note.id) ?? []
}));
return json(changesWithTags);