diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 20e125d..d9b7dc1 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -11,6 +11,31 @@ type OnChange = () => void; const MAX_FILE_SIZE_MB = 200; const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024; +/** Explicit MIME allowlist — matches backend ALLOWED_MIME_TYPES exactly. */ +const ALLOWED_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'video/mp4', + 'video/webm', + 'video/quicktime', +]); + +/** Human-readable extension from MIME type, for error messages. */ +function extFromMime(mime: string): string { + const map: Record = { + 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', + 'image/webp': '.webp', 'image/svg+xml': '.svg', + 'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov', + 'image/bmp': '.bmp', 'image/tiff': '.tiff', 'image/avif': '.avif', + 'image/heic': '.heic', 'image/heif': '.heif', + 'application/pdf': '.pdf', 'image/x-adobe-dng': '.dng', + }; + return map[mime] || mime.split('/')[1] || mime; +} + /* ------------------------------------------------------------------ */ /* Placeholder helper */ /* ------------------------------------------------------------------ */ @@ -147,7 +172,7 @@ export function setupDragDrop( // Count valid media files to determine grid columns let mediaCount = 0; for (let i = 0; i < files.length; i++) { - if (files[i].type.startsWith('image/') || files[i].type.startsWith('video/')) mediaCount++; + if (ALLOWED_MIME_TYPES.has(files[i].type)) mediaCount++; } const cols = Math.ceil(Math.sqrt(mediaCount)); // square-ish grid let col = 0; @@ -159,7 +184,17 @@ export function setupDragDrop( for (let i = 0; i < files.length; i++) { const file = files[i]; - if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) continue; + + // Reject unsupported file types with immediate feedback + if (!ALLOWED_MIME_TYPES.has(file.type)) { + if (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type) { + uploads?.addRejected( + file.name || 'unknown', + `Unsupported format: ${extFromMime(file.type)}`, + ); + } + continue; + } // Compute x from column widths so far let cursorX = world.x; @@ -256,9 +291,21 @@ export function setupPaste( for (let i = 0; i < items.length; i++) { const item = items[i]; + + // Skip non-media clipboard items (text, html, etc.) if (!item.type.startsWith('image/') && !item.type.startsWith('video/')) continue; e.preventDefault(); + + // Reject unsupported media types with feedback + if (!ALLOWED_MIME_TYPES.has(item.type)) { + uploads?.addRejected( + item.type.split('/')[1] || 'unknown', + `Unsupported format: ${extFromMime(item.type)}`, + ); + continue; + } + const file = item.getAsFile(); if (!file) continue; diff --git a/frontend/src/stores/uploadManager.ts b/frontend/src/stores/uploadManager.ts index 28f9f1f..5b65dc0 100644 --- a/frontend/src/stores/uploadManager.ts +++ b/frontend/src/stores/uploadManager.ts @@ -67,6 +67,23 @@ export class UploadManager { return id; } + /** Create an immediately-failed job (for unsupported file types). */ + addRejected(fileName: string, error: string): string { + const id = crypto.randomUUID(); + this.jobs.set(id, { + id, + fileName, + fileSize: 0, + mediaType: 'image', + status: 'failed', + progress: 0, + error, + createdAt: Date.now(), + }); + this._notify(); + return id; + } + /** Update upload progress (0-1). */ setProgress(jobId: string, progress: number) { const job = this.jobs.get(jobId);