mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -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<FileEntry>) => 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 `<img>` or `<a href>`.
|
||||
- `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<string, unknown> | null;
|
||||
exifError?: string;
|
||||
gps?: Record<string, unknown> | null;
|
||||
icc?: Record<string, string> | null;
|
||||
xmp?: Record<string, string> | 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<string, MetadataResult>` 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).
|
||||
Reference in New Issue
Block a user