diff --git a/src/lib/server/images/storage.ts b/src/lib/server/images/storage.ts new file mode 100644 index 0000000..305f785 --- /dev/null +++ b/src/lib/server/images/storage.ts @@ -0,0 +1,61 @@ +import { mkdir, writeFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +export const UPLOAD_DIR = 'data/uploads'; +export const ORIGINALS_DIR = 'data/uploads/originals'; +export const THUMBNAILS_DIR = 'data/uploads/thumbnails'; + +/** + * Ensure upload directories exist + */ +export async function ensureDirectories(): Promise { + await mkdir(ORIGINALS_DIR, { recursive: true }); + await mkdir(THUMBNAILS_DIR, { recursive: true }); +} + +/** + * Save original image to filesystem + */ +export async function saveOriginal(id: string, ext: string, buffer: Buffer): Promise { + const path = getOriginalPath(id, ext); + await writeFile(path, buffer); +} + +/** + * Save thumbnail to filesystem (always jpg) + */ +export async function saveThumbnail(id: string, buffer: Buffer): Promise { + const path = getThumbnailPath(id); + await writeFile(path, buffer); +} + +/** + * Get path to original image + */ +export function getOriginalPath(id: string, ext: string): string { + return join(ORIGINALS_DIR, `${id}.${ext}`); +} + +/** + * Get path to thumbnail (always jpg) + */ +export function getThumbnailPath(id: string): string { + return join(THUMBNAILS_DIR, `${id}.jpg`); +} + +/** + * Delete both original and thumbnail files + * Does not throw if files don't exist + */ +export async function deleteImage(id: string, ext: string): Promise { + try { + await unlink(getOriginalPath(id, ext)); + } catch { + // File may not exist, ignore + } + try { + await unlink(getThumbnailPath(id)); + } catch { + // File may not exist, ignore + } +} diff --git a/src/lib/server/images/thumbnails.ts b/src/lib/server/images/thumbnails.ts new file mode 100644 index 0000000..a65d56c --- /dev/null +++ b/src/lib/server/images/thumbnails.ts @@ -0,0 +1,23 @@ +import sharp from 'sharp'; + +/** + * Generate a thumbnail from an image buffer + * + * CRITICAL: Uses sharp.rotate() first to handle EXIF orientation from mobile photos. + * Without this, photos taken in portrait mode may appear rotated. + * + * @param buffer - Original image buffer + * @param size - Thumbnail size (default 150px) + * @returns Thumbnail buffer as JPEG + */ +export async function generateThumbnail(buffer: Buffer, size: number = 150): Promise { + return sharp(buffer) + .rotate() // Auto-rotate based on EXIF orientation + .resize(size, size, { + fit: 'cover', + position: 'center', + withoutEnlargement: true + }) + .jpeg({ quality: 80 }) + .toBuffer(); +}