From c31144b628377d7d06717fe8219fefac31fed3a4 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:44:34 +0800 Subject: [PATCH] docs: add multi-image UX redesign spec Design spec for overhauling multi-image upload/preview/processing UX: - Bottom filmstrip thumbnail strip with left/right navigation arrows - Hybrid batch processing (apply-to-all with per-file preview) - Individual + ZIP download options - Multi-file aware strip-metadata with existing /inspect endpoint - File store evolution to per-file tracking with FileEntry model --- .../specs/2026-03-23-multi-image-ux-design.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-23-multi-image-ux-design.md diff --git a/docs/superpowers/specs/2026-03-23-multi-image-ux-design.md b/docs/superpowers/specs/2026-03-23-multi-image-ux-design.md new file mode 100644 index 00000000..3f4b210f --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-multi-image-ux-design.md @@ -0,0 +1,233 @@ +# Multi-Image UX Redesign + +## Problem + +When uploading multiple images, the tool page only previews the first image. There's no way to navigate between uploaded files, processing only handles one file, and downloads are single-file only. The strip-metadata tool doesn't show what metadata exists before removing it. + +## Design Decisions + +- **Processing model**: Hybrid — "Apply to all" batch processing with navigation to preview individual files before/after processing. +- **Thumbnail layout**: Bottom filmstrip strip below the main preview area. Horizontal scroll when many images. Left/right arrows on the main image. +- **Download model**: Individual per-file downloads + "Download All as ZIP" option. +- **Metadata display**: Fully parsed EXIF/GPS/ICC/XMP shown in a categorized table before stripping. GPS gets a red privacy warning badge. + +## Architecture + +### 1. File Store (`file-store.ts`) + +Evolve from single-file state to multi-file aware state. + +**Current state**: `files[]`, `originalBlobUrl` (first file only), `processedUrl` (single), `selectedFileName`, `selectedFileSize`. + +**New state**: + +```typescript +interface FileEntry { + file: File; + blobUrl: string; + /** Server download URL (single-file) or client blob URL (batch/ZIP extraction). */ + processedUrl: string | null; + processedSize: number | null; + originalSize: number; + status: 'pending' | 'processing' | 'completed' | 'failed'; + error: string | null; +} + +interface FileState { + entries: FileEntry[]; + selectedIndex: number; + /** Cached ZIP blob from batch processing, for "Download All" button. */ + batchZipBlob: Blob | null; + batchZipFilename: string | null; + // Derived getters + currentEntry: FileEntry | null; + hasFiles: boolean; + allProcessed: boolean; + // Actions + setFiles: (files: File[]) => void; + addFiles: (files: File[]) => void; + removeFile: (index: number) => void; + setSelectedIndex: (index: number) => void; + navigateNext: () => void; + navigatePrev: () => void; + updateEntry: (index: number, updates: Partial) => void; + setBatchZip: (blob: Blob, filename: string) => void; + /** Reset all entries to pending state, clear processed results. Replaces the old `undoProcessing()`. */ + undoProcessing: () => void; + reset: () => void; +} +``` + +Key changes: +- Blob URLs generated for ALL files, not just first. +- `selectedIndex` replaces `selectedFileName` for navigation. +- Per-file processing state (`status`, `processedUrl`, `processedSize`). +- `processedUrl` can be either a server download path (single-file processing) or a client-side blob URL (from batch ZIP extraction). Both work as `src` for `` or ``. +- `addFiles()` for the "+ Add more" button (appends to existing). +- `removeFile()` for removing individual files. +- `batchZipBlob` / `batchZipFilename` — cached ZIP blob from batch processing. The "Download All (ZIP)" button uses this directly instead of re-requesting. +- `undoProcessing()` — replaces the old single-file version. Resets all entries' `processedUrl`, `processedSize`, `status` back to `pending`, clears `error`, revokes any client-side blob URLs from processed results, and clears `batchZipBlob`. +- Blob URL cleanup on unmount/reset for all entries. +- Memory: the frontend enforces the same `MAX_BATCH_SIZE` limit as the backend. For large batches, thumbnails use the same blob URLs as the full preview (browser handles scaling via CSS `object-fit`). + +### 2. Multi-Image Viewer Component + +New `MultiImageViewer` component wraps the existing `ImageViewer`. Only renders the navigation chrome when `entries.length > 1`. + +**Structure**: +``` +┌──────────────────────────────────┐ +│ [zoom toolbar] │ +├──────────────────────────────────┤ +│ ‹ │ Main Image │ › │ ← arrows overlay, "2/5" badge +├──────────────────────────────────┤ +│ filename.jpg 4032x3024 2.4MB │ +├──────────────────────────────────┤ +│ [thumb] [thumb] [thumb] [thumb] │ ← filmstrip, horizontal scroll +└──────────────────────────────────┘ +``` + +**Props**: Uses file store directly (no props drilling). Renders `ImageViewer` for the currently selected entry, or `BeforeAfterSlider` if the current entry has a `processedUrl`. + +**Navigation**: +- Left/right arrow buttons (circular, semi-transparent, positioned over image). +- Keyboard: left/right arrow keys, **only when the viewer container has focus** (not when a tool-specific input like crop handles or text fields is focused). Use `onKeyDown` on the viewer container div with `tabIndex={0}`, not a global listener. +- Click thumbnail to jump to that image. +- "N / M" counter badge in top-right of image area. + +**Filmstrip**: +- Horizontal row of thumbnails (52x38px) with 6px gap. +- Active thumbnail has `outline: 2px solid primary` with 1px offset. +- Processed thumbnails have a green checkmark badge (14px circle, top-right corner). +- Failed thumbnails have a red X badge. +- Horizontal scroll via CSS `overflow-x: auto` with `scroll-behavior: smooth`. +- Auto-scroll to keep selected thumbnail visible (use `scrollIntoView({ block: 'nearest', inline: 'nearest' })`). + +**Single-file fallback**: When only 1 file is uploaded, render the existing `ImageViewer` directly — no arrows, no filmstrip, no counter. Identical to current behavior. + +### 3. Tool Processor (`use-tool-processor.ts`) + +Add batch processing alongside the existing single-file processing. + +**New method: `processAllFiles(entries, settings)`**: + +Uses `fetch()` (not XHR) to POST to `/api/v1/tools/{toolId}/batch`: +- Build `FormData` with all files + settings JSON + `clientJobId`. +- Use `fetch()` so we can read response headers immediately — specifically the `X-Job-Id` header to correlate with SSE progress. + +**Batch progress via SSE**: +- The batch endpoint needs a small change: parse `clientJobId` from multipart and use it as the job ID (instead of generating a random one server-side). This lets the client open the SSE connection _before_ the upload completes, matching the existing AI tool pattern. +- SSE events update per-file `status` in the store via `updateEntry()`. The `currentFile` field in `JobProgress` maps to the entry by filename. +- Overall progress: "Processing 3 / 5 files..." shown in a `ProgressCard`. + +**ZIP extraction after batch completes**: +- The `fetch()` response body is consumed as a `Blob`. +- The blob is stored in `batchZipBlob` for the "Download All" button. +- Use `fflate` to decompress the ZIP in the browser. +- For each file in the ZIP, create a blob URL and call `updateEntry(index, { processedUrl, processedSize, status: 'completed' })`. +- Match ZIP entries to store entries **by index/order** (the batch endpoint processes files in submission order, and `archiver` appends results in completion order via p-queue — but since we use `concurrency: 1` equivalent ordering, or we can sort by original filename). To be safe, the batch endpoint will include an `X-File-Order` response header listing original filenames in order, so the client can map ZIP entries back to store entries even if `getUniqueName()` renamed duplicates. +- **Important**: per-file `status` updates to `'completed'` happen via SSE during processing (for progress UI), but `processedUrl` is only populated after the full ZIP is downloaded and extracted. The UI shows a checkmark on the thumbnail as soon as SSE reports completion, but the before/after preview for that image becomes available only after ZIP extraction. + +**Single-file processing**: Keep existing `processFiles()` method unchanged for tools that only work with one file (compare, collage, etc.). + +### 4. Tool Page (`tool-page.tsx`) + +**Changes to left panel**: +- `FileSelectionInfo` replaced with richer file summary: + - Shows "Files (N)" with count. + - "+ Add more" link that opens file picker (calls `addFiles`). + - Currently selected filename + size. + - "Clear all" to reset. +- Process button text changes: "Process All (N files)" when N > 1, "Process" when N = 1. + +**Changes to main area**: +- Replace direct `ImageViewer` usage with `MultiImageViewer`. +- `MultiImageViewer` handles all states: single image, multiple images, pre-process, post-process. +- When processed and multiple files: arrows navigate between before/after results per image. + +**Download section in left panel (post-processing)**: +- "Download This" — downloads current file's processed result (uses `processedUrl` from the entry). +- "Download All (ZIP)" — creates a download link from `batchZipBlob` stored in the file store. No re-request needed. +- Per-file stats: "2.4 MB → 2.1 MB (−300 KB)". +- Overall stats: "Processed: 5/5, Total saved: 1.2 MB". + +### 5. Strip Metadata Enhancement + +#### Backend: Existing `/inspect` endpoint + +The strip-metadata tool already has a `POST /api/v1/tools/strip-metadata/inspect` endpoint that returns parsed EXIF, GPS, ICC, and XMP metadata. The frontend already calls this endpoint and displays the results in collapsible sections with a GPS privacy warning. **No new backend endpoint is needed.** + +The existing response shape: +```typescript +interface MetadataResult { + filename: string; + fileSize: number; + exif?: Record | null; + exifError?: string; + gps?: Record | null; + icc?: Record | null; + xmp?: Record | null; +} +``` + +This already works. The only change needed is making the metadata display **multi-file aware**. + +#### Frontend: `StripMetadataSettings` changes for multi-file + +The existing metadata auto-fetch logic fetches metadata for `files[0]`. Change it to: +- Fetch metadata for `entries[selectedIndex].file` instead of `files[0]`. +- Cache metadata per-file to avoid re-fetching when navigating between images (use a `Map` keyed by file identity). +- When the user navigates to a different image via the filmstrip, the metadata panel updates to show that image's metadata. +- The strip options and "Process All" button apply to all files uniformly. + +### 6. Batch Endpoint Change (`batch.ts`) + +One small backend change: accept `clientJobId` from the multipart form and use it as the job ID. + +```typescript +// In the multipart parsing loop, add: +} else if (part.fieldname === "clientJobId") { + clientJobId = part.value as string; +} + +// Then use it: +const jobId = clientJobId || randomUUID(); +``` + +This lets the client open the SSE connection before upload completes, enabling real-time progress tracking for batch operations. + +### 7. Dropzone Changes + +**"+ Add more" support**: New `addFiles()` action in store. The dropzone on the tool page is replaced by the image viewer after upload, but a small "+ Add more" link in the left panel opens a file picker dialog (reuses the same `input.click()` pattern from the existing dropzone). + +**No dropzone changes needed for the main drop area**: The existing dropzone handles multi-file upload correctly. After files are uploaded, it's replaced by `MultiImageViewer`. + +### 8. Docker Build + +Update the local Docker build to ensure the UI changes are testable: +- No new system dependencies needed (`fflate` is pure JS). +- Ensure `pnpm install` picks up new deps and frontend builds correctly. + +## New Dependencies + +- `fflate` — Lightweight ZIP decompression in the browser. Pure JS. Added to `apps/web`. + +## Files Changed + +### New files: +- `apps/web/src/components/common/multi-image-viewer.tsx` — Wrapper with filmstrip + arrows +- `apps/web/src/components/common/thumbnail-strip.tsx` — Horizontal thumbnail filmstrip + +### Modified files: +- `apps/web/src/stores/file-store.ts` — Multi-file state with per-file tracking +- `apps/web/src/hooks/use-tool-processor.ts` — Add `processAllFiles` batch method +- `apps/web/src/pages/tool-page.tsx` — Use `MultiImageViewer`, update left panel +- `apps/web/src/components/tools/strip-metadata-settings.tsx` — Multi-file metadata display +- `apps/api/src/routes/batch.ts` — Accept `clientJobId` from multipart + +## Out of Scope + +- Per-file different settings (all files get same settings in batch mode). +- Drag-to-reorder files in the filmstrip. +- Pipeline/chaining multiple tools on batch results. +- Mobile-specific filmstrip optimizations (will use same horizontal scroll, works fine on touch).