From 88001a8410a1f37274c0ce635753eacc015a9dca Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:35:19 +0800 Subject: [PATCH 01/21] docs: add resize & rotate/flip UX redesign spec --- .../2026-03-23-resize-rotate-redesign.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md diff --git a/docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md b/docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md new file mode 100644 index 00000000..963be238 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md @@ -0,0 +1,79 @@ +# Resize & Rotate/Flip UX Redesign + +## Problem + +The before/after comparison slider is a poor fit for resize and rotate/flip tools: +- **Resize** overlays two different-sized images — doesn't communicate anything useful. Users care about "how big will it be?" not pixel-level comparison. +- **Rotate/Flip** transformations are self-evident. A slider adds nothing. + +The resize settings also use technical jargon (contain, cover, fill, inside, outside) that confuses layman users. + +## Design + +### Resize: Tab-Based Settings with Presets Front-and-Center + +Replace the current mode toggle (Pixels/Percentage) with three tabs: + +#### Presets Tab (default) +- Grid of cards grouped by platform (Instagram, Twitter/X, Facebook, YouTube, LinkedIn) +- Each card shows: platform icon, preset name (e.g., "Post", "Story", "Header"), dimensions (e.g., "1080 × 1080") +- Clicking a card selects it (highlighted border), clicking again deselects +- Selected preset populates the dimensions automatically +- Process button at bottom + +#### Custom Size Tab +- Width and Height number inputs +- Aspect ratio lock toggle between them (link/unlink icon) +- Fit mode as 3 plain-language options: + - "Crop to fit" (maps to sharp `cover`) + - "Fit inside" (maps to sharp `contain`) + - "Stretch" (maps to sharp `fill`) +- Remove "inside" and "outside" fit modes — they confuse laymen +- "Don't enlarge" checkbox +- Process button at bottom + +#### Scale Tab +- Percentage number input +- Quick-select buttons: 25% | 50% | 75% +- Process button at bottom + +### Resize: Side-by-Side Result Display + +Replace the before/after slider with side-by-side thumbnails: + +- Two image thumbnails side by side, each fitted within its half +- **Left**: "Original" label, the original image, dimensions below (e.g., "3000 × 2000"), file size (e.g., "2.4 MB") +- **Right**: "Resized" label, the processed image, new dimensions below (e.g., "1080 × 720"), file size (e.g., "340 KB") +- File size savings shown between/below (e.g., "86% smaller") +- Checkerboard background for transparency (same pattern as current viewer) +- Review panel (undo, download, continue editing) remains unchanged + +### Rotate/Flip: Live CSS Preview + +Replace the "process then compare" flow with live preview: + +- When a file is loaded, it shows in the image viewer as normal +- As the user adjusts controls (rotate buttons, angle slider, flip toggles), CSS transforms update the preview in real-time — no server call +- Controls stay the same: quick rotate 90 left/right, angle slider 0-360, horizontal/vertical flip toggles +- The "Process" button label changes to "Apply" to signal finality +- Clicking "Apply" sends to the server, produces the final file + +### Rotate/Flip: Result Display + +After applying: +- Result shows in the standard ImageViewer (no before/after slider, no side-by-side) +- The transformation is self-evident +- Review panel appears with undo/download options + +## Files to Modify + +### Frontend +- `apps/web/src/components/tools/resize-settings.tsx` — rewrite with tab-based UI +- `apps/web/src/components/tools/rotate-settings.tsx` — add live CSS preview controls +- `apps/web/src/pages/tool-page.tsx` — conditionally render side-by-side for resize, skip before/after slider for rotate/flip +- New component: `apps/web/src/components/common/side-by-side-comparison.tsx` — reusable side-by-side thumbnail comparison + +### No Backend Changes +- Resize and rotate API routes remain unchanged +- Image engine operations remain unchanged +- Only the frontend presentation and interaction model changes From 790a8709954ebb5d0fd54883861fd05fd88252ff Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:38:17 +0800 Subject: [PATCH 02/21] docs: address spec review feedback for resize/rotate redesign Adds: state architecture for rotate preview, dimension data source, fit mode defaults, mobile layout notes, extensibility guidance. --- .../2026-03-23-resize-rotate-redesign.md | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md b/docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md index 963be238..9c42689d 100644 --- a/docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md +++ b/docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md @@ -15,10 +15,12 @@ The resize settings also use technical jargon (contain, cover, fill, inside, out Replace the current mode toggle (Pixels/Percentage) with three tabs: #### Presets Tab (default) -- Grid of cards grouped by platform (Instagram, Twitter/X, Facebook, YouTube, LinkedIn) +- Single-column scrollable list of cards grouped by platform (Instagram, Twitter/X, Facebook, YouTube, LinkedIn) — the sidebar is 18rem wide, so single-column avoids cramped cards - Each card shows: platform icon, preset name (e.g., "Post", "Story", "Header"), dimensions (e.g., "1080 × 1080") - Clicking a card selects it (highlighted border), clicking again deselects - Selected preset populates the dimensions automatically +- Presets use "Crop to fit" (cover) as the default fit mode — this is the expected behavior for social media sizing +- "Don't enlarge" checkbox available below preset grid - Process button at bottom #### Custom Size Tab @@ -35,6 +37,7 @@ Replace the current mode toggle (Pixels/Percentage) with three tabs: #### Scale Tab - Percentage number input - Quick-select buttons: 25% | 50% | 75% +- `fit` and `withoutEnlargement` are intentionally omitted — percentage scaling doesn't need them. API defaults (`contain`, `false`) are sent. - Process button at bottom ### Resize: Side-by-Side Result Display @@ -46,16 +49,23 @@ Replace the before/after slider with side-by-side thumbnails: - **Right**: "Resized" label, the processed image, new dimensions below (e.g., "1080 × 720"), file size (e.g., "340 KB") - File size savings shown between/below (e.g., "86% smaller") - Checkerboard background for transparency (same pattern as current viewer) +- **Dimensions**: Read client-side from blob URLs using `Image.onload` to get `naturalWidth`/`naturalHeight`. No backend changes needed. +- **File sizes**: Use existing `originalSize`/`processedSize` from the API response (already available in the file store). +- **Mobile**: On small screens, thumbnails stack vertically instead of side-by-side. - Review panel (undo, download, continue editing) remains unchanged ### Rotate/Flip: Live CSS Preview Replace the "process then compare" flow with live preview: +**State architecture**: `rotate-settings.tsx` emits transform values (angle, flipH, flipV) via a callback prop from `tool-page.tsx`. `tool-page.tsx` holds the preview transform state and passes it down to `ImageViewer` as optional props (`cssRotate`, `cssFlipH`, `cssFlipV`). `ImageViewer` applies these as CSS `transform: rotate(Xdeg) scaleX(Y) scaleY(Z)`. + - When a file is loaded, it shows in the image viewer as normal - As the user adjusts controls (rotate buttons, angle slider, flip toggles), CSS transforms update the preview in real-time — no server call - Controls stay the same: quick rotate 90 left/right, angle slider 0-360, horizontal/vertical flip toggles +- **Non-90-degree angles**: CSS preview will clip corners (the image rotates within its container). This is acceptable as a preview — the final server output will have proper canvas extension. This is a known discrepancy. - The "Process" button label changes to "Apply" to signal finality +- "Apply" button remains disabled when no changes are made (angle=0, no flips) — same as current behavior - Clicking "Apply" sends to the server, produces the final file ### Rotate/Flip: Result Display @@ -65,13 +75,18 @@ After applying: - The transformation is self-evident - Review panel appears with undo/download options +### Other Tools + +The `BeforeAfterSlider` remains for all other tools (compress, filters, etc.). Only resize and rotate/flip get special treatment. In `tool-page.tsx`, branch on `toolId` using a set (e.g., `TOOLS_WITHOUT_SLIDER`) for extensibility. + ## Files to Modify ### Frontend - `apps/web/src/components/tools/resize-settings.tsx` — rewrite with tab-based UI -- `apps/web/src/components/tools/rotate-settings.tsx` — add live CSS preview controls -- `apps/web/src/pages/tool-page.tsx` — conditionally render side-by-side for resize, skip before/after slider for rotate/flip -- New component: `apps/web/src/components/common/side-by-side-comparison.tsx` — reusable side-by-side thumbnail comparison +- `apps/web/src/components/tools/rotate-settings.tsx` — emit transform values via callback, change button label to "Apply" +- `apps/web/src/pages/tool-page.tsx` — hold preview transform state, conditionally render side-by-side for resize, ImageViewer for rotate/flip, BeforeAfterSlider for everything else +- `apps/web/src/components/common/image-viewer.tsx` — accept optional CSS transform props for live rotate/flip preview +- `apps/web/src/components/common/side-by-side-comparison.tsx` — new component for side-by-side thumbnail comparison with dimensions and file size ### No Backend Changes - Resize and rotate API routes remain unchanged From c31144b628377d7d06717fe8219fefac31fed3a4 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:44:34 +0800 Subject: [PATCH 03/21] 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). From 97147ece9d4c8e54bb7d2f8928c25b31d4a15bab Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:44:47 +0800 Subject: [PATCH 04/21] docs: add resize & rotate/flip UX redesign implementation plan --- .../2026-03-23-resize-rotate-redesign.md | 869 ++++++++++++++++++ 1 file changed, 869 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-23-resize-rotate-redesign.md diff --git a/docs/superpowers/plans/2026-03-23-resize-rotate-redesign.md b/docs/superpowers/plans/2026-03-23-resize-rotate-redesign.md new file mode 100644 index 00000000..ea10e5c3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-resize-rotate-redesign.md @@ -0,0 +1,869 @@ +# Resize & Rotate/Flip UX Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the before/after slider with intuitive side-by-side comparison for resize and live CSS preview for rotate/flip. + +**Architecture:** Frontend-only changes. Resize gets a tab-based settings panel (Presets/Custom Size/Scale) and a new SideBySideComparison result view. Rotate/flip gets live CSS transform preview on the ImageViewer with an "Apply" button. The tool-page.tsx conditionally renders the appropriate result view per tool. No backend changes. + +**Tech Stack:** React, TypeScript, Tailwind CSS, Zustand, lucide-react + +**Spec:** `docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md` + +--- + +### Task 1: Create SideBySideComparison Component + +**Files:** +- Create: `apps/web/src/components/common/side-by-side-comparison.tsx` + +- [ ] **Step 1: Create the component file** + +```tsx +import { useState } from "react"; + +interface SideBySideComparisonProps { + beforeSrc: string; + afterSrc: string; + beforeSize?: number; + afterSize?: number; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export function SideBySideComparison({ + beforeSrc, + afterSrc, + beforeSize, + afterSize, +}: SideBySideComparisonProps) { + const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null); + const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null); + + const savingsPercent = + beforeSize && afterSize && beforeSize > 0 + ? ((1 - afterSize / beforeSize) * 100).toFixed(1) + : null; + + const checkerboard = { + backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%), + linear-gradient(-45deg, #ccc 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ccc 75%), + linear-gradient(-45deg, transparent 75%, #ccc 75%)`, + backgroundSize: "16px 16px", + backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px", + }; + + return ( +
+ {/* Side-by-side images */} +
+ {/* Original */} +
+ + Original + +
+ Original { + const img = e.currentTarget; + setBeforeDims({ w: img.naturalWidth, h: img.naturalHeight }); + }} + /> +
+
+ {beforeDims && ( +

+ {beforeDims.w} × {beforeDims.h} +

+ )} + {beforeSize != null &&

{formatSize(beforeSize)}

} +
+
+ + {/* Resized */} +
+ + Resized + +
+ Resized { + const img = e.currentTarget; + setAfterDims({ w: img.naturalWidth, h: img.naturalHeight }); + }} + /> +
+
+ {afterDims && ( +

+ {afterDims.w} × {afterDims.h} +

+ )} + {afterSize != null &&

{formatSize(afterSize)}

} +
+
+
+ + {/* Size savings */} + {savingsPercent !== null && ( +

0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`} + > + {Number(savingsPercent) > 0 + ? `${savingsPercent}% smaller` + : `${Math.abs(Number(savingsPercent))}% larger`} +

+ )} +
+ ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/common/side-by-side-comparison.tsx +git commit -m "feat: add SideBySideComparison component for resize results" +``` + +--- + +### Task 2: Rewrite ResizeSettings with Tab-Based UI + +**Files:** +- Modify: `apps/web/src/components/tools/resize-settings.tsx` + +- [ ] **Step 1: Rewrite resize-settings.tsx with three tabs** + +Replace the entire file content with: + +```tsx +import { useState } from "react"; +import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared"; +import { useFileStore } from "@/stores/file-store"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { Download, Link, Unlink } from "lucide-react"; +import { ProgressCard } from "@/components/common/progress-card"; + +type ResizeTab = "presets" | "custom" | "scale"; +type FitMode = "cover" | "contain" | "fill"; + +const FIT_LABELS: Record = { + cover: "Crop to fit", + contain: "Fit inside", + fill: "Stretch", +}; + +// Group presets by platform +const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))]; + +export function ResizeSettings() { + const { files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, progress } = + useToolProcessor("resize"); + + const [tab, setTab] = useState("presets"); + const [selectedPreset, setSelectedPreset] = useState(null); + const [width, setWidth] = useState(""); + const [height, setHeight] = useState(""); + const [percentage, setPercentage] = useState("50"); + const [fit, setFit] = useState("cover"); + const [lockAspect, setLockAspect] = useState(true); + const [withoutEnlargement, setWithoutEnlargement] = useState(false); + + const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => { + const key = `${preset.platform}-${preset.name}`; + if (selectedPreset === key) { + setSelectedPreset(null); + setWidth(""); + setHeight(""); + } else { + setSelectedPreset(key); + setWidth(String(preset.width)); + setHeight(String(preset.height)); + } + }; + + const handleProcess = () => { + const settings: Record = {}; + + if (tab === "scale") { + settings.percentage = Number(percentage); + } else { + if (width) settings.width = Number(width); + if (height) settings.height = Number(height); + settings.fit = tab === "presets" ? "cover" : fit; + settings.withoutEnlargement = withoutEnlargement; + } + + processFiles(files, settings); + }; + + const hasFile = files.length > 0; + const canProcess = + hasFile && + !processing && + (tab === "scale" + ? Number(percentage) > 0 + : Boolean(width) || Boolean(height)); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (canProcess) handleProcess(); + }; + + const tabClass = (t: ResizeTab) => + `flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`; + + return ( +
+ {/* Tab selector */} +
+
+ + + +
+
+ + {/* Presets tab */} + {tab === "presets" && ( +
+ {platforms.map((platform) => ( +
+

{platform}

+
+ {SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { + const key = `${preset.platform}-${preset.name}`; + const isSelected = selectedPreset === key; + return ( + + ); + })} +
+
+ ))} + + {/* Don't enlarge */} + +
+ )} + + {/* Custom Size tab */} + {tab === "custom" && ( +
+
+
+ + setWidth(e.target.value)} + placeholder="Auto" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setHeight(e.target.value)} + placeholder="Auto" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+
+ + {/* Fit mode */} +
+ +
+ {(Object.keys(FIT_LABELS) as FitMode[]).map((f) => ( + + ))} +
+
+ + {/* Don't enlarge */} + +
+ )} + + {/* Scale tab */} + {tab === "scale" && ( +
+
+ + setPercentage(e.target.value)} + min={1} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+
+ {[25, 50, 75].map((pct) => ( + + ))} +
+
+ )} + + {/* Error */} + {error &&

{error}

} + + {/* Process button */} + {processing ? ( + + ) : ( + + )} + + {/* Download */} + {downloadUrl && ( +
+ + Download + + )} +
+ ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/tools/resize-settings.tsx +git commit -m "feat: rewrite resize settings with tab-based UI (presets, custom, scale)" +``` + +--- + +### Task 3: Add CSS Transform Props to ImageViewer + +**Files:** +- Modify: `apps/web/src/components/common/image-viewer.tsx` + +- [ ] **Step 1: Add optional CSS transform props to the ImageViewer interface and apply them** + +Add `cssRotate`, `cssFlipH`, `cssFlipV` optional props to the `ImageViewerProps` interface. In the `imageStyle` computation, compose CSS transforms when these props are provided. + +Changes to make: + +1. Update the interface (line 5-8): + +```tsx +interface ImageViewerProps { + src: string; + filename: string; + fileSize: number; + cssRotate?: number; + cssFlipH?: boolean; + cssFlipV?: boolean; +} +``` + +2. Update the component destructuring (line 14): + +```tsx +export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) { +``` + +3. Update the `imageStyle` computation (lines 65-68) to compose CSS transforms: + +```tsx + const previewTransform = [ + cssRotate ? `rotate(${cssRotate}deg)` : "", + cssFlipH ? "scaleX(-1)" : "", + cssFlipV ? "scaleY(-1)" : "", + ] + .filter(Boolean) + .join(" "); + + const imageStyle = + fitMode === "fit" + ? { + maxWidth: "100%", + maxHeight: "100%", + objectFit: "contain" as const, + ...(previewTransform && { transform: previewTransform }), + } + : { + transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`, + transformOrigin: "center center", + }; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/common/image-viewer.tsx +git commit -m "feat: add CSS transform props to ImageViewer for live rotate/flip preview" +``` + +--- + +### Task 4: Update RotateSettings with Live Preview Callback + +**Files:** +- Modify: `apps/web/src/components/tools/rotate-settings.tsx` + +- [ ] **Step 1: Add onPreviewTransform callback prop and change button label** + +The component needs to: +1. Accept an optional `onPreviewTransform` callback +2. Call it on every state change (angle, flipH, flipV) via useEffect +3. Change the submit button label from "Rotate" to "Apply" + +Replace the entire file: + +```tsx +import { useState, useEffect } from "react"; +import { useFileStore } from "@/stores/file-store"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { + Download, + RotateCcw, + RotateCw, + FlipHorizontal, + FlipVertical, +} from "lucide-react"; +import { ProgressCard } from "@/components/common/progress-card"; + +export interface PreviewTransform { + rotate: number; + flipH: boolean; + flipV: boolean; +} + +interface RotateSettingsProps { + onPreviewTransform?: (transform: PreviewTransform) => void; +} + +export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { + const { files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, progress } = + useToolProcessor("rotate"); + + const [angle, setAngle] = useState(0); + const [flipH, setFlipH] = useState(false); + const [flipV, setFlipV] = useState(false); + + // Emit preview transform on every change + useEffect(() => { + onPreviewTransform?.({ rotate: angle, flipH, flipV }); + }, [angle, flipH, flipV, onPreviewTransform]); + + const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360); + const rotateRight = () => setAngle((a) => (a + 90) % 360); + + const handleProcess = () => { + processFiles(files, { + angle, + horizontal: flipH, + vertical: flipV, + }); + }; + + const hasFile = files.length > 0; + const hasChanges = angle !== 0 || flipH || flipV; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (hasFile && hasChanges && !processing) handleProcess(); + }; + + return ( +
+ {/* Quick rotate buttons */} +
+ +
+ + +
+
+ + {/* Angle slider */} +
+
+ + {angle} deg +
+ setAngle(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Flip buttons */} +
+ +
+ + +
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Process */} + {processing ? ( + + ) : ( + + )} + + {/* Download */} + {downloadUrl && ( + + + Download + + )} + + ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/tools/rotate-settings.tsx +git commit -m "feat: add live preview callback to RotateSettings, rename button to Apply" +``` + +--- + +### Task 5: Update tool-page.tsx for Conditional Rendering + +**Files:** +- Modify: `apps/web/src/pages/tool-page.tsx` + +- [ ] **Step 1: Add imports, preview transform state, and conditional result rendering** + +Changes to make in `tool-page.tsx`: + +1. Add imports at the top (after existing imports, around line 8): + +```tsx +import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; +import type { PreviewTransform } from "@/components/tools/rotate-settings"; +``` + +2. Add a set for tools that use alternate result views (after `NO_DROPZONE_TOOLS` on line 63): + +```tsx +const SIDE_BY_SIDE_TOOLS = new Set(["resize"]); +const LIVE_PREVIEW_TOOLS = new Set(["rotate"]); +``` + +3. Update `ToolSettingsPanel` to accept and pass through the preview transform callback (replace the function starting at line 65): + +```tsx +function ToolSettingsPanel({ + toolId, + onPreviewTransform, +}: { + toolId: string; + onPreviewTransform?: (t: PreviewTransform) => void; +}) { + // Phase 2: Core tools + if (toolId === "resize") return ; + if (toolId === "crop") return ; + if (toolId === "rotate") return ; +``` + +(Rest of ToolSettingsPanel stays identical) + +4. In the `ToolPage` component, add preview transform state (after `const [mobileSettingsOpen, setMobileSettingsOpen]` on line 177): + +```tsx +const [previewTransform, setPreviewTransform] = useState(null); +``` + +5. Update ToolSettingsPanel usage in both mobile and desktop layouts — pass the callback: + +```tsx + +``` + +6. Replace the result rendering logic in the main area for **both mobile and desktop layouts**. Replace the entire conditional block (from `{isNoDropzone ?` through the closing `}`). Apply this **identically** in both the mobile layout (around line 286) and the desktop layout (around line 372): + +```tsx +{isNoDropzone ? ( +
+

Configure settings and generate.

+
+) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( + +) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( + +) : hasProcessed && originalBlobUrl ? ( + +) : hasFile && originalBlobUrl ? ( + +) : ( + +)} +``` + +**Important:** The `BeforeAfterSlider` is kept for all tools except resize (SideBySideComparison) and rotate (ImageViewer). The `BeforeAfterSlider` import must NOT be removed. + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/pages/tool-page.tsx +git commit -m "feat: conditional result views — side-by-side for resize, live preview for rotate" +``` + +--- + +### Task 6: Docker Rebuild and Test + +**Files:** +- No file changes — build and run existing Docker setup + +- [ ] **Step 1: Build Docker image** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image +docker compose -f docker/docker-compose.yml build +``` + +- [ ] **Step 2: Start the container** + +```bash +docker compose -f docker/docker-compose.yml up -d +``` + +- [ ] **Step 3: Verify the app is running** + +```bash +curl -f http://localhost:1349/api/v1/health +``` + +Expected: Health check passes + +- [ ] **Step 4: Report to user for UI testing** + +App is running at `http://localhost:1349`. User can test: +- Resize tool: tab-based settings (Presets, Custom Size, Scale), side-by-side result view +- Rotate tool: live CSS preview as controls change, "Apply" button, processed result in standard viewer From 271cab647bb41872c5e205d6236e35eb7fad0810 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:47:06 +0800 Subject: [PATCH 05/21] feat: add SideBySideComparison component for resize results --- .../common/side-by-side-comparison.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 apps/web/src/components/common/side-by-side-comparison.tsx diff --git a/apps/web/src/components/common/side-by-side-comparison.tsx b/apps/web/src/components/common/side-by-side-comparison.tsx new file mode 100644 index 00000000..f484e960 --- /dev/null +++ b/apps/web/src/components/common/side-by-side-comparison.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; + +interface SideBySideComparisonProps { + beforeSrc: string; + afterSrc: string; + beforeSize?: number; + afterSize?: number; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export function SideBySideComparison({ + beforeSrc, + afterSrc, + beforeSize, + afterSize, +}: SideBySideComparisonProps) { + const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null); + const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null); + + const savingsPercent = + beforeSize && afterSize && beforeSize > 0 + ? ((1 - afterSize / beforeSize) * 100).toFixed(1) + : null; + + const checkerboard = { + backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%), + linear-gradient(-45deg, #ccc 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #ccc 75%), + linear-gradient(-45deg, transparent 75%, #ccc 75%)`, + backgroundSize: "16px 16px", + backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px", + }; + + return ( +
+ {/* Side-by-side images */} +
+ {/* Original */} +
+ + Original + +
+ Original { + const img = e.currentTarget; + setBeforeDims({ w: img.naturalWidth, h: img.naturalHeight }); + }} + /> +
+
+ {beforeDims && ( +

+ {beforeDims.w} × {beforeDims.h} +

+ )} + {beforeSize != null &&

{formatSize(beforeSize)}

} +
+
+ + {/* Resized */} +
+ + Resized + +
+ Resized { + const img = e.currentTarget; + setAfterDims({ w: img.naturalWidth, h: img.naturalHeight }); + }} + /> +
+
+ {afterDims && ( +

+ {afterDims.w} × {afterDims.h} +

+ )} + {afterSize != null &&

{formatSize(afterSize)}

} +
+
+
+ + {/* Size savings */} + {savingsPercent !== null && ( +

0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`} + > + {Number(savingsPercent) > 0 + ? `${savingsPercent}% smaller` + : `${Math.abs(Number(savingsPercent))}% larger`} +

+ )} +
+ ); +} From bc105fd261de3c11b279557dba98aaad65214f42 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:49:20 +0800 Subject: [PATCH 06/21] feat: rewrite resize settings with tab-based UI (presets, custom, scale) --- .../src/components/tools/resize-settings.tsx | 315 ++++++++++-------- 1 file changed, 182 insertions(+), 133 deletions(-) diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx index e7597099..a70aa4ae 100644 --- a/apps/web/src/components/tools/resize-settings.tsx +++ b/apps/web/src/components/tools/resize-settings.tsx @@ -5,187 +5,236 @@ import { useToolProcessor } from "@/hooks/use-tool-processor"; import { Download, Link, Unlink } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card"; -type FitMode = "contain" | "cover" | "fill" | "inside" | "outside"; +type ResizeTab = "presets" | "custom" | "scale"; +type FitMode = "cover" | "contain" | "fill"; + +const FIT_LABELS: Record = { + cover: "Crop to fit", + contain: "Fit inside", + fill: "Stretch", +}; + +// Group presets by platform +const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))]; export function ResizeSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processing, error, downloadUrl, progress } = useToolProcessor("resize"); - const [mode, setMode] = useState<"pixels" | "percentage">("pixels"); + const [tab, setTab] = useState("presets"); + const [selectedPreset, setSelectedPreset] = useState(null); const [width, setWidth] = useState(""); const [height, setHeight] = useState(""); - const [percentage, setPercentage] = useState("100"); - const [fit, setFit] = useState("contain"); + const [percentage, setPercentage] = useState("50"); + const [fit, setFit] = useState("cover"); const [lockAspect, setLockAspect] = useState(true); const [withoutEnlargement, setWithoutEnlargement] = useState(false); - const handlePreset = (w: number, h: number) => { - setMode("pixels"); - setWidth(String(w)); - setHeight(String(h)); + const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => { + const key = `${preset.platform}-${preset.name}`; + if (selectedPreset === key) { + setSelectedPreset(null); + setWidth(""); + setHeight(""); + } else { + setSelectedPreset(key); + setWidth(String(preset.width)); + setHeight(String(preset.height)); + } }; const handleProcess = () => { - const settings: Record = { fit, withoutEnlargement }; - if (mode === "percentage") { + const settings: Record = {}; + + if (tab === "scale") { settings.percentage = Number(percentage); } else { if (width) settings.width = Number(width); if (height) settings.height = Number(height); + settings.fit = tab === "presets" ? "cover" : fit; + settings.withoutEnlargement = withoutEnlargement; } + processFiles(files, settings); }; const hasFile = files.length > 0; - - // Group presets by platform - const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))]; + const canProcess = + hasFile && + !processing && + (tab === "scale" + ? Number(percentage) > 0 + : Boolean(width) || Boolean(height)); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (hasFile && !processing) handleProcess(); + if (canProcess) handleProcess(); }; + const tabClass = (t: ResizeTab) => + `flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`; + return (
- {/* Mode toggle */} + {/* Tab selector */}
- -
- - +
- {mode === "pixels" ? ( - <> - {/* Width / Height */} -
-
-
- - setWidth(e.target.value)} - placeholder="Auto" - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" - /> -
- -
- - setHeight(e.target.value)} - placeholder="Auto" - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" - /> + {/* Presets tab */} + {tab === "presets" && ( +
+ {platforms.map((platform) => ( +
+

{platform}

+
+ {SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { + const key = `${preset.platform}-${preset.name}`; + const isSelected = selectedPreset === key; + return ( + + ); + })}
+ ))} + + {/* Don't enlarge */} + +
+ )} + + {/* Custom Size tab */} + {tab === "custom" && ( +
+
+
+ + setWidth(e.target.value)} + placeholder="Auto" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + setHeight(e.target.value)} + placeholder="Auto" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
{/* Fit mode */}
- +
+ {(Object.keys(FIT_LABELS) as FitMode[]).map((f) => ( + + ))} +
- {/* Social media presets */} -
- - -
- - ) : ( -
- - setPercentage(e.target.value)} - min={1} - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" - /> + {/* Don't enlarge */} +
)} - {/* Don't enlarge */} - + {/* Scale tab */} + {tab === "scale" && ( +
+
+ + setPercentage(e.target.value)} + min={1} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+
+ {[25, 50, 75].map((pct) => ( + + ))} +
+
+ )} {/* Error */} - {error && ( -

{error}

- )} - - {/* Size info */} - {originalSize != null && processedSize != null && ( -
-

Original: {(originalSize / 1024).toFixed(1)} KB

-

Processed: {(processedSize / 1024).toFixed(1)} KB

-
- )} + {error &&

{error}

} {/* Process button */} {processing ? ( @@ -200,7 +249,7 @@ export function ResizeSettings() { ) : ( + ); + })} +
+ ); +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && npx tsc --noEmit --skipLibCheck 2>&1 | head -5 +``` + +Note: There will be errors from other files referencing the old store — that's expected. The new file itself should not have errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/common/thumbnail-strip.tsx +git commit -m "feat: add ThumbnailStrip filmstrip component" +``` + +--- + +## Task 4: Build MultiImageViewer component + +**Files:** +- Create: `apps/web/src/components/common/multi-image-viewer.tsx` + +- [ ] **Step 1: Create MultiImageViewer component** + +```typescript +// apps/web/src/components/common/multi-image-viewer.tsx +import { useCallback, useRef } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ImageViewer } from "@/components/common/image-viewer"; +import { BeforeAfterSlider } from "@/components/common/before-after-slider"; +import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; +import { useFileStore } from "@/stores/file-store"; + +export function MultiImageViewer() { + const { + entries, + selectedIndex, + setSelectedIndex, + navigateNext, + navigatePrev, + } = useFileStore(); + const containerRef = useRef(null); + + const currentEntry = entries[selectedIndex]; + if (!currentEntry) return null; + + const hasMultiple = entries.length > 1; + const hasPrev = selectedIndex > 0; + const hasNext = selectedIndex < entries.length - 1; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowLeft") { + e.preventDefault(); + navigatePrev(); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + navigateNext(); + } + }, + [navigateNext, navigatePrev], + ); + + const hasProcessed = !!currentEntry.processedUrl; + + return ( +
+ {/* Main viewer with navigation arrows */} +
+ {/* Left arrow */} + {hasMultiple && hasPrev && ( + + )} + + {/* Image or Before/After */} +
+ {hasProcessed ? ( + + ) : ( + + )} +
+ + {/* Right arrow */} + {hasMultiple && hasNext && ( + + )} + + {/* Counter badge */} + {hasMultiple && ( +
+ {selectedIndex + 1} / {entries.length} +
+ )} +
+ + {/* Thumbnail filmstrip */} + +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/web/src/components/common/multi-image-viewer.tsx +git commit -m "feat: add MultiImageViewer with arrow navigation and filmstrip" +``` + +--- + +## Task 5: Update tool-page.tsx to use new store and MultiImageViewer + +**Files:** +- Modify: `apps/web/src/pages/tool-page.tsx` + +This is the integration point — the tool page needs to use the new store interface, render `MultiImageViewer`, and show updated file info + download buttons. + +- [ ] **Step 1: Update tool-page.tsx** + +Key changes: +1. Import `MultiImageViewer` instead of directly using `ImageViewer`/`BeforeAfterSlider`. +2. Update `FileSelectionInfo` to show file count, "+ Add more", and current file info from entries. +3. Use `entries` and `currentEntry` from store instead of `files[0]`. +4. Change process button to "Process All (N files)" when N > 1. +5. Add "Download This" + "Download All (ZIP)" buttons post-processing. +6. Add an "+ Add more" handler that opens a file picker. + +The full implementation: + +In `FileSelectionInfo`, replace the component to show: +- "Files (N)" header with count +- "+ Add more" link +- Current file name and size (from `currentEntry`) +- "Clear all" button + +In the main area, replace the `ImageViewer`/`BeforeAfterSlider` conditionals with a single `` when `hasFile`. + +In the review panel area, add batch download buttons when `entries.length > 1`: +- "Download This" — uses `currentEntry.processedUrl` +- "Download All (ZIP)" — uses `batchZipBlob` from store + +Update the import list: add `MultiImageViewer`, remove direct `ImageViewer` and `BeforeAfterSlider` imports (they're now used internally by `MultiImageViewer`). + +The `handleFiles` callback stays the same (calls `reset()` then `setFiles()`). + +Add a new `handleAddMore` callback: +```typescript +const handleAddMore = useCallback(() => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.accept = "image/*"; + input.onchange = (e) => { + const newFiles = Array.from((e.target as HTMLInputElement).files || []); + if (newFiles.length > 0) addFiles(newFiles); + }; + input.click(); +}, [addFiles]); +``` + +For "Download All (ZIP)": when `batchZipBlob` exists, create a download link: +```typescript +const handleDownloadAll = useCallback(() => { + if (!batchZipBlob) return; + const url = URL.createObjectURL(batchZipBlob); + const a = document.createElement("a"); + a.href = url; + a.download = batchZipFilename ?? "processed-images.zip"; + a.click(); + URL.revokeObjectURL(url); +}, [batchZipBlob, batchZipFilename]); +``` + +- [ ] **Step 2: Fix type errors and verify build** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck +``` + +Fix any remaining type errors. The main ones will be: +- `originalBlobUrl` — now a getter on the store, works the same way. +- `processedUrl` — now a getter returning current entry's processedUrl. +- `originalSize` / `processedSize` — now getters. +- `jobId` — `setJobId` is still available but is a no-op for batch. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/pages/tool-page.tsx +git commit -m "feat: integrate MultiImageViewer and multi-file UX into tool page" +``` + +--- + +## Task 6: Update all tool settings components for new store interface + +**Files:** +- Modify: All `apps/web/src/components/tools/*-settings.tsx` files + +The store's getter-based backward compat (`files`, `processedUrl`, `originalSize`, `processedSize`, `selectedFileName`, `selectedFileSize`, `originalBlobUrl`) should handle most cases. But the individual settings components need to be checked. + +- [ ] **Step 1: Audit and fix all settings components** + +For each settings component that uses `useFileStore`: +- `files` — now a getter, returns `File[]` from entries. Should work unchanged. +- `processFiles(files, settings)` — the tool processor still takes `File[]`. For single-file tools, pass `files` (it uses `files[0]`). Works unchanged. +- `downloadUrl` — from `useToolProcessor`, still returns the processedUrl. Works unchanged. +- `originalSize` / `processedSize` — now getters. Works unchanged. + +Most settings components should work without changes. Run typecheck to confirm: + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck +``` + +Fix any type errors found. + +- [ ] **Step 2: Commit any fixes** + +```bash +git add apps/web/src/components/tools/ +git commit -m "fix: update tool settings for new file store interface" +``` + +--- + +## Task 7: Update strip-metadata-settings for multi-file metadata + +**Files:** +- Modify: `apps/web/src/components/tools/strip-metadata-settings.tsx` + +- [ ] **Step 1: Update metadata fetching to use selectedIndex** + +Change the `useEffect` that auto-fetches metadata: +- Instead of `files[0]`, use `entries[selectedIndex].file`. +- Add a `Map` cache (keyed by `${file.name}-${file.size}-${file.lastModified}`) to avoid re-fetching when navigating between images. +- Listen to `selectedIndex` changes to update the displayed metadata. + +```typescript +// Key changes in the component: +const { entries, selectedIndex, files } = useFileStore(); + +// Cache metadata per file to avoid re-fetching +const [metadataCache, setMetadataCache] = useState>(new Map()); +const [metadata, setMetadata] = useState(null); + +const currentFile = entries[selectedIndex]?.file ?? null; +const fileKey = currentFile ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` : null; + +useEffect(() => { + if (!currentFile || !fileKey) { + setMetadata(null); + return; + } + + // Check cache first + const cached = metadataCache.get(fileKey); + if (cached) { + setMetadata(cached); + return; + } + + // Fetch metadata for this file + const controller = new AbortController(); + (async () => { + setInspecting(true); + setInspectError(null); + setMetadata(null); + try { + const formData = new FormData(); + formData.append("file", currentFile); + const res = await fetch("/api/v1/tools/strip-metadata/inspect", { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + body: formData, + signal: controller.signal, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Failed: ${res.status}`); + } + const data: MetadataResult = await res.json(); + setMetadata(data); + setMetadataCache((prev) => new Map(prev).set(fileKey, data)); + } catch (err) { + if ((err as Error).name === "AbortError") return; + setInspectError(err instanceof Error ? err.message : "Failed to inspect"); + } finally { + setInspecting(false); + } + })(); + + return () => controller.abort(); +}, [currentFile, fileKey]); +``` + +- [ ] **Step 2: Verify the component works** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/tools/strip-metadata-settings.tsx +git commit -m "feat: multi-file metadata display with per-file caching" +``` + +--- + +## Task 8: Add clientJobId support to batch endpoint + +**Files:** +- Modify: `apps/api/src/routes/batch.ts` + +- [ ] **Step 1: Update batch.ts multipart parsing** + +In the multipart parsing loop (around line 61 in `batch.ts`), add a case for `clientJobId`: + +```typescript +// Add this variable before the parsing loop: +let clientJobId: string | null = null; + +// In the parsing loop, add this branch: +} else if (part.fieldname === "clientJobId") { + clientJobId = part.value as string; +} +``` + +Then change the job ID generation (around line 105): + +```typescript +const jobId = clientJobId || randomUUID(); +``` + +Also add the `X-File-Order` response header after processing (before `archive.finalize()`). This lists original filenames in submission order so the client can match ZIP entries to store entries: + +```typescript +// After the writeHead call, before archive operations, add to headers: +// Actually, we need to add this after we know the filenames. +// Add after the processing loop, before archive.finalize(): +reply.raw.setHeader("X-File-Order", files.map(f => f.filename).join(",")); +``` + +Wait — we can't set headers after `writeHead`. Instead, include it in the initial `writeHead`: + +```typescript +reply.raw.writeHead(200, { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`, + "Transfer-Encoding": "chunked", + "X-Job-Id": jobId, + "X-File-Order": files.map(f => f.filename).join(","), +}); +``` + +- [ ] **Step 2: Run existing tests** + +```bash +pnpm test:integration +``` + +Expected: Existing tests pass (batch route changes are additive). + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/routes/batch.ts +git commit -m "feat: accept clientJobId in batch endpoint for SSE progress correlation" +``` + +--- + +## Task 9: Add processAllFiles to useToolProcessor + +**Files:** +- Modify: `apps/web/src/hooks/use-tool-processor.ts` + +- [ ] **Step 1: Add batch processing method** + +Add a new `processAllFiles` method to the hook. This uses `fetch()` instead of XHR to read response headers immediately. Keep the existing `processFiles` method for single-file backward compat. + +```typescript +// Add to the hook, alongside processFiles: + +const processAllFiles = useCallback( + async (files: File[], settings: Record) => { + if (files.length === 0) { + setError("No files selected"); + return; + } + if (files.length === 1) { + // For single file, use the existing single-file method + processFiles(files, settings); + return; + } + + const { updateEntry, setBatchZip } = useFileStore.getState(); + + setError(null); + setProcessing(true); + setProgress({ phase: "uploading", percent: 0, elapsed: 0 }); + + const startTime = Date.now(); + elapsedRef.current = setInterval(() => { + setProgress((prev) => ({ + ...prev, + elapsed: Math.floor((Date.now() - startTime) / 1000), + })); + }, 1000); + + const clientJobId = crypto.randomUUID(); + + // Open SSE before upload + try { + const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); + eventSourceRef.current = es; + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === "batch") { + const pct = data.totalFiles > 0 + ? 15 + (data.completedFiles / data.totalFiles) * 85 + : 15; + setProgress((prev) => ({ + ...prev, + phase: "processing", + percent: pct, + stage: data.currentFile + ? `Processing ${data.currentFile} (${data.completedFiles}/${data.totalFiles})` + : `Processing ${data.completedFiles}/${data.totalFiles}`, + })); + + // Update per-file status via currentFile + if (data.currentFile) { + const entries = useFileStore.getState().entries; + const idx = entries.findIndex((e) => e.file.name === data.currentFile); + if (idx >= 0) { + updateEntry(idx, { status: "processing" }); + } + } + + // Mark completed files + if (data.completedFiles > 0) { + const entries = useFileStore.getState().entries; + // Mark entries as completed based on count (SSE doesn't tell us which specific files completed) + // We'll update processedUrl later after ZIP extraction + for (let i = 0; i < Math.min(data.completedFiles, entries.length); i++) { + if (entries[i].status === "processing") { + updateEntry(i, { status: "completed" }); + } + } + } + } + } catch { + // Ignore malformed SSE + } + }; + + es.onerror = () => { + es.close(); + eventSourceRef.current = null; + }; + } catch { + // SSE failed — proceed without real-time progress + } + + // Build FormData + const formData = new FormData(); + for (const file of files) { + formData.append("file", file); + } + formData.append("settings", JSON.stringify(settings)); + formData.append("clientJobId", clientJobId); + + try { + const token = localStorage.getItem("stirling-token") || ""; + const response = await fetch(`/api/v1/tools/${toolId}/batch`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + if (!response.ok) { + const text = await response.text(); + let errorMsg: string; + try { + const body = JSON.parse(text); + errorMsg = body.error || body.details || `Batch processing failed: ${response.status}`; + } catch { + errorMsg = `Batch processing failed: ${response.status}`; + } + setError(errorMsg); + setProcessing(false); + setProgress(IDLE_PROGRESS); + return; + } + + // Get the ZIP blob + const zipBlob = await response.blob(); + const filename = `batch-${toolId}.zip`; + setBatchZip(zipBlob, filename); + + // Extract files from ZIP using fflate + const { unzipSync } = await import("fflate"); + const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer()); + const extracted = unzipSync(zipBuffer); + + // Get file order from response header + const fileOrder = response.headers.get("X-File-Order")?.split(",") ?? []; + + // Match extracted files to store entries + const entries = useFileStore.getState().entries; + const extractedNames = Object.keys(extracted); + + for (let i = 0; i < entries.length; i++) { + const originalName = entries[i].file.name; + // Try to find by original order first, then by name match + let zipName: string | undefined; + if (fileOrder[i] && extracted[fileOrder[i]]) { + zipName = fileOrder[i]; + } else { + zipName = extractedNames.find((n) => n === originalName) + ?? extractedNames[i]; + } + + if (zipName && extracted[zipName]) { + const blob = new Blob([extracted[zipName]]); + const blobUrl = URL.createObjectURL(blob); + updateEntry(i, { + processedUrl: blobUrl, + processedSize: blob.size, + status: "completed", + }); + } else { + updateEntry(i, { + status: "failed", + error: "File not found in batch results", + }); + } + } + + setProcessing(false); + setProgress(IDLE_PROGRESS); + } catch (err) { + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setError(err instanceof Error ? err.message : "Batch processing failed"); + setProcessing(false); + setProgress(IDLE_PROGRESS); + } + }, + [toolId, processFiles, setProcessing, setError], +); +``` + +Add `processAllFiles` to the hook's return value: + +```typescript +return { + processFiles, + processAllFiles, + processing, + error, + downloadUrl: processedUrl, + originalSize, + processedSize, + progress, +}; +``` + +- [ ] **Step 2: Verify typecheck passes** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/hooks/use-tool-processor.ts +git commit -m "feat: add processAllFiles batch method to tool processor hook" +``` + +--- + +## Task 10: Wire up batch processing in tool settings components + +**Files:** +- Modify: `apps/web/src/components/tools/strip-metadata-settings.tsx` (as example) +- Modify: Other settings components that should support batch + +- [ ] **Step 1: Update strip-metadata-settings to use processAllFiles** + +Change the `handleProcess` function: + +```typescript +const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("strip-metadata"); + +const handleProcess = () => { + if (files.length > 1) { + processAllFiles(files, { stripAll, stripExif, stripGps, stripIcc, stripXmp }); + } else { + processFiles(files, { stripAll, stripExif, stripGps, stripIcc, stripXmp }); + } +}; +``` + +Update the button text: + +```typescript + +``` + +- [ ] **Step 2: Apply same pattern to other batch-compatible tools** + +For each tool settings component that makes sense for batch processing (resize, compress, convert, rotate, strip-metadata, all color tools, watermark tools, border, favicon): +- Import `processAllFiles` from the hook. +- Use `processAllFiles` when `files.length > 1`, `processFiles` when `files.length === 1`. +- Update button text to show file count. + +Tools that are inherently single-file or multi-file specific (compare, find-duplicates, collage, compose, split, image-to-pdf, bulk-rename) can keep using their existing logic. + +- [ ] **Step 3: Verify typecheck** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/tools/ +git commit -m "feat: wire up batch processing across tool settings components" +``` + +--- + +## Task 11: Build and test locally with Docker + +**Files:** +- No new files + +- [ ] **Step 1: Build Docker image** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image +docker compose -f docker/docker-compose.yml build +``` + +Expected: Build succeeds. fflate is pure JS — no native deps needed. + +- [ ] **Step 2: Start the container** + +```bash +docker compose -f docker/docker-compose.yml up -d +``` + +Expected: Container starts on port 1349. + +- [ ] **Step 3: Manual testing checklist** + +Open `http://localhost:1349` and test: + +1. **Single image upload**: Upload 1 image → should work exactly as before (no filmstrip, no arrows). +2. **Multi image upload**: Upload 3+ images → filmstrip appears at bottom, arrows appear on sides, counter shows "1 / N". +3. **Navigation**: Click thumbnails to switch, click arrows, use keyboard left/right. +4. **Strip metadata**: Upload image with EXIF → metadata display shows parsed fields, GPS warning if present. +5. **Process single**: With 1 file, process → before/after slider works. +6. **Process batch**: With multiple files, click "Process All (N files)" → progress shows, thumbnails get checkmarks. +7. **Download**: After batch processing, "Download This" downloads current file, "Download All (ZIP)" downloads ZIP. +8. **Undo**: Click undo → all entries reset to pending, filmstrip loses checkmarks. +9. **Add more**: Click "+ Add more" → new files appended to filmstrip. +10. **Clear**: Click "Clear all" → everything resets. + +- [ ] **Step 4: Stop container** + +```bash +docker compose -f docker/docker-compose.yml down +``` + +- [ ] **Step 5: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix: address issues found during manual testing" +``` + +--- + +## Task 12: Run full test suite + +**Files:** +- No new files + +- [ ] **Step 1: Run unit tests** + +```bash +pnpm test:unit +``` + +Expected: All tests pass. + +- [ ] **Step 2: Run integration tests** + +```bash +pnpm test:integration +``` + +Expected: All tests pass. The batch endpoint change (clientJobId) is backward compatible. + +- [ ] **Step 3: Run typecheck across all packages** + +```bash +pnpm typecheck +``` + +Expected: No type errors. From ec192e3d96d864e3ce47e991ba1c72d6a4ee7cea Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:51:29 +0800 Subject: [PATCH 09/21] feat: add live preview callback to RotateSettings, rename button to Apply --- .../src/components/tools/rotate-settings.tsx | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx index 1f41f429..9a23b734 100644 --- a/apps/web/src/components/tools/rotate-settings.tsx +++ b/apps/web/src/components/tools/rotate-settings.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useFileStore } from "@/stores/file-store"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { @@ -10,15 +10,30 @@ import { } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card"; -export function RotateSettings() { +export interface PreviewTransform { + rotate: number; + flipH: boolean; + flipV: boolean; +} + +interface RotateSettingsProps { + onPreviewTransform?: (transform: PreviewTransform) => void; +} + +export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processing, error, downloadUrl, progress } = useToolProcessor("rotate"); const [angle, setAngle] = useState(0); const [flipH, setFlipH] = useState(false); const [flipV, setFlipV] = useState(false); + // Emit preview transform on every change + useEffect(() => { + onPreviewTransform?.({ rotate: angle, flipH, flipV }); + }, [angle, flipH, flipV, onPreviewTransform]); + const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360); const rotateRight = () => setAngle((a) => (a + 90) % 360); @@ -113,20 +128,12 @@ export function RotateSettings() { {/* Error */} {error &&

{error}

} - {/* Size info */} - {originalSize != null && processedSize != null && ( -
-

Original: {(originalSize / 1024).toFixed(1)} KB

-

Processed: {(processedSize / 1024).toFixed(1)} KB

-
- )} - {/* Process */} {processing ? ( - Rotate + Apply )} From 59a57f3a00bbd0c6331d1165594ad4266ce3b536 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:53:18 +0800 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20conditional=20result=20views=20?= =?UTF-8?q?=E2=80=94=20side-by-side=20for=20resize,=20live=20preview=20for?= =?UTF-8?q?=20rotate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/tool-page.tsx | 69 ++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 826f6f1e..4e442cb5 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -6,6 +6,8 @@ import { Dropzone } from "@/components/common/dropzone"; import { ImageViewer } from "@/components/common/image-viewer"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { ReviewPanel } from "@/components/common/review-panel"; +import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; +import type { PreviewTransform } from "@/components/tools/rotate-settings"; import { useFileStore } from "@/stores/file-store"; import { useMobile } from "@/hooks/use-mobile"; import { formatFileSize } from "@/lib/download"; @@ -61,12 +63,20 @@ const COLOR_TOOL_IDS = new Set([ // Tools that don't need a file dropzone (they generate content or have custom UI) const NO_DROPZONE_TOOLS = new Set(["qr-generate"]); +const SIDE_BY_SIDE_TOOLS = new Set(["resize"]); +const LIVE_PREVIEW_TOOLS = new Set(["rotate"]); -function ToolSettingsPanel({ toolId }: { toolId: string }) { +function ToolSettingsPanel({ + toolId, + onPreviewTransform, +}: { + toolId: string; + onPreviewTransform?: (t: PreviewTransform) => void; +}) { // Phase 2: Core tools if (toolId === "resize") return ; if (toolId === "crop") return ; - if (toolId === "rotate") return ; + if (toolId === "rotate") return ; if (toolId === "convert") return ; if (toolId === "compress") return ; if (toolId === "strip-metadata") return ; @@ -175,6 +185,7 @@ export function ToolPage() { } = useFileStore(); const isMobile = useMobile(); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true); + const [previewTransform, setPreviewTransform] = useState(null); const handleFiles = useCallback( (newFiles: File[]) => { @@ -263,7 +274,10 @@ export function ToolPage() {

Settings

- +
{/* Review panel (mobile) */} @@ -287,6 +301,19 @@ export function ToolPage() {

Configure settings and generate.

+ ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( + + ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( + ) : hasProcessed && originalBlobUrl ? ( ) : ( Settings - +
{/* Review panel (desktop - below settings) */} @@ -372,10 +409,21 @@ export function ToolPage() {
{isNoDropzone ? (
-

- Configure settings in the panel and generate. -

+

Configure settings and generate.

+ ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( + + ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( + ) : hasProcessed && originalBlobUrl ? ( ) : ( Date: Mon, 23 Mar 2026 12:53:34 +0800 Subject: [PATCH 11/21] chore: add .worktrees/ to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0bb067e2..6868fd72 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ stirling-pdf-*.png settings-*.png layout-*.png audit_report.md +.worktrees/ From 890ed216172ef2b7220afa56012b86a61fffe831 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:55:10 +0800 Subject: [PATCH 12/21] chore: add fflate for client-side ZIP extraction --- apps/web/package.json | 9 +++++---- pnpm-lock.yaml | 8 ++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index c69d616b..afd72497 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,20 +12,21 @@ }, "dependencies": { "@stirling-image/shared": "workspace:*", + "clsx": "^2.1.0", + "fflate": "^0.8.2", + "lucide-react": "^0.469.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.0", - "zustand": "^5.0.0", - "clsx": "^2.1.0", "tailwind-merge": "^2.6.0", - "lucide-react": "^0.469.0" + "zustand": "^5.0.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", "tailwindcss": "^4.0.0", - "@tailwindcss/vite": "^4.0.0", "typescript": "^5.7.0", "vite": "^6.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cbf3c05..c5c08ef6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: clsx: specifier: ^2.1.0 version: 2.1.1 + fflate: + specifier: ^0.8.2 + version: 0.8.2 lucide-react: specifier: ^0.469.0 version: 0.469.0(react@19.2.4) @@ -3254,6 +3257,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -8275,6 +8281,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 From 8844b44fff2abfbe3262a113dfc35d83367add05 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 14:02:33 +0800 Subject: [PATCH 13/21] feat: rewrite file-store with FileEntry model for multi-image support --- apps/web/src/stores/file-store.ts | 269 ++++++++++++--- tests/unit/web/stores.test.ts | 546 ++++++++++++++++++------------ 2 files changed, 549 insertions(+), 266 deletions(-) diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts index 9051414a..cda62ad7 100644 --- a/apps/web/src/stores/file-store.ts +++ b/apps/web/src/stores/file-store.ts @@ -1,83 +1,258 @@ import { create } from "zustand"; -interface FileState { - files: File[]; - jobId: string | null; +export interface FileEntry { + file: File; + blobUrl: string; processedUrl: string | null; - /** Blob URL for the original image (for before/after comparison). */ - originalBlobUrl: string | null; + processedSize: number | null; + originalSize: number; + status: "pending" | "processing" | "completed" | "failed"; + error: string | null; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createEntry(file: File): FileEntry { + return { + file, + blobUrl: URL.createObjectURL(file), + processedUrl: null, + processedSize: null, + originalSize: file.size, + status: "pending", + error: null, + }; +} + +function revokeEntries(entries: FileEntry[]): void { + for (const entry of entries) { + URL.revokeObjectURL(entry.blobUrl); + if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl); + } +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +interface FileState { + entries: FileEntry[]; + selectedIndex: number; + batchZipBlob: Blob | null; + batchZipFilename: string | null; processing: boolean; error: string | null; - originalSize: number | null; - processedSize: number | null; - selectedFileName: string | null; - selectedFileSize: number | null; + + // Backward compat getters (computed from entries + selectedIndex) + readonly files: File[]; + readonly currentEntry: FileEntry | undefined; + readonly hasFiles: boolean; + readonly allProcessed: boolean; + readonly selectedFileName: string | null; + readonly selectedFileSize: number | null; + readonly originalBlobUrl: string | null; + readonly processedUrl: string | null; + readonly originalSize: number | null; + readonly processedSize: number | null; + + // Actions setFiles: (files: File[]) => void; - setJobId: (id: string) => void; - setProcessedUrl: (url: string | null) => void; + addFiles: (files: File[]) => void; + removeFile: (index: number) => void; + setSelectedIndex: (index: number) => void; + navigateNext: () => void; + navigatePrev: () => void; + updateEntry: (index: number, patch: Partial) => void; + setBatchZip: (blob: Blob, filename: string) => void; setProcessing: (v: boolean) => void; setError: (e: string | null) => void; + setJobId: (id: string) => void; + setProcessedUrl: (url: string | null) => void; setSizes: (original: number, processed: number) => void; - /** Clear processed result but keep the original uploaded file. */ undoProcessing: () => void; reset: () => void; } +/** + * Compute backward-compat derived values from core state. + * Called after every state mutation to keep derived fields in sync. + */ +function deriveCompat(entries: FileEntry[], selectedIndex: number) { + const entry = entries[selectedIndex]; + return { + files: entries.map((e) => e.file), + currentEntry: entry, + hasFiles: entries.length > 0, + allProcessed: + entries.length > 0 && entries.every((e) => e.status === "completed"), + selectedFileName: entry ? entry.file.name : null, + selectedFileSize: entry ? entry.file.size : null, + originalBlobUrl: entry ? entry.blobUrl : null, + processedUrl: entry ? entry.processedUrl : null, + originalSize: entry ? entry.originalSize : null, + processedSize: entry ? entry.processedSize : null, + }; +} + export const useFileStore = create((set, get) => ({ - files: [], - jobId: null, - processedUrl: null, - originalBlobUrl: null, + entries: [], + selectedIndex: 0, + batchZipBlob: null, + batchZipFilename: null, processing: false, error: null, - originalSize: null, - processedSize: null, - selectedFileName: null, - selectedFileSize: null, + + // Initial derived values (empty state) + ...deriveCompat([], 0), + + // -- Actions -------------------------------------------------------------- + setFiles: (files) => { - // Revoke old blob URL if any - const old = get().originalBlobUrl; - if (old) URL.revokeObjectURL(old); - // Create a blob URL for the first file for before/after preview - const blobUrl = files.length > 0 ? URL.createObjectURL(files[0]) : null; - const firstName = files.length > 0 ? files[0].name : null; - const firstSize = files.length > 0 ? files[0].size : null; + revokeEntries(get().entries); + const entries = files.map(createEntry); set({ - files, + entries, + selectedIndex: 0, error: null, - originalBlobUrl: blobUrl, - selectedFileName: firstName, - selectedFileSize: firstSize, + ...deriveCompat(entries, 0), }); }, - setJobId: (id) => set({ jobId: id }), - setProcessedUrl: (url) => set({ processedUrl: url }), + + addFiles: (files) => { + const entries = [...get().entries, ...files.map(createEntry)]; + const idx = get().selectedIndex; + set({ entries, ...deriveCompat(entries, idx) }); + }, + + removeFile: (index) => { + const { entries, selectedIndex } = get(); + const removed = entries[index]; + if (!removed) return; + + URL.revokeObjectURL(removed.blobUrl); + if (removed.processedUrl) URL.revokeObjectURL(removed.processedUrl); + + const newEntries = entries.filter((_, i) => i !== index); + let newIndex = selectedIndex; + if (index < selectedIndex) { + newIndex = selectedIndex - 1; + } else if (selectedIndex >= newEntries.length && newEntries.length > 0) { + newIndex = newEntries.length - 1; + } else if (newEntries.length === 0) { + newIndex = 0; + } + set({ + entries: newEntries, + selectedIndex: newIndex, + ...deriveCompat(newEntries, newIndex), + }); + }, + + setSelectedIndex: (index) => { + set({ + selectedIndex: index, + ...deriveCompat(get().entries, index), + }); + }, + + navigateNext: () => { + const { selectedIndex, entries } = get(); + if (selectedIndex < entries.length - 1) { + const idx = selectedIndex + 1; + set({ selectedIndex: idx, ...deriveCompat(entries, idx) }); + } + }, + + navigatePrev: () => { + const { selectedIndex, entries } = get(); + if (selectedIndex > 0) { + const idx = selectedIndex - 1; + set({ selectedIndex: idx, ...deriveCompat(entries, idx) }); + } + }, + + updateEntry: (index, patch) => { + const entries = [...get().entries]; + if (!entries[index]) return; + entries[index] = { ...entries[index], ...patch }; + const idx = get().selectedIndex; + set({ entries, ...deriveCompat(entries, idx) }); + }, + + setBatchZip: (blob, filename) => + set({ batchZipBlob: blob, batchZipFilename: filename }), + setProcessing: (v) => set({ processing: v }), + setError: (e) => set({ error: e, processing: false }), - setSizes: (original, processed) => - set({ originalSize: original, processedSize: processed }), + + setJobId: (_id) => { + // no-op for backward compat + }, + + setProcessedUrl: (url) => { + const { entries, selectedIndex } = get(); + if (!entries[selectedIndex]) return; + const updated = [...entries]; + if (url) { + updated[selectedIndex] = { + ...updated[selectedIndex], + processedUrl: url, + status: "completed", + }; + } else { + updated[selectedIndex] = { + ...updated[selectedIndex], + processedUrl: null, + status: "pending", + }; + } + set({ entries: updated, ...deriveCompat(updated, selectedIndex) }); + }, + + setSizes: (original, processed) => { + const { entries, selectedIndex } = get(); + if (!entries[selectedIndex]) return; + const updated = [...entries]; + updated[selectedIndex] = { + ...updated[selectedIndex], + originalSize: original, + processedSize: processed, + }; + set({ entries: updated, ...deriveCompat(updated, selectedIndex) }); + }, + undoProcessing: () => { - set({ + const { entries, selectedIndex } = get(); + for (const entry of entries) { + if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl); + } + const resetEntries = entries.map((e) => ({ + ...e, processedUrl: null, - jobId: null, processedSize: null, + status: "pending" as const, error: null, + })); + set({ + entries: resetEntries, + error: null, + ...deriveCompat(resetEntries, selectedIndex), }); }, + reset: () => { - const old = get().originalBlobUrl; - if (old) URL.revokeObjectURL(old); + revokeEntries(get().entries); set({ - files: [], - jobId: null, - processedUrl: null, - originalBlobUrl: null, + entries: [], + selectedIndex: 0, + batchZipBlob: null, + batchZipFilename: null, processing: false, error: null, - originalSize: null, - processedSize: null, - selectedFileName: null, - selectedFileSize: null, + ...deriveCompat([], 0), }); }, })); diff --git a/tests/unit/web/stores.test.ts b/tests/unit/web/stores.test.ts index 52fece9b..381c1b34 100644 --- a/tests/unit/web/stores.test.ts +++ b/tests/unit/web/stores.test.ts @@ -82,295 +82,403 @@ function failResponse(status: number) { describe("FileStore", () => { beforeEach(() => { - // Reset the store to initial state before every test. - // Zustand keeps state across calls, so we manually reset. useFileStore.getState().reset(); vi.clearAllMocks(); - // After reset, createObjectURL/revokeObjectURL calls are from reset itself; - // clear them so each test starts clean. createObjectURL.mockClear(); revokeObjectURL.mockClear(); + // Reset the mock to return incrementing URLs + let urlCounter = 0; + createObjectURL.mockImplementation( + (_obj: Blob | MediaSource) => `blob:url-${++urlCounter}`, + ); }); // -- Initial state ------------------------------------------------------- - it("has correct initial state (everything null/empty/false)", () => { + it("has correct initial state", () => { const s = useFileStore.getState(); - expect(s.files).toEqual([]); - expect(s.jobId).toBeNull(); - expect(s.processedUrl).toBeNull(); - expect(s.originalBlobUrl).toBeNull(); + expect(s.entries).toEqual([]); + expect(s.selectedIndex).toBe(0); + expect(s.batchZipBlob).toBeNull(); + expect(s.batchZipFilename).toBeNull(); expect(s.processing).toBe(false); expect(s.error).toBeNull(); - expect(s.originalSize).toBeNull(); - expect(s.processedSize).toBeNull(); - expect(s.selectedFileName).toBeNull(); - expect(s.selectedFileSize).toBeNull(); }); // -- setFiles ------------------------------------------------------------- - it("setFiles stores files, creates blob URL, sets selectedFileName/Size, clears error", () => { - // Seed an error first so we can verify it gets cleared - useFileStore.getState().setError("old error"); - expect(useFileStore.getState().error).toBe("old error"); - - const file = makeFile("photo.png", 2048); - useFileStore.getState().setFiles([file]); - - const s = useFileStore.getState(); - expect(s.files).toHaveLength(1); - expect(s.files[0]).toBe(file); - expect(createObjectURL).toHaveBeenCalledWith(file); - expect(s.originalBlobUrl).toBe("blob:fake-url"); - expect(s.selectedFileName).toBe("photo.png"); - expect(s.selectedFileSize).toBe(2048); - expect(s.error).toBeNull(); // error cleared - }); - - it("setFiles revokes the previous blob URL when new files are set", () => { - createObjectURL - .mockReturnValueOnce("blob:first-url") - .mockReturnValueOnce("blob:second-url"); - - useFileStore.getState().setFiles([makeFile("a.png")]); - expect(useFileStore.getState().originalBlobUrl).toBe("blob:first-url"); - - useFileStore.getState().setFiles([makeFile("b.png")]); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:first-url"); - expect(useFileStore.getState().originalBlobUrl).toBe("blob:second-url"); - }); - - it("setFiles with empty array does NOT create a blob URL", () => { - useFileStore.getState().setFiles([]); - - const s = useFileStore.getState(); - expect(createObjectURL).not.toHaveBeenCalled(); - expect(s.originalBlobUrl).toBeNull(); - expect(s.selectedFileName).toBeNull(); - expect(s.selectedFileSize).toBeNull(); - }); - - it("setFiles with empty array after prior files still revokes old URL", () => { - createObjectURL.mockReturnValueOnce("blob:old"); - useFileStore.getState().setFiles([makeFile("old.png")]); - revokeObjectURL.mockClear(); - - useFileStore.getState().setFiles([]); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:old"); - }); - - it("setFiles uses the FIRST file for blob URL when given multiple files", () => { - const f1 = makeFile("first.png", 100); - const f2 = makeFile("second.png", 200); + it("setFiles creates entries with blob URLs", () => { + const f1 = makeFile("a.png", 100); + const f2 = makeFile("b.png", 200); useFileStore.getState().setFiles([f1, f2]); - // createObjectURL is called exactly once (only for the first file) - expect(createObjectURL).toHaveBeenCalledTimes(1); - // Verify the argument was f1 by identity (same reference) - expect(createObjectURL.mock.calls[0][0]).toBe(f1); - expect(useFileStore.getState().selectedFileName).toBe("first.png"); - expect(useFileStore.getState().selectedFileSize).toBe(100); - }); - - // -- setJobId ------------------------------------------------------------- - - it("setJobId stores the job ID", () => { - useFileStore.getState().setJobId("job-abc"); - expect(useFileStore.getState().jobId).toBe("job-abc"); - }); - - // -- setProcessedUrl ------------------------------------------------------ - - it("setProcessedUrl stores a URL", () => { - useFileStore.getState().setProcessedUrl("blob:processed"); - expect(useFileStore.getState().processedUrl).toBe("blob:processed"); - }); - - it("setProcessedUrl can clear URL with null", () => { - useFileStore.getState().setProcessedUrl("blob:x"); - useFileStore.getState().setProcessedUrl(null); - expect(useFileStore.getState().processedUrl).toBeNull(); - }); - - // -- setProcessing -------------------------------------------------------- - - it("setProcessing sets the processing flag", () => { - useFileStore.getState().setProcessing(true); - expect(useFileStore.getState().processing).toBe(true); - useFileStore.getState().setProcessing(false); - expect(useFileStore.getState().processing).toBe(false); - }); - - // -- setError ------------------------------------------------------------- - - it("setError sets error AND forces processing to false", () => { - useFileStore.getState().setProcessing(true); - expect(useFileStore.getState().processing).toBe(true); - - useFileStore.getState().setError("something broke"); const s = useFileStore.getState(); - expect(s.error).toBe("something broke"); - expect(s.processing).toBe(false); // critical side-effect + expect(s.entries).toHaveLength(2); + expect(s.entries[0].file).toBe(f1); + expect(s.entries[0].blobUrl).toBe("blob:url-1"); + expect(s.entries[0].originalSize).toBe(100); + expect(s.entries[0].status).toBe("pending"); + expect(s.entries[0].processedUrl).toBeNull(); + expect(s.entries[0].processedSize).toBeNull(); + expect(s.entries[0].error).toBeNull(); + expect(s.entries[1].file).toBe(f2); + expect(s.entries[1].blobUrl).toBe("blob:url-2"); + expect(createObjectURL).toHaveBeenCalledTimes(2); }); - it("setError(null) clears error but still forces processing to false", () => { - useFileStore.getState().setProcessing(true); - useFileStore.getState().setError(null); + it("setFiles revokes old blob URLs", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + const oldUrl = useFileStore.getState().entries[0].blobUrl; + revokeObjectURL.mockClear(); + + useFileStore.getState().setFiles([makeFile("b.png")]); + expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl); + }); + + it("setFiles clears on empty array", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + revokeObjectURL.mockClear(); + const oldUrl = useFileStore.getState().entries[0].blobUrl; + + useFileStore.getState().setFiles([]); + expect(useFileStore.getState().entries).toEqual([]); + expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl); + }); + + it("setFiles resets selectedIndex to 0", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().setSelectedIndex(1); + expect(useFileStore.getState().selectedIndex).toBe(1); + + useFileStore.getState().setFiles([makeFile("c.png")]); + expect(useFileStore.getState().selectedIndex).toBe(0); + }); + + it("setFiles clears error", () => { + useFileStore.getState().setError("old error"); + useFileStore.getState().setFiles([makeFile("a.png")]); expect(useFileStore.getState().error).toBeNull(); - expect(useFileStore.getState().processing).toBe(false); }); - // -- setSizes ------------------------------------------------------------- + // -- addFiles ------------------------------------------------------------- + + it("addFiles appends new entries without revoking existing", () => { + useFileStore.getState().setFiles([makeFile("a.png", 100)]); + revokeObjectURL.mockClear(); + createObjectURL.mockClear(); + + const f2 = makeFile("b.png", 200); + useFileStore.getState().addFiles([f2]); + + expect(revokeObjectURL).not.toHaveBeenCalled(); + expect(useFileStore.getState().entries).toHaveLength(2); + expect(useFileStore.getState().entries[1].file).toBe(f2); + expect(createObjectURL).toHaveBeenCalledTimes(1); + }); + + // -- removeFile ----------------------------------------------------------- + + it("removeFile removes entry and revokes its blob URLs", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + const removedUrl = useFileStore.getState().entries[0].blobUrl; + revokeObjectURL.mockClear(); + + useFileStore.getState().removeFile(0); + expect(useFileStore.getState().entries).toHaveLength(1); + expect(useFileStore.getState().entries[0].file.name).toBe("b.png"); + expect(revokeObjectURL).toHaveBeenCalledWith(removedUrl); + }); + + it("removeFile adjusts selectedIndex when removing before it", () => { + useFileStore.getState().setFiles([ + makeFile("a.png"), + makeFile("b.png"), + makeFile("c.png"), + ]); + useFileStore.getState().setSelectedIndex(2); + + useFileStore.getState().removeFile(0); + expect(useFileStore.getState().selectedIndex).toBe(1); + }); + + it("removeFile clamps selectedIndex if it was the last entry", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().setSelectedIndex(1); + + useFileStore.getState().removeFile(1); + expect(useFileStore.getState().selectedIndex).toBe(0); + }); + + it("removeFile revokes processedUrl if present", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().updateEntry(0, { + processedUrl: "blob:processed", + status: "completed", + }); + revokeObjectURL.mockClear(); + + useFileStore.getState().removeFile(0); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed"); + }); + + // -- Navigation ----------------------------------------------------------- + + it("navigateNext advances selectedIndex", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + expect(useFileStore.getState().selectedIndex).toBe(0); + + useFileStore.getState().navigateNext(); + expect(useFileStore.getState().selectedIndex).toBe(1); + }); + + it("navigateNext does not exceed bounds", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().setSelectedIndex(1); + + useFileStore.getState().navigateNext(); + expect(useFileStore.getState().selectedIndex).toBe(1); + }); + + it("navigatePrev decrements selectedIndex", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().setSelectedIndex(1); + + useFileStore.getState().navigatePrev(); + expect(useFileStore.getState().selectedIndex).toBe(0); + }); + + it("navigatePrev does not go below 0", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().navigatePrev(); + expect(useFileStore.getState().selectedIndex).toBe(0); + }); + + // -- updateEntry ---------------------------------------------------------- + + it("updateEntry merges partial data into the entry at index", () => { + useFileStore.getState().setFiles([makeFile("a.png", 500)]); + useFileStore.getState().updateEntry(0, { + status: "completed", + processedUrl: "blob:done", + processedSize: 250, + }); + + const entry = useFileStore.getState().entries[0]; + expect(entry.status).toBe("completed"); + expect(entry.processedUrl).toBe("blob:done"); + expect(entry.processedSize).toBe(250); + expect(entry.file.name).toBe("a.png"); // unchanged + }); + + // -- setBatchZip ---------------------------------------------------------- + + it("setBatchZip stores blob and filename", () => { + const blob = new Blob(["zip-data"]); + useFileStore.getState().setBatchZip(blob, "results.zip"); - it("setSizes sets both originalSize and processedSize", () => { - useFileStore.getState().setSizes(5000, 2500); const s = useFileStore.getState(); - expect(s.originalSize).toBe(5000); - expect(s.processedSize).toBe(2500); - }); - - it("setSizes with zero values stores zeros (not null)", () => { - useFileStore.getState().setSizes(0, 0); - expect(useFileStore.getState().originalSize).toBe(0); - expect(useFileStore.getState().processedSize).toBe(0); + expect(s.batchZipBlob).toBe(blob); + expect(s.batchZipFilename).toBe("results.zip"); }); // -- undoProcessing ------------------------------------------------------- - it("undoProcessing clears processedUrl, jobId, processedSize, error but KEEPS files and originalBlobUrl", () => { - createObjectURL.mockReturnValueOnce("blob:orig"); - - // Set up full state - const file = makeFile("keep-me.png", 3000); - useFileStore.getState().setFiles([file]); - useFileStore.getState().setJobId("job-1"); - useFileStore.getState().setProcessedUrl("blob:result"); - useFileStore.getState().setSizes(3000, 1500); - useFileStore.getState().setError("transient error"); + it("undoProcessing resets all entries to pending and revokes processed blob URLs", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().updateEntry(0, { + status: "completed", + processedUrl: "blob:proc-a", + processedSize: 50, + }); + useFileStore.getState().updateEntry(1, { + status: "completed", + processedUrl: "blob:proc-b", + processedSize: 75, + }); + revokeObjectURL.mockClear(); useFileStore.getState().undoProcessing(); const s = useFileStore.getState(); - // Cleared - expect(s.processedUrl).toBeNull(); - expect(s.jobId).toBeNull(); - expect(s.processedSize).toBeNull(); - expect(s.error).toBeNull(); - // Preserved - expect(s.files).toHaveLength(1); - expect(s.files[0]).toBe(file); - expect(s.originalBlobUrl).toBe("blob:orig"); - expect(s.selectedFileName).toBe("keep-me.png"); - expect(s.selectedFileSize).toBe(3000); - // originalSize is NOT cleared by undoProcessing (only processedSize is) - expect(s.originalSize).toBe(3000); + // All entries reset to pending + expect(s.entries[0].status).toBe("pending"); + expect(s.entries[0].processedUrl).toBeNull(); + expect(s.entries[0].processedSize).toBeNull(); + expect(s.entries[0].error).toBeNull(); + expect(s.entries[1].status).toBe("pending"); + expect(s.entries[1].processedUrl).toBeNull(); + // Processed URLs revoked + expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-a"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-b"); }); - it("undoProcessing does NOT revoke the originalBlobUrl", () => { - createObjectURL.mockReturnValueOnce("blob:keep-alive"); - useFileStore.getState().setFiles([makeFile("x.png")]); + it("undoProcessing keeps original blob URLs", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + const origUrl = useFileStore.getState().entries[0].blobUrl; revokeObjectURL.mockClear(); useFileStore.getState().undoProcessing(); - expect(revokeObjectURL).not.toHaveBeenCalled(); + // Should NOT revoke original blob URL + expect(revokeObjectURL).not.toHaveBeenCalledWith(origUrl); + expect(useFileStore.getState().entries[0].blobUrl).toBe(origUrl); }); // -- reset ---------------------------------------------------------------- - it("reset clears everything and revokes the blob URL", () => { - createObjectURL.mockReturnValueOnce("blob:to-revoke"); - useFileStore.getState().setFiles([makeFile("doomed.png")]); - useFileStore.getState().setJobId("job-x"); - useFileStore.getState().setProcessedUrl("blob:proc"); - useFileStore.getState().setProcessing(true); - useFileStore.getState().setError("oops"); - useFileStore.getState().setSizes(100, 50); + it("reset clears everything and revokes all blob URLs", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().updateEntry(0, { processedUrl: "blob:proc" }); + const origUrl0 = useFileStore.getState().entries[0].blobUrl; + const origUrl1 = useFileStore.getState().entries[1].blobUrl; revokeObjectURL.mockClear(); useFileStore.getState().reset(); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:to-revoke"); + expect(revokeObjectURL).toHaveBeenCalledWith(origUrl0); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc"); + expect(revokeObjectURL).toHaveBeenCalledWith(origUrl1); const s = useFileStore.getState(); - expect(s.files).toEqual([]); - expect(s.jobId).toBeNull(); - expect(s.processedUrl).toBeNull(); - expect(s.originalBlobUrl).toBeNull(); + expect(s.entries).toEqual([]); + expect(s.selectedIndex).toBe(0); + expect(s.batchZipBlob).toBeNull(); + expect(s.batchZipFilename).toBeNull(); expect(s.processing).toBe(false); expect(s.error).toBeNull(); - expect(s.originalSize).toBeNull(); - expect(s.processedSize).toBeNull(); - expect(s.selectedFileName).toBeNull(); - expect(s.selectedFileSize).toBeNull(); }); - it("reset when originalBlobUrl is already null does NOT call revokeObjectURL", () => { - // Start from a clean state (no files set) + it("reset with no entries does not call revokeObjectURL", () => { revokeObjectURL.mockClear(); useFileStore.getState().reset(); expect(revokeObjectURL).not.toHaveBeenCalled(); }); - // -- State transition sequences ------------------------------------------- + // -- Backward compat getters ---------------------------------------------- - it("setFiles -> setProcessing(true) -> setError -> processing is false", () => { - useFileStore.getState().setFiles([makeFile("t.png")]); - useFileStore.getState().setProcessing(true); - expect(useFileStore.getState().processing).toBe(true); + it("files getter maps entries to File[]", () => { + const f1 = makeFile("a.png"); + const f2 = makeFile("b.png"); + useFileStore.getState().setFiles([f1, f2]); - useFileStore.getState().setError("fail"); - expect(useFileStore.getState().processing).toBe(false); - expect(useFileStore.getState().error).toBe("fail"); + const s = useFileStore.getState(); + expect(s.files).toEqual([f1, f2]); + expect(s.files[0]).toBe(f1); }); - it("setFiles -> setProcessing(true) -> setProcessedUrl -> setProcessing(false) (happy path)", () => { - useFileStore.getState().setFiles([makeFile("t.png")]); - useFileStore.getState().setProcessing(true); - expect(useFileStore.getState().processing).toBe(true); + it("currentEntry returns entry at selectedIndex", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().setSelectedIndex(1); - useFileStore.getState().setProcessedUrl("blob:done"); - // processedUrl does NOT auto-clear processing - expect(useFileStore.getState().processing).toBe(true); + expect(useFileStore.getState().currentEntry?.file.name).toBe("b.png"); + }); - useFileStore.getState().setProcessing(false); - expect(useFileStore.getState().processing).toBe(false); + it("currentEntry returns undefined when no entries", () => { + expect(useFileStore.getState().currentEntry).toBeUndefined(); + }); + + it("selectedFileName returns current entry file name", () => { + useFileStore.getState().setFiles([makeFile("photo.png")]); + expect(useFileStore.getState().selectedFileName).toBe("photo.png"); + }); + + it("selectedFileName returns null when no entries", () => { + expect(useFileStore.getState().selectedFileName).toBeNull(); + }); + + it("selectedFileSize returns current entry file size", () => { + useFileStore.getState().setFiles([makeFile("photo.png", 2048)]); + expect(useFileStore.getState().selectedFileSize).toBe(2048); + }); + + it("selectedFileSize returns null when no entries", () => { + expect(useFileStore.getState().selectedFileSize).toBeNull(); + }); + + it("originalBlobUrl returns current entry blobUrl", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + expect(useFileStore.getState().originalBlobUrl).toBe( + useFileStore.getState().entries[0].blobUrl, + ); + }); + + it("originalBlobUrl returns null when no entries", () => { + expect(useFileStore.getState().originalBlobUrl).toBeNull(); + }); + + it("processedUrl returns current entry processedUrl", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().updateEntry(0, { processedUrl: "blob:done" }); expect(useFileStore.getState().processedUrl).toBe("blob:done"); }); - it("rapid setFiles calls only keep the latest state and revoke each prior URL", () => { - createObjectURL - .mockReturnValueOnce("blob:1") - .mockReturnValueOnce("blob:2") - .mockReturnValueOnce("blob:3"); - - useFileStore.getState().setFiles([makeFile("a.png")]); - useFileStore.getState().setFiles([makeFile("b.png")]); - useFileStore.getState().setFiles([makeFile("c.png")]); - - expect(revokeObjectURL).toHaveBeenCalledWith("blob:1"); - expect(revokeObjectURL).toHaveBeenCalledWith("blob:2"); - expect(revokeObjectURL).toHaveBeenCalledTimes(2); - expect(useFileStore.getState().originalBlobUrl).toBe("blob:3"); - expect(useFileStore.getState().selectedFileName).toBe("c.png"); + it("originalSize returns current entry originalSize", () => { + useFileStore.getState().setFiles([makeFile("a.png", 999)]); + expect(useFileStore.getState().originalSize).toBe(999); }); - it("setError during processing, then undoProcessing, then retry cycle works", () => { - useFileStore.getState().setFiles([makeFile("retry.png")]); - useFileStore.getState().setProcessing(true); - useFileStore.getState().setError("timeout"); - expect(useFileStore.getState().processing).toBe(false); + it("processedSize returns current entry processedSize", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().updateEntry(0, { processedSize: 500 }); + expect(useFileStore.getState().processedSize).toBe(500); + }); - useFileStore.getState().undoProcessing(); - expect(useFileStore.getState().error).toBeNull(); - expect(useFileStore.getState().files).toHaveLength(1); + it("hasFiles returns true when entries exist", () => { + expect(useFileStore.getState().hasFiles).toBe(false); + useFileStore.getState().setFiles([makeFile("a.png")]); + expect(useFileStore.getState().hasFiles).toBe(true); + }); - // Retry - useFileStore.getState().setProcessing(true); - expect(useFileStore.getState().processing).toBe(true); - useFileStore.getState().setProcessedUrl("blob:retry-ok"); - useFileStore.getState().setProcessing(false); - expect(useFileStore.getState().processedUrl).toBe("blob:retry-ok"); + it("allProcessed returns true when all entries are completed", () => { + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + expect(useFileStore.getState().allProcessed).toBe(false); + + useFileStore.getState().updateEntry(0, { status: "completed" }); + expect(useFileStore.getState().allProcessed).toBe(false); + + useFileStore.getState().updateEntry(1, { status: "completed" }); + expect(useFileStore.getState().allProcessed).toBe(true); + }); + + it("allProcessed returns false when no entries", () => { + expect(useFileStore.getState().allProcessed).toBe(false); + }); + + // -- setProcessedUrl (backward compat, updates current entry) ------------- + + it("setProcessedUrl updates current entry processedUrl and status", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().setProcessedUrl("blob:result"); + + const entry = useFileStore.getState().entries[0]; + expect(entry.processedUrl).toBe("blob:result"); + expect(entry.status).toBe("completed"); + }); + + it("setProcessedUrl with null resets current entry", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().setProcessedUrl("blob:result"); + useFileStore.getState().setProcessedUrl(null); + + const entry = useFileStore.getState().entries[0]; + expect(entry.processedUrl).toBeNull(); + expect(entry.status).toBe("pending"); + }); + + // -- setSizes (backward compat, updates current entry) -------------------- + + it("setSizes updates current entry sizes", () => { + useFileStore.getState().setFiles([makeFile("a.png", 1000)]); + useFileStore.getState().setSizes(1000, 500); + + const entry = useFileStore.getState().entries[0]; + expect(entry.originalSize).toBe(1000); + expect(entry.processedSize).toBe(500); + }); + + // -- setJobId (no-op for compat) ------------------------------------------ + + it("setJobId is a no-op (does not throw)", () => { + expect(() => useFileStore.getState().setJobId("job-abc")).not.toThrow(); }); }); From 6521d702385f23a7cb9312c1661541943bfe2e03 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 14:03:55 +0800 Subject: [PATCH 14/21] feat: add ThumbnailStrip filmstrip component --- .../src/components/common/thumbnail-strip.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/web/src/components/common/thumbnail-strip.tsx diff --git a/apps/web/src/components/common/thumbnail-strip.tsx b/apps/web/src/components/common/thumbnail-strip.tsx new file mode 100644 index 00000000..aeb5e457 --- /dev/null +++ b/apps/web/src/components/common/thumbnail-strip.tsx @@ -0,0 +1,57 @@ +import { useRef, useEffect } from "react"; +import { CheckCircle2, XCircle } from "lucide-react"; +import type { FileEntry } from "@/stores/file-store"; + +interface ThumbnailStripProps { + entries: FileEntry[]; + selectedIndex: number; + onSelect: (index: number) => void; +} + +export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailStripProps) { + const selectedRef = useRef(null); + + useEffect(() => { + selectedRef.current?.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: "smooth", + }); + }, [selectedIndex]); + + if (entries.length <= 1) return null; + + return ( +
+ {entries.map((entry, i) => { + const isSelected = i === selectedIndex; + const isCompleted = entry.status === "completed"; + const isFailed = entry.status === "failed"; + return ( + + ); + })} +
+ ); +} From 2514078ea1feb8e44296e226e37e8e379a20473a Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 14:03:56 +0800 Subject: [PATCH 15/21] feat: accept clientJobId in batch endpoint for SSE progress correlation --- apps/api/src/routes/batch.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index 62ea134c..041014f2 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -42,6 +42,7 @@ export async function registerBatchRoutes( // Parse multipart: collect all files and the settings field const files: ParsedFile[] = []; let settingsRaw: string | null = null; + let clientJobId: string | null = null; try { const parts = request.parts(); @@ -60,6 +61,8 @@ export async function registerBatchRoutes( } } else if (part.fieldname === "settings") { settingsRaw = part.value as string; + } else if (part.fieldname === "clientJobId") { + clientJobId = part.value as string; } } } catch (err) { @@ -102,7 +105,7 @@ export async function registerBatchRoutes( } // Create a job ID for progress tracking - const jobId = randomUUID(); + const jobId = clientJobId || randomUUID(); const progress: JobProgress = { jobId, @@ -120,6 +123,7 @@ export async function registerBatchRoutes( "Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`, "Transfer-Encoding": "chunked", "X-Job-Id": jobId, + "X-File-Order": files.map(f => f.filename).join(","), }); // Create ZIP archive that pipes directly to the response From 815a8ed419b82aadec4e07ef4456e9e96b84b65f Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 14:03:59 +0800 Subject: [PATCH 16/21] feat: add MultiImageViewer with arrow navigation and filmstrip --- .../components/common/multi-image-viewer.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/web/src/components/common/multi-image-viewer.tsx diff --git a/apps/web/src/components/common/multi-image-viewer.tsx b/apps/web/src/components/common/multi-image-viewer.tsx new file mode 100644 index 00000000..6534841d --- /dev/null +++ b/apps/web/src/components/common/multi-image-viewer.tsx @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ImageViewer } from "@/components/common/image-viewer"; +import { BeforeAfterSlider } from "@/components/common/before-after-slider"; +import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; +import { useFileStore } from "@/stores/file-store"; + +export function MultiImageViewer() { + const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore(); + const currentEntry = entries[selectedIndex]; + if (!currentEntry) return null; + + const hasMultiple = entries.length > 1; + const hasPrev = selectedIndex > 0; + const hasNext = selectedIndex < entries.length - 1; + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); } + else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); } + }, [navigateNext, navigatePrev]); + + const hasProcessed = !!currentEntry.processedUrl; + + return ( +
+
+ {hasMultiple && hasPrev && ( + + )} +
+ {hasProcessed ? ( + + ) : ( + + )} +
+ {hasMultiple && hasNext && ( + + )} + {hasMultiple && ( +
+ {selectedIndex + 1} / {entries.length} +
+ )} +
+ +
+ ); +} From 37965338228a2fca3623718a7db2f8757a464651 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 14:07:28 +0800 Subject: [PATCH 17/21] feat: integrate MultiImageViewer and multi-file UX into tool page --- apps/web/src/pages/tool-page.tsx | 149 ++++++++++++------------------- 1 file changed, 59 insertions(+), 90 deletions(-) diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 4e442cb5..73cc04c7 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -3,10 +3,8 @@ import { useMemo, useCallback, useState } from "react"; import { TOOLS } from "@stirling-image/shared"; import { AppLayout } from "@/components/layout/app-layout"; import { Dropzone } from "@/components/common/dropzone"; -import { ImageViewer } from "@/components/common/image-viewer"; -import { BeforeAfterSlider } from "@/components/common/before-after-slider"; +import { MultiImageViewer } from "@/components/common/multi-image-viewer"; import { ReviewPanel } from "@/components/common/review-panel"; -import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; import type { PreviewTransform } from "@/components/tools/rotate-settings"; import { useFileStore } from "@/stores/file-store"; import { useMobile } from "@/hooks/use-mobile"; @@ -52,7 +50,7 @@ import { BlurFacesSettings } from "@/components/tools/blur-faces-settings"; import { EraseObjectSettings } from "@/components/tools/erase-object-settings"; import { SmartCropSettings } from "@/components/tools/smart-crop-settings"; import * as icons from "lucide-react"; -import { CheckCircle2 } from "lucide-react"; +import { CheckCircle2, Download } from "lucide-react"; const COLOR_TOOL_IDS = new Set([ "brightness-contrast", @@ -63,7 +61,6 @@ const COLOR_TOOL_IDS = new Set([ // Tools that don't need a file dropzone (they generate content or have custom UI) const NO_DROPZONE_TOOLS = new Set(["qr-generate"]); -const SIDE_BY_SIDE_TOOLS = new Set(["resize"]); const LIVE_PREVIEW_TOOLS = new Set(["rotate"]); function ToolSettingsPanel({ @@ -128,11 +125,13 @@ function FileSelectionInfo({ selectedFileName, selectedFileSize, onClear, + onAddMore, }: { files: File[]; selectedFileName: string | null; selectedFileSize: number | null; onClear: () => void; + onAddMore: () => void; }) { if (files.length === 0) { return ( @@ -144,26 +143,16 @@ function FileSelectionInfo({ return (
+
+ Files ({files.length}) + +
- - Selected: {selectedFileName ?? files[0].name} - - - {formatFileSize(selectedFileSize ?? files[0].size)} - + {selectedFileName ?? files[0].name} + {formatFileSize(selectedFileSize ?? files[0].size)}
- {files.length > 1 && ( -

- +{files.length - 1} more file{files.length > 2 ? "s" : ""} -

- )} - +
); } @@ -173,7 +162,9 @@ export function ToolPage() { const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]); const { files, + entries, setFiles, + addFiles, reset, processedUrl, originalBlobUrl, @@ -182,6 +173,8 @@ export function ToolPage() { selectedFileName, selectedFileSize, undoProcessing, + batchZipBlob, + batchZipFilename, } = useFileStore(); const isMobile = useMobile(); const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true); @@ -199,6 +192,28 @@ export function ToolPage() { undoProcessing(); }, [undoProcessing]); + const handleAddMore = useCallback(() => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.accept = "image/*"; + input.onchange = (e) => { + const newFiles = Array.from((e.target as HTMLInputElement).files || []); + if (newFiles.length > 0) addFiles(newFiles); + }; + input.click(); + }, [addFiles]); + + const handleDownloadAll = useCallback(() => { + if (!batchZipBlob) return; + const url = URL.createObjectURL(batchZipBlob); + const a = document.createElement("a"); + a.href = url; + a.download = batchZipFilename ?? "processed-images.zip"; + a.click(); + URL.revokeObjectURL(url); + }, [batchZipBlob, batchZipFilename]); + if (!tool) { return ( @@ -264,6 +279,7 @@ export function ToolPage() { selectedFileName={selectedFileName} selectedFileSize={selectedFileSize} onClear={reset} + onAddMore={handleAddMore} />
)} @@ -295,45 +311,14 @@ export function ToolPage() {
)} - {/* Main area: Dropzone / Image Viewer / Before-After */} + {/* Main area: Dropzone / MultiImageViewer */}
{isNoDropzone ? (

Configure settings and generate.

- ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl ? ( - - ) : hasFile && originalBlobUrl ? ( - + ) : hasFile ? ( + ) : (
)} @@ -403,47 +389,30 @@ export function ToolPage() { currentToolId={tool.id} /> )} + + {/* Batch download */} + {entries.length > 1 && hasProcessed && batchZipBlob && ( +
+
+ +
+ )}
- {/* Main area: Dropzone / Image Viewer / Before-After */} + {/* Main area: Dropzone / MultiImageViewer */}
{isNoDropzone ? (

Configure settings and generate.

- ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl ? ( - - ) : hasFile && originalBlobUrl ? ( - + ) : hasFile ? ( + ) : ( Date: Mon, 23 Mar 2026 14:09:09 +0800 Subject: [PATCH 18/21] feat: add processAllFiles batch method to tool processor hook --- apps/web/src/hooks/use-tool-processor.ts | 117 +++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index 86e51eb1..b196249d 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -205,8 +205,125 @@ export function useToolProcessor(toolId: string) { [toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId], ); + const processAllFiles = useCallback( + async (files: File[], settings: Record) => { + if (files.length === 0) { + setError("No files selected"); + return; + } + if (files.length === 1) { + processFiles(files, settings); + return; + } + + const { updateEntry, setBatchZip } = useFileStore.getState(); + + setError(null); + setProcessing(true); + setProgress({ phase: "uploading", percent: 0, elapsed: 0 }); + + const startTime = Date.now(); + elapsedRef.current = setInterval(() => { + setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) })); + }, 1000); + + const clientJobId = crypto.randomUUID(); + + // Open SSE before upload for real-time progress + try { + const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); + eventSourceRef.current = es; + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === "batch") { + const pct = data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15; + setProgress((prev) => ({ + ...prev, + phase: "processing", + percent: pct, + stage: data.currentFile + ? `Processing ${data.currentFile} (${data.completedFiles}/${data.totalFiles})` + : `Processing ${data.completedFiles}/${data.totalFiles}`, + })); + } + } catch { /* ignore malformed SSE */ } + }; + es.onerror = () => { es.close(); eventSourceRef.current = null; }; + } catch { /* SSE failed, proceed without */ } + + const formData = new FormData(); + for (const file of files) formData.append("file", file); + formData.append("settings", JSON.stringify(settings)); + formData.append("clientJobId", clientJobId); + + try { + const token = getToken(); + const response = await fetch(`/api/v1/tools/${toolId}/batch`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } + + if (!response.ok) { + const text = await response.text(); + let errorMsg: string; + try { + const body = JSON.parse(text); + errorMsg = body.error || body.details || `Batch processing failed: ${response.status}`; + } catch { errorMsg = `Batch processing failed: ${response.status}`; } + setError(errorMsg); + setProcessing(false); + setProgress(IDLE_PROGRESS); + return; + } + + const zipBlob = await response.blob(); + setBatchZip(zipBlob, `batch-${toolId}.zip`); + + // Extract files from ZIP using fflate + const { unzipSync } = await import("fflate"); + const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer()); + const extracted = unzipSync(zipBuffer); + + const fileOrder = response.headers.get("X-File-Order")?.split(",") ?? []; + const entries = useFileStore.getState().entries; + const extractedNames = Object.keys(extracted); + + for (let i = 0; i < entries.length; i++) { + let zipName: string | undefined; + if (fileOrder[i] && extracted[fileOrder[i]]) { + zipName = fileOrder[i]; + } else { + zipName = extractedNames.find((n) => n === entries[i].file.name) ?? extractedNames[i]; + } + if (zipName && extracted[zipName]) { + const blob = new Blob([extracted[zipName]]); + updateEntry(i, { processedUrl: URL.createObjectURL(blob), processedSize: blob.size, status: "completed" }); + } else { + updateEntry(i, { status: "failed", error: "File not found in batch results" }); + } + } + + setProcessing(false); + setProgress(IDLE_PROGRESS); + } catch (err) { + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } + setError(err instanceof Error ? err.message : "Batch processing failed"); + setProcessing(false); + setProgress(IDLE_PROGRESS); + } + }, + [toolId, processFiles, setProcessing, setError], + ); + return { processFiles, + processAllFiles, processing, error, downloadUrl: processedUrl, From 9e2c28f19124fca4bc4f55c0997e2314170ae478 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 14:11:08 +0800 Subject: [PATCH 19/21] feat: wire up batch processing across tool settings components --- apps/web/src/components/tools/border-settings.tsx | 11 ++++++++--- apps/web/src/components/tools/color-settings.tsx | 13 +++++++++---- apps/web/src/components/tools/compress-settings.tsx | 10 +++++++--- apps/web/src/components/tools/convert-settings.tsx | 10 +++++++--- apps/web/src/components/tools/crop-settings.tsx | 13 +++++++++---- .../src/components/tools/replace-color-settings.tsx | 11 ++++++++--- apps/web/src/components/tools/resize-settings.tsx | 10 +++++++--- apps/web/src/components/tools/rotate-settings.tsx | 13 +++++++++---- .../src/components/tools/text-overlay-settings.tsx | 11 ++++++++--- .../components/tools/watermark-text-settings.tsx | 11 ++++++++--- 10 files changed, 80 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/tools/border-settings.tsx b/apps/web/src/components/tools/border-settings.tsx index 65ee9ae3..e434a644 100644 --- a/apps/web/src/components/tools/border-settings.tsx +++ b/apps/web/src/components/tools/border-settings.tsx @@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card"; export function BorderSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("border"); const [borderWidth, setBorderWidth] = useState(10); @@ -16,7 +16,12 @@ export function BorderSettings() { const [shadowBlur, setShadowBlur] = useState(0); const handleProcess = () => { - processFiles(files, { borderWidth, borderColor, cornerRadius, padding, shadowBlur }); + const settings = { borderWidth, borderColor, cornerRadius, padding, shadowBlur }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -84,7 +89,7 @@ export function BorderSettings() { disabled={!hasFile || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Add Border + {files.length > 1 ? `Apply Border (${files.length} files)` : "Apply Border"} )} diff --git a/apps/web/src/components/tools/color-settings.tsx b/apps/web/src/components/tools/color-settings.tsx index 170df7d4..ccf771fc 100644 --- a/apps/web/src/components/tools/color-settings.tsx +++ b/apps/web/src/components/tools/color-settings.tsx @@ -14,7 +14,7 @@ interface ColorSettingsProps { export function ColorSettings({ toolId }: ColorSettingsProps) { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor(toolId); const [tab, setTab] = useState(() => { @@ -37,7 +37,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) { const [effect, setEffect] = useState("none"); const handleProcess = () => { - processFiles(files, { + const settings = { brightness, contrast, saturation, @@ -45,7 +45,12 @@ export function ColorSettings({ toolId }: ColorSettingsProps) { green, blue, effect, - }); + }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -215,7 +220,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) { disabled={!hasFile || !hasChanges || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Apply + {files.length > 1 ? `Apply (${files.length} files)` : "Apply"} )} diff --git a/apps/web/src/components/tools/compress-settings.tsx b/apps/web/src/components/tools/compress-settings.tsx index f2f4f31a..460a0b67 100644 --- a/apps/web/src/components/tools/compress-settings.tsx +++ b/apps/web/src/components/tools/compress-settings.tsx @@ -8,7 +8,7 @@ type CompressMode = "quality" | "targetSize"; export function CompressSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("compress"); const [mode, setMode] = useState("quality"); @@ -22,7 +22,11 @@ export function CompressSettings() { } else { settings.targetSizeKb = Number(targetSizeKb); } - processFiles(files, settings); + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -124,7 +128,7 @@ export function CompressSettings() { disabled={!hasFile || !canProcess || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Compress + {files.length > 1 ? `Compress (${files.length} files)` : "Compress"} )} diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx index 86c744b0..1d7728f8 100644 --- a/apps/web/src/components/tools/convert-settings.tsx +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -9,7 +9,7 @@ const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]); export function ConvertSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("convert"); const [format, setFormat] = useState("png"); @@ -28,7 +28,11 @@ export function ConvertSettings() { if (isLossy) { settings.quality = quality; } - processFiles(files, settings); + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -118,7 +122,7 @@ export function ConvertSettings() { disabled={!hasFile || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Convert + {files.length > 1 ? `Convert (${files.length} files)` : "Convert"} )} diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx index 65a5ff03..88e1b7a8 100644 --- a/apps/web/src/components/tools/crop-settings.tsx +++ b/apps/web/src/components/tools/crop-settings.tsx @@ -14,7 +14,7 @@ const ASPECT_PRESETS = [ export function CropSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("crop"); const [left, setLeft] = useState("0"); @@ -31,12 +31,17 @@ export function CropSettings() { }; const handleProcess = () => { - processFiles(files, { + const settings = { left: Number(left), top: Number(top), width: Number(width), height: Number(height), - }); + }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -141,7 +146,7 @@ export function CropSettings() { disabled={!hasFile || !hasSize || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Crop + {files.length > 1 ? `Crop (${files.length} files)` : "Crop"} )} diff --git a/apps/web/src/components/tools/replace-color-settings.tsx b/apps/web/src/components/tools/replace-color-settings.tsx index dec3bbb1..eece1db8 100644 --- a/apps/web/src/components/tools/replace-color-settings.tsx +++ b/apps/web/src/components/tools/replace-color-settings.tsx @@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card"; export function ReplaceColorSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("replace-color"); const [sourceColor, setSourceColor] = useState("#FF0000"); @@ -15,7 +15,12 @@ export function ReplaceColorSettings() { const [tolerance, setTolerance] = useState(30); const handleProcess = () => { - processFiles(files, { sourceColor, targetColor, makeTransparent, tolerance }); + const settings = { sourceColor, targetColor, makeTransparent, tolerance }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -81,7 +86,7 @@ export function ReplaceColorSettings() { disabled={!hasFile || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Replace Color + {files.length > 1 ? `Replace Color (${files.length} files)` : "Replace Color"} )} diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx index a70aa4ae..9074e6d7 100644 --- a/apps/web/src/components/tools/resize-settings.tsx +++ b/apps/web/src/components/tools/resize-settings.tsx @@ -19,7 +19,7 @@ const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))]; export function ResizeSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("resize"); const [tab, setTab] = useState("presets"); @@ -56,7 +56,11 @@ export function ResizeSettings() { settings.withoutEnlargement = withoutEnlargement; } - processFiles(files, settings); + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -252,7 +256,7 @@ export function ResizeSettings() { disabled={!canProcess} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Resize + {files.length > 1 ? `Resize (${files.length} files)` : "Resize"} )} diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx index 9a23b734..ea709f53 100644 --- a/apps/web/src/components/tools/rotate-settings.tsx +++ b/apps/web/src/components/tools/rotate-settings.tsx @@ -22,7 +22,7 @@ interface RotateSettingsProps { export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("rotate"); const [angle, setAngle] = useState(0); @@ -38,11 +38,16 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { const rotateRight = () => setAngle((a) => (a + 90) % 360); const handleProcess = () => { - processFiles(files, { + const settings = { angle, horizontal: flipH, vertical: flipV, - }); + }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -144,7 +149,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { disabled={!hasFile || !hasChanges || processing} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Apply + {files.length > 1 ? `Apply (${files.length} files)` : "Apply"} )} diff --git a/apps/web/src/components/tools/text-overlay-settings.tsx b/apps/web/src/components/tools/text-overlay-settings.tsx index 2bf072dc..7931a542 100644 --- a/apps/web/src/components/tools/text-overlay-settings.tsx +++ b/apps/web/src/components/tools/text-overlay-settings.tsx @@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card"; export function TextOverlaySettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("text-overlay"); const [text, setText] = useState("Your Text Here"); @@ -18,7 +18,12 @@ export function TextOverlaySettings() { const [shadow, setShadow] = useState(true); const handleProcess = () => { - processFiles(files, { text, fontSize, color, position, backgroundBox, backgroundColor, shadow }); + const settings = { text, fontSize, color, position, backgroundBox, backgroundColor, shadow }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -102,7 +107,7 @@ export function TextOverlaySettings() { disabled={!hasFile || processing || !text} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Add Text + {files.length > 1 ? `Apply Overlay (${files.length} files)` : "Apply Overlay"} )} diff --git a/apps/web/src/components/tools/watermark-text-settings.tsx b/apps/web/src/components/tools/watermark-text-settings.tsx index 7311079c..cfdef5ab 100644 --- a/apps/web/src/components/tools/watermark-text-settings.tsx +++ b/apps/web/src/components/tools/watermark-text-settings.tsx @@ -8,7 +8,7 @@ type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-ri export function WatermarkTextSettings() { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("watermark-text"); const [text, setText] = useState("Sample Watermark"); @@ -19,7 +19,12 @@ export function WatermarkTextSettings() { const [rotation, setRotation] = useState(0); const handleProcess = () => { - processFiles(files, { text, fontSize, color, opacity, position, rotation }); + const settings = { text, fontSize, color, opacity, position, rotation }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } }; const hasFile = files.length > 0; @@ -106,7 +111,7 @@ export function WatermarkTextSettings() { disabled={!hasFile || processing || !text} className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" > - Add Watermark + {files.length > 1 ? `Apply Watermark (${files.length} files)` : "Apply Watermark"} )} From 474b4a941e1be43887beac8d2ec57eb703cba260 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 14:11:49 +0800 Subject: [PATCH 20/21] feat: multi-file metadata display with per-file caching --- .../tools/strip-metadata-settings.tsx | 157 +++++++++++++++++- 1 file changed, 154 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx index 125c414e..caf54eea 100644 --- a/apps/web/src/components/tools/strip-metadata-settings.tsx +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -1,11 +1,25 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useFileStore } from "@/stores/file-store"; import { useToolProcessor } from "@/hooks/use-tool-processor"; -import { Download } from "lucide-react"; +import { Download, Loader2, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card"; +function getToken(): string { + return localStorage.getItem("stirling-token") || ""; +} + +interface MetadataResult { + filename: string; + fileSize: number; + exif?: Record | null; + exifError?: string; + gps?: Record | null; + icc?: Record | null; + xmp?: Record | null; +} + export function StripMetadataSettings() { - const { files } = useFileStore(); + const { entries, selectedIndex, files } = useFileStore(); const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = useToolProcessor("strip-metadata"); @@ -15,6 +29,74 @@ export function StripMetadataSettings() { const [stripIcc, setStripIcc] = useState(false); const [stripXmp, setStripXmp] = useState(false); + // Metadata inspection state + const [metadataCache, setMetadataCache] = useState>(new Map()); + const [metadata, setMetadata] = useState(null); + const [inspecting, setInspecting] = useState(false); + const [inspectError, setInspectError] = useState(null); + + // Collapsible sections + const [expandedSections, setExpandedSections] = useState>(new Set()); + + const currentFile = entries[selectedIndex]?.file ?? null; + const fileKey = currentFile ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` : null; + + // Auto-fetch metadata for the selected file + useEffect(() => { + if (!currentFile || !fileKey) { + setMetadata(null); + setInspectError(null); + return; + } + + // Check cache first + const cached = metadataCache.get(fileKey); + if (cached) { + setMetadata(cached); + return; + } + + const controller = new AbortController(); + (async () => { + setInspecting(true); + setInspectError(null); + setMetadata(null); + try { + const formData = new FormData(); + formData.append("file", currentFile); + const res = await fetch("/api/v1/tools/strip-metadata/inspect", { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + body: formData, + signal: controller.signal, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Failed: ${res.status}`); + } + const data: MetadataResult = await res.json(); + setMetadata(data); + setMetadataCache((prev) => new Map(prev).set(fileKey!, data)); + } catch (err) { + if ((err as Error).name === "AbortError") return; + setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata"); + } finally { + setInspecting(false); + } + })(); + + return () => controller.abort(); + }, [currentFile, fileKey]); + + const toggleSection = (section: string) => { + setExpandedSections((prev) => { + const next = new Set(prev); + if (next.has(section)) next.delete(section); + else next.add(section); + return next; + }); + }; + const handleStripAllChange = (checked: boolean) => { setStripAll(checked); if (checked) { @@ -36,8 +118,77 @@ export function StripMetadataSettings() { if (hasFile && !processing) handleProcess(); }; + const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0; + + const renderMetadataSection = (title: string, key: string, data: Record | null | undefined) => { + if (!data || Object.keys(data).length === 0) return null; + const expanded = expandedSections.has(key); + return ( +
+ + {expanded && ( +
+ {Object.entries(data).map(([k, v]) => ( +
+ {k}: + {String(v)} +
+ ))} +
+ )} +
+ ); + }; + return ( + {/* Metadata inspection */} + {inspecting && ( +
+ + Inspecting metadata... +
+ )} + + {inspectError &&

{inspectError}

} + + {metadata && ( +
+ + + {hasGps && ( +
+ +

+ This image contains GPS location data. Consider stripping it for privacy. +

+
+ )} + + {renderMetadataSection("EXIF", "exif", metadata.exif)} + {metadata.exifError && ( +

EXIF: {metadata.exifError}

+ )} + {renderMetadataSection("GPS", "gps", metadata.gps)} + {renderMetadataSection("ICC Profile", "icc", metadata.icc)} + {renderMetadataSection("XMP", "xmp", metadata.xmp)} + + {!metadata.exif && !metadata.gps && !metadata.icc && !metadata.xmp && !metadata.exifError && ( +

No metadata found in this file.

+ )} +
+ )} + +
+ {/* Strip All */}