diff --git a/.planning/phases/03-images/03-RESEARCH.md b/.planning/phases/03-images/03-RESEARCH.md new file mode 100644 index 0000000..7dd408f --- /dev/null +++ b/.planning/phases/03-images/03-RESEARCH.md @@ -0,0 +1,521 @@ +# Phase 3: Images - Research + +**Researched:** 2026-01-29 +**Domain:** Image attachments with file upload, camera capture, storage, and display +**Confidence:** HIGH + +## Summary + +This phase implements image attachments for entries with support for file upload (desktop), camera capture (mobile), thumbnail generation, and lightbox viewing. The research covers the complete image lifecycle: capture/upload on the client, transfer to server via form actions, storage on filesystem with thumbnail generation, and retrieval via API endpoints. + +The established approach for SvelteKit image handling is: +1. **Client:** HTML5 File API for uploads, MediaDevices API for camera capture, canvas.toBlob() for photo capture +2. **Server:** Form actions with multipart/form-data, write files via Node.js fs/promises +3. **Processing:** Sharp for thumbnail generation (dramatically faster than alternatives) +4. **Storage:** Filesystem-based with API endpoints serving images (not static folder) +5. **Display:** svelte-lightbox for fullscreen viewing, native img tags for thumbnails + +**Primary recommendation:** Use Sharp for server-side thumbnail generation, native browser APIs for camera/upload, and svelte-lightbox for the lightbox component. Store images outside static/ and serve via +server.ts endpoints for better control. + +## Standard Stack + +The established libraries/tools for this domain: + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| sharp | latest | Server-side image processing, thumbnail generation | 4-5x faster than ImageMagick, 20x faster than Jimp. Native C++ bindings via libvips | +| svelte-lightbox | 1.1.x | Fullscreen image lightbox | Purpose-built for Svelte/SvelteKit, keyboard navigation, mobile support | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| nanoid | 5.1.x | Generate unique filenames | Already in project, use for image IDs | +| Node.js fs/promises | built-in | File system operations | Write uploaded images to disk | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| sharp | jimp | Pure JS (no native deps), but 20x slower - not recommended | +| svelte-lightbox | bigger-picture | Smaller bundle, but needs Svelte 4 compatibility config for Svelte 5 | +| Filesystem storage | SQLite blob | Simpler queries, but larger DB, slower, harder to backup | + +**Installation:** +```bash +npm install sharp svelte-lightbox +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +data/ + uploads/ + originals/ # Full-size images (user uploaded) + thumbnails/ # Generated thumbnails (150x150) +src/ + lib/ + server/ + images/ + storage.ts # File system operations + thumbnails.ts # Sharp thumbnail generation + components/ + ImageUpload.svelte # Upload button + drag-drop zone + CameraCapture.svelte # Camera modal with preview + ImageGallery.svelte # Horizontal scroll gallery + ImageLightbox.svelte # Fullscreen viewer wrapper + routes/ + api/ + images/ + [id]/ + +server.ts # Serve original image + thumbnail/ + +server.ts # Serve thumbnail +``` + +### Pattern 1: Form Action File Upload +**What:** Handle multipart form data in SvelteKit form actions +**When to use:** File uploads from forms +**Example:** +```typescript +// +page.server.ts +import { writeFile } from 'node:fs/promises'; +import { nanoid } from 'nanoid'; +import sharp from 'sharp'; + +export const actions = { + uploadImage: async ({ request }) => { + const formData = await request.formData(); + const file = formData.get('image') as File; + const entryId = formData.get('entryId') as string; + + if (!file || file.size === 0) { + return fail(400, { error: 'No file uploaded' }); + } + + const imageId = nanoid(); + const ext = file.name.split('.').pop() || 'jpg'; + const buffer = Buffer.from(await file.arrayBuffer()); + + // Save original + await writeFile(`data/uploads/originals/${imageId}.${ext}`, buffer); + + // Generate thumbnail with EXIF auto-rotation + await sharp(buffer) + .rotate() // Auto-orient based on EXIF + .resize(150, 150, { fit: 'cover', position: 'center' }) + .jpeg({ quality: 80 }) + .toFile(`data/uploads/thumbnails/${imageId}.jpg`); + + // Save to database + // imageRepository.create({ id: imageId, entryId, ext }); + + return { success: true, imageId }; + } +}; +``` + +### Pattern 2: Camera Capture with getUserMedia +**What:** Access device camera directly in browser +**When to use:** Mobile photo capture, desktop webcam +**Example:** +```typescript +// CameraCapture.svelte + +``` + +### Pattern 3: API Endpoint for Serving Images +**What:** Serve uploaded images via +server.ts endpoints +**When to use:** Serving user-uploaded files not in static/ +**Example:** +```typescript +// src/routes/api/images/[id]/+server.ts +import { readFile } from 'node:fs/promises'; +import { error } from '@sveltejs/kit'; + +export async function GET({ params }) { + const { id } = params; + + // Look up image metadata from database + const image = imageRepository.getById(id); + if (!image) { + throw error(404, 'Image not found'); + } + + const filePath = `data/uploads/originals/${id}.${image.ext}`; + + try { + const data = await readFile(filePath); + return new Response(data, { + headers: { + 'Content-Type': `image/${image.ext === 'jpg' ? 'jpeg' : image.ext}`, + 'Content-Length': data.length.toString(), + 'Cache-Control': 'public, max-age=31536000, immutable' + } + }); + } catch { + throw error(404, 'Image file not found'); + } +} +``` + +### Pattern 4: Drag and Drop Zone +**What:** HTML5 drag-and-drop file upload +**When to use:** Desktop file upload experience +**Example:** +```typescript +// ImageUpload.svelte + + +
fileInput.click()} + onkeydown={(e) => e.key === 'Enter' && fileInput.click()} +> + + Drop image here or click to upload +
+``` + +### Anti-Patterns to Avoid +- **Storing images in SQLite:** Bloats database, slows queries, complicates backups +- **Storing in static/ folder:** Files overwritten on deploy, not recommended for user uploads +- **Blocking UI during upload:** Always show optimistic preview, upload in background +- **Missing EXIF rotation:** Images from phones will appear rotated; always use sharp.rotate() +- **Using jimp for thumbnails:** 20x slower than Sharp, poor choice for any volume + +## Don't Hand-Roll + +Problems that look simple but have existing solutions: + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Thumbnail generation | Canvas resize on client | Sharp on server | Server handles EXIF rotation, consistent quality, proper color management | +| Image EXIF orientation | Manual rotation logic | sharp.rotate() | Complex orientation matrix, mirroring cases, Sharp handles all 8 orientations | +| Lightbox gallery | Custom modal + gestures | svelte-lightbox | Keyboard navigation, pinch-zoom, swipe, accessibility baked in | +| Camera permissions | Custom prompts | getUserMedia native prompts | Browser handles permission UI, remembers choice, shows camera indicator | +| File type validation | Regex on extension | File API + accept attribute | MIME type checking, browser validates before upload | + +**Key insight:** Image handling has many edge cases (EXIF, color profiles, format variations). Sharp and browser APIs handle these; custom solutions will miss them. + +## Common Pitfalls + +### Pitfall 1: Missing EXIF Auto-Rotation +**What goes wrong:** Photos from mobile devices appear rotated 90 or 180 degrees +**Why it happens:** Mobile cameras embed rotation in EXIF metadata rather than rotating pixels +**How to avoid:** Always call `sharp.rotate()` without arguments before any resize operation +**Warning signs:** Portrait photos appearing landscape, images "sideways" + +### Pitfall 2: Blocking UI During Upload +**What goes wrong:** User clicks upload, UI freezes until complete +**Why it happens:** Not showing optimistic preview, waiting for server response +**How to avoid:** Use URL.createObjectURL() for instant preview, upload via background fetch +**Warning signs:** "Nothing happened" user feedback, perceived slowness + +### Pitfall 3: Camera Not Available Handling +**What goes wrong:** App crashes or shows blank when camera unavailable +**Why it happens:** Not catching getUserMedia errors, not checking navigator.mediaDevices +**How to avoid:** Feature detection first, catch NotAllowedError and NotFoundError, show graceful fallback +**Warning signs:** White screen on desktop without webcam, permission denied with no feedback + +### Pitfall 4: Memory Leaks from Object URLs +**What goes wrong:** Memory usage grows over time as user uploads images +**Why it happens:** Not calling URL.revokeObjectURL() after preview no longer needed +**How to avoid:** Revoke URLs when component unmounts or when upload completes +**Warning signs:** Browser tab memory increasing, eventual slowdown + +### Pitfall 5: Form Without enctype +**What goes wrong:** File upload appears to work but server receives empty file +**Why it happens:** Missing `enctype="multipart/form-data"` on form element +**How to avoid:** Always set enctype for file upload forms, or use FormData with fetch directly +**Warning signs:** formData.get('file') returns null or 0-byte file + +### Pitfall 6: Camera Stream Not Stopped +**What goes wrong:** Camera indicator stays on, battery drain, privacy concern +**Why it happens:** Not calling track.stop() when closing camera modal +**How to avoid:** Always stop all tracks when camera not in use: `stream.getTracks().forEach(t => t.stop())` +**Warning signs:** Browser camera indicator remains active after closing + +## Code Examples + +Verified patterns from official sources: + +### Sharp Thumbnail Generation +```typescript +// Source: https://sharp.pixelplumbing.com/api-resize +import sharp from 'sharp'; + +async function generateThumbnail( + inputBuffer: Buffer, + outputPath: string, + size = 150 +): Promise { + await sharp(inputBuffer) + .rotate() // Auto-orient based on EXIF Orientation tag + .resize(size, size, { + fit: 'cover', // Crop to fill dimensions + position: 'center', // Center the crop + withoutEnlargement: true // Don't upscale small images + }) + .jpeg({ quality: 80 }) + .toFile(outputPath); +} +``` + +### getUserMedia Camera Access +```typescript +// Source: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia +async function startCamera(preferBack = true): Promise { + // Feature detection + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('Camera not supported in this browser'); + } + + try { + return await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: preferBack ? 'environment' : 'user', + width: { ideal: 1920 }, + height: { ideal: 1080 } + }, + audio: false + }); + } catch (err) { + if (err instanceof DOMException) { + if (err.name === 'NotAllowedError') { + throw new Error('Camera permission denied'); + } + if (err.name === 'NotFoundError') { + throw new Error('No camera found'); + } + } + throw err; + } +} +``` + +### Canvas to Blob for Photo Capture +```typescript +// Source: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob +function canvasToBlob( + canvas: HTMLCanvasElement, + type = 'image/jpeg', + quality = 0.9 +): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) resolve(blob); + else reject(new Error('Failed to create blob')); + }, + type, + quality + ); + }); +} +``` + +### SvelteKit Form Action File Upload +```typescript +// Source: https://travishorn.com/uploading-and-saving-files-with-sveltekit +import { writeFile, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; + +export const actions = { + upload: async ({ request }) => { + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file || file.size === 0) { + return fail(400, { error: 'No file provided' }); + } + + // Ensure upload directory exists + const uploadDir = 'data/uploads/originals'; + if (!existsSync(uploadDir)) { + await mkdir(uploadDir, { recursive: true }); + } + + // Convert to buffer and write + const buffer = Buffer.from(await file.arrayBuffer()); + const filename = `${crypto.randomUUID()}.${file.name.split('.').pop()}`; + await writeFile(`${uploadDir}/${filename}`, buffer); + + return { success: true, filename }; + } +}; +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| navigator.getUserMedia | navigator.mediaDevices.getUserMedia | 2017+ | Old API deprecated, use MediaDevices interface | +| readFileSync for serving | SvelteKit read() helper | SvelteKit 2.4 | Better streaming, works in serverless | +| Base64 data URLs | Blob + Object URLs | Always preferred | 33% smaller, faster parsing | +| jimp for Node.js images | Sharp | Ongoing | Sharp 20x faster, better format support | + +**Deprecated/outdated:** +- `navigator.getUserMedia()`: Deprecated, use `navigator.mediaDevices.getUserMedia()` +- Canvas `toDataURL()` for uploads: Use `toBlob()` for better performance +- Storing files in static/: Build process overwrites; use data/ directory + +## Open Questions + +Things that couldn't be fully resolved: + +1. **Thumbnail dimensions** + - What we know: 150x150 is common for list thumbnails + - What's unclear: Exact dimensions depend on UI design + - Recommendation: Start with 150x150 cover crop, adjust based on visual testing + +2. **Camera button visibility on desktop** + - What we know: Can detect camera with enumerateDevices() + - What's unclear: Whether to show camera button proactively or only after first use + - Recommendation: Show camera button, handle no-camera gracefully with error message + +3. **Upload progress indication** + - What we know: fetch() doesn't support upload progress natively + - What's unclear: Whether optimistic preview is sufficient UX + - Recommendation: Show optimistic preview immediately; if progress needed, use XMLHttpRequest + +## Sources + +### Primary (HIGH confidence) +- [Sharp resize API](https://sharp.pixelplumbing.com/api-resize) - Thumbnail generation, fit modes +- [Sharp operations API](https://sharp.pixelplumbing.com/api-operation/) - EXIF auto-rotation +- [MDN getUserMedia](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) - Camera access +- [MDN Taking still photos](https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API/Taking_still_photos) - Canvas capture +- [MDN toBlob](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob) - Canvas to blob conversion +- [MDN File drag and drop](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/File_drag_and_drop) - Drag-drop API + +### Secondary (MEDIUM confidence) +- [SvelteKit file upload tutorial](https://travishorn.com/uploading-and-saving-files-with-sveltekit) - Form actions pattern +- [svelte-lightbox docs](https://svelte-lightbox.js.org/docs/introduction) - Lightbox component usage +- [Sharp vs Jimp benchmark](https://www.peterbe.com/plog/sharp-vs-jimp) - Performance comparison + +### Tertiary (LOW confidence) +- [SvelteKit serving uploads discussion](https://github.com/sveltejs/kit/discussions/10162) - Custom server pattern for uploads outside static/ + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - Sharp and browser APIs are well-documented, widely used +- Architecture: HIGH - SvelteKit patterns verified from official sources +- Pitfalls: HIGH - Based on MDN documentation and established best practices +- Lightbox choice: MEDIUM - svelte-lightbox is well-maintained but alternatives exist + +**Research date:** 2026-01-29 +**Valid until:** 2026-03-01 (Sharp and browser APIs are stable)