From fdaf5656b2cdb8364aae6e6ecb012450c21cf495 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 23 Mar 2026 12:51:19 +0800 Subject: [PATCH] docs: add multi-image UX implementation plan 12-task plan covering file store rewrite, MultiImageViewer with filmstrip, batch processing with fflate ZIP extraction, multi-file metadata display, and Docker build verification. --- .../plans/2026-03-23-multi-image-ux.md | 1500 +++++++++++++++++ 1 file changed, 1500 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-23-multi-image-ux.md diff --git a/docs/superpowers/plans/2026-03-23-multi-image-ux.md b/docs/superpowers/plans/2026-03-23-multi-image-ux.md new file mode 100644 index 00000000..6102701f --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-multi-image-ux.md @@ -0,0 +1,1500 @@ +# Multi-Image 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:** Overhaul multi-image upload UX with filmstrip navigation, batch processing, per-file results with ZIP download, and rich metadata display for strip-metadata. + +**Architecture:** Evolve file-store from single-file to per-entry tracking (`FileEntry[]` + `selectedIndex`). Wrap existing `ImageViewer` in a new `MultiImageViewer` with bottom filmstrip + arrow navigation. Add `processAllFiles` to `useToolProcessor` using fetch + fflate for ZIP extraction. Backend: add `clientJobId` support to batch route. + +**Tech Stack:** React 19, Zustand 5, Vite, Fastify, Sharp, fflate (ZIP), Vitest, Tailwind 4 + +**Spec:** `docs/superpowers/specs/2026-03-23-multi-image-ux-design.md` + +--- + +## File Structure + +### New files: +| File | Responsibility | +|------|---------------| +| `apps/web/src/components/common/multi-image-viewer.tsx` | Wrapper: arrows, counter, filmstrip, delegates to ImageViewer/BeforeAfterSlider | +| `apps/web/src/components/common/thumbnail-strip.tsx` | Horizontal filmstrip with thumbnails, selection highlight, status badges | + +### Modified files: +| File | Changes | +|------|---------| +| `apps/web/src/stores/file-store.ts` | Replace with FileEntry[] model, selectedIndex, batch state, navigation actions | +| `apps/web/src/hooks/use-tool-processor.ts` | Add `processAllFiles` using fetch + fflate ZIP extraction | +| `apps/web/src/pages/tool-page.tsx` | Use MultiImageViewer, update FileSelectionInfo, batch buttons, download section | +| `apps/web/src/components/tools/strip-metadata-settings.tsx` | Multi-file metadata: fetch per selected entry, cache results | +| `apps/api/src/routes/batch.ts` | Accept `clientJobId` from multipart, add `X-File-Order` header | +| `apps/web/package.json` | Add `fflate` dependency | +| `tests/unit/web/stores.test.ts` | Rewrite FileStore tests for new interface | + +--- + +## Task 1: Install fflate dependency + +**Files:** +- Modify: `apps/web/package.json` + +- [ ] **Step 1: Install fflate** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image +pnpm add fflate --filter @stirling-image/web +``` + +- [ ] **Step 2: Verify installation** + +```bash +pnpm ls fflate --filter @stirling-image/web +``` + +Expected: fflate version listed. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/package.json pnpm-lock.yaml +git commit -m "chore: add fflate for client-side ZIP extraction" +``` + +--- + +## Task 2: Rewrite file-store with FileEntry model + +**Files:** +- Modify: `apps/web/src/stores/file-store.ts` +- Modify: `tests/unit/web/stores.test.ts` + +The store is the foundation — everything else depends on it. The new interface replaces the flat state with a `FileEntry[]` array and `selectedIndex`. + +- [ ] **Step 1: Write failing tests for new store interface** + +Replace the `FileStore` describe block in `tests/unit/web/stores.test.ts`. Keep the existing test infrastructure (mocks, helpers, API lib tests). The new tests cover: + +```typescript +// In tests/unit/web/stores.test.ts, replace the FileStore describe block: + +describe("FileStore", () => { + beforeEach(() => { + useFileStore.getState().reset(); + vi.clearAllMocks(); + createObjectURL.mockClear(); + revokeObjectURL.mockClear(); + }); + + // -- Initial state + it("has correct initial state", () => { + const s = useFileStore.getState(); + 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(); + }); + + // -- setFiles + it("setFiles creates FileEntry for each file with blob URLs", () => { + createObjectURL + .mockReturnValueOnce("blob:url-1") + .mockReturnValueOnce("blob:url-2"); + + const f1 = makeFile("a.png", 100); + const f2 = makeFile("b.png", 200); + useFileStore.getState().setFiles([f1, f2]); + + const s = useFileStore.getState(); + 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[1].file).toBe(f2); + expect(s.entries[1].blobUrl).toBe("blob:url-2"); + expect(s.selectedIndex).toBe(0); + expect(createObjectURL).toHaveBeenCalledTimes(2); + }); + + it("setFiles revokes all previous blob URLs", () => { + createObjectURL + .mockReturnValueOnce("blob:old-1") + .mockReturnValueOnce("blob:old-2") + .mockReturnValueOnce("blob:new-1"); + + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + revokeObjectURL.mockClear(); + + useFileStore.getState().setFiles([makeFile("c.png")]); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:old-1"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:old-2"); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + }); + + it("setFiles with empty array clears entries", () => { + createObjectURL.mockReturnValueOnce("blob:x"); + useFileStore.getState().setFiles([makeFile("x.png")]); + revokeObjectURL.mockClear(); + + useFileStore.getState().setFiles([]); + expect(useFileStore.getState().entries).toEqual([]); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:x"); + }); + + it("setFiles resets selectedIndex to 0", () => { + createObjectURL.mockReturnValue("blob:x"); + 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"); + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png")]); + expect(useFileStore.getState().error).toBeNull(); + }); + + // -- addFiles + it("addFiles appends to existing entries", () => { + createObjectURL + .mockReturnValueOnce("blob:1") + .mockReturnValueOnce("blob:2"); + + useFileStore.getState().setFiles([makeFile("a.png", 100)]); + useFileStore.getState().addFiles([makeFile("b.png", 200)]); + + const s = useFileStore.getState(); + expect(s.entries).toHaveLength(2); + expect(s.entries[0].file.name).toBe("a.png"); + expect(s.entries[1].file.name).toBe("b.png"); + }); + + it("addFiles does not change selectedIndex", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().setSelectedIndex(0); + useFileStore.getState().addFiles([makeFile("b.png")]); + expect(useFileStore.getState().selectedIndex).toBe(0); + }); + + // -- removeFile + it("removeFile removes entry and revokes its blob URL", () => { + createObjectURL + .mockReturnValueOnce("blob:1") + .mockReturnValueOnce("blob:2"); + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + 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("blob:1"); + }); + + it("removeFile adjusts selectedIndex if needed", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png"), makeFile("c.png")]); + useFileStore.getState().setSelectedIndex(2); + useFileStore.getState().removeFile(2); + expect(useFileStore.getState().selectedIndex).toBe(1); + }); + + // -- Navigation + it("navigateNext increments selectedIndex", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().navigateNext(); + expect(useFileStore.getState().selectedIndex).toBe(1); + }); + + it("navigateNext does not go past last entry", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().navigateNext(); + useFileStore.getState().navigateNext(); + expect(useFileStore.getState().selectedIndex).toBe(1); + }); + + it("navigatePrev decrements selectedIndex", () => { + createObjectURL.mockReturnValue("blob:x"); + 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", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().navigatePrev(); + expect(useFileStore.getState().selectedIndex).toBe(0); + }); + + // -- updateEntry + it("updateEntry updates specific fields of an entry", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png", 1000)]); + useFileStore.getState().updateEntry(0, { + processedUrl: "blob:processed", + processedSize: 800, + status: "completed", + }); + + const entry = useFileStore.getState().entries[0]; + expect(entry.processedUrl).toBe("blob:processed"); + expect(entry.processedSize).toBe(800); + expect(entry.status).toBe("completed"); + expect(entry.file.name).toBe("a.png"); // unchanged + }); + + it("updateEntry with out-of-bounds index is a no-op", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().updateEntry(5, { status: "completed" }); + expect(useFileStore.getState().entries[0].status).toBe("pending"); + }); + + // -- setBatchZip + it("setBatchZip stores blob and filename", () => { + const blob = new Blob(["zip-data"]); + useFileStore.getState().setBatchZip(blob, "batch-results.zip"); + expect(useFileStore.getState().batchZipBlob).toBe(blob); + expect(useFileStore.getState().batchZipFilename).toBe("batch-results.zip"); + }); + + // -- undoProcessing + it("undoProcessing resets all entries to pending and clears batch zip", () => { + createObjectURL + .mockReturnValueOnce("blob:orig-1") + .mockReturnValueOnce("blob:orig-2"); + + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().updateEntry(0, { + processedUrl: "blob:proc-1", + processedSize: 500, + status: "completed", + }); + useFileStore.getState().updateEntry(1, { + processedUrl: "blob:proc-2", + processedSize: 600, + status: "completed", + }); + useFileStore.getState().setBatchZip(new Blob(), "test.zip"); + revokeObjectURL.mockClear(); + + useFileStore.getState().undoProcessing(); + + const s = useFileStore.getState(); + 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(); + expect(s.batchZipBlob).toBeNull(); + expect(s.batchZipFilename).toBeNull(); + // Should revoke processed blob URLs + expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-1"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-2"); + // Should NOT revoke original blob URLs + expect(revokeObjectURL).not.toHaveBeenCalledWith("blob:orig-1"); + expect(revokeObjectURL).not.toHaveBeenCalledWith("blob:orig-2"); + }); + + it("undoProcessing keeps files and selectedIndex", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().setSelectedIndex(1); + useFileStore.getState().undoProcessing(); + expect(useFileStore.getState().entries).toHaveLength(2); + expect(useFileStore.getState().selectedIndex).toBe(1); + }); + + // -- reset + it("reset clears everything and revokes all blob URLs", () => { + createObjectURL + .mockReturnValueOnce("blob:orig") + .mockReturnValueOnce("blob:orig-2"); + + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().updateEntry(0, { processedUrl: "blob:proc" }); + revokeObjectURL.mockClear(); + + useFileStore.getState().reset(); + + expect(revokeObjectURL).toHaveBeenCalledWith("blob:orig"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:orig-2"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc"); + + const s = useFileStore.getState(); + expect(s.entries).toEqual([]); + expect(s.selectedIndex).toBe(0); + expect(s.batchZipBlob).toBeNull(); + expect(s.processing).toBe(false); + expect(s.error).toBeNull(); + }); + + // -- Backward compat helpers + it("exposes files getter that returns File[] from entries", () => { + createObjectURL.mockReturnValue("blob:x"); + const f1 = makeFile("a.png"); + const f2 = makeFile("b.png"); + useFileStore.getState().setFiles([f1, f2]); + expect(useFileStore.getState().files).toEqual([f1, f2]); + }); + + it("exposes currentEntry getter", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + useFileStore.getState().setSelectedIndex(1); + expect(useFileStore.getState().currentEntry?.file.name).toBe("b.png"); + }); + + it("currentEntry is null when no entries", () => { + expect(useFileStore.getState().currentEntry).toBeNull(); + }); + + it("exposes selectedFileName from current entry", () => { + createObjectURL.mockReturnValue("blob:x"); + useFileStore.getState().setFiles([makeFile("photo.png", 2048)]); + expect(useFileStore.getState().selectedFileName).toBe("photo.png"); + expect(useFileStore.getState().selectedFileSize).toBe(2048); + }); + + it("exposes originalBlobUrl from current entry", () => { + createObjectURL.mockReturnValueOnce("blob:first").mockReturnValueOnce("blob:second"); + useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); + expect(useFileStore.getState().originalBlobUrl).toBe("blob:first"); + useFileStore.getState().setSelectedIndex(1); + expect(useFileStore.getState().originalBlobUrl).toBe("blob:second"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm test:unit tests/unit/web/stores.test.ts +``` + +Expected: FileStore tests fail (old interface doesn't match new tests). + +- [ ] **Step 3: Implement the new file store** + +Rewrite `apps/web/src/stores/file-store.ts`: + +```typescript +import { create } from "zustand"; + +export interface FileEntry { + file: File; + blobUrl: string; + /** Server download URL (single-file) or client blob URL (batch/ZIP). */ + processedUrl: string | null; + processedSize: number | null; + originalSize: number; + status: "pending" | "processing" | "completed" | "failed"; + error: string | null; +} + +interface FileState { + entries: FileEntry[]; + selectedIndex: number; + batchZipBlob: Blob | null; + batchZipFilename: string | null; + processing: boolean; + error: string | null; + + // Derived (computed as getters) + readonly files: File[]; + readonly currentEntry: FileEntry | null; + 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; + 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; + setProcessing: (v: boolean) => void; + setError: (e: string | null) => void; + setJobId: (id: string) => void; + setProcessedUrl: (url: string | null) => void; + setSizes: (original: number, processed: number) => void; + undoProcessing: () => void; + reset: () => void; +} + +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[]) { + for (const entry of entries) { + URL.revokeObjectURL(entry.blobUrl); + if (entry.processedUrl?.startsWith("blob:")) { + URL.revokeObjectURL(entry.processedUrl); + } + } +} + +export const useFileStore = create((set, get) => ({ + entries: [], + selectedIndex: 0, + batchZipBlob: null, + batchZipFilename: null, + processing: false, + error: null, + + // Derived getters — these are accessed via getState() or selectors + get files() { + return get().entries.map((e) => e.file); + }, + get currentEntry() { + const { entries, selectedIndex } = get(); + return entries[selectedIndex] ?? null; + }, + get hasFiles() { + return get().entries.length > 0; + }, + get allProcessed() { + const { entries } = get(); + return entries.length > 0 && entries.every((e) => e.status === "completed"); + }, + get selectedFileName() { + return get().currentEntry?.file.name ?? null; + }, + get selectedFileSize() { + return get().currentEntry?.file.size ?? null; + }, + get originalBlobUrl() { + return get().currentEntry?.blobUrl ?? null; + }, + get processedUrl() { + return get().currentEntry?.processedUrl ?? null; + }, + get originalSize() { + return get().currentEntry?.originalSize ?? null; + }, + get processedSize() { + return get().currentEntry?.processedSize ?? null; + }, + + setFiles: (files) => { + revokeEntries(get().entries); + set({ + entries: files.map(createEntry), + selectedIndex: 0, + error: null, + batchZipBlob: null, + batchZipFilename: null, + }); + }, + + addFiles: (files) => { + const newEntries = files.map(createEntry); + set((state) => ({ + entries: [...state.entries, ...newEntries], + })); + }, + + removeFile: (index) => { + const { entries, selectedIndex } = get(); + if (index < 0 || index >= entries.length) return; + const entry = entries[index]; + URL.revokeObjectURL(entry.blobUrl); + if (entry.processedUrl?.startsWith("blob:")) { + URL.revokeObjectURL(entry.processedUrl); + } + const newEntries = entries.filter((_, i) => i !== index); + const newIndex = selectedIndex >= newEntries.length + ? Math.max(0, newEntries.length - 1) + : selectedIndex; + set({ entries: newEntries, selectedIndex: newIndex }); + }, + + setSelectedIndex: (index) => { + const { entries } = get(); + if (index >= 0 && index < entries.length) { + set({ selectedIndex: index }); + } + }, + + navigateNext: () => { + const { selectedIndex, entries } = get(); + if (selectedIndex < entries.length - 1) { + set({ selectedIndex: selectedIndex + 1 }); + } + }, + + navigatePrev: () => { + const { selectedIndex } = get(); + if (selectedIndex > 0) { + set({ selectedIndex: selectedIndex - 1 }); + } + }, + + updateEntry: (index, updates) => { + const { entries } = get(); + if (index < 0 || index >= entries.length) return; + const newEntries = [...entries]; + newEntries[index] = { ...newEntries[index], ...updates }; + set({ entries: newEntries }); + }, + + setBatchZip: (blob, filename) => { + set({ batchZipBlob: blob, batchZipFilename: filename }); + }, + + setProcessing: (v) => set({ processing: v }), + setError: (e) => set({ error: e, processing: false }), + setJobId: (_id) => { + // Kept for backward compat — batch doesn't need a single jobId + }, + setProcessedUrl: (url) => { + // Backward compat: update current entry's processedUrl + const { selectedIndex, entries } = get(); + if (entries.length === 0) return; + const newEntries = [...entries]; + newEntries[selectedIndex] = { ...newEntries[selectedIndex], processedUrl: url, status: url ? "completed" : "pending" }; + set({ entries: newEntries }); + }, + setSizes: (original, processed) => { + const { selectedIndex, entries } = get(); + if (entries.length === 0) return; + const newEntries = [...entries]; + newEntries[selectedIndex] = { ...newEntries[selectedIndex], originalSize: original, processedSize: processed }; + set({ entries: newEntries }); + }, + + undoProcessing: () => { + const { entries } = get(); + for (const entry of entries) { + if (entry.processedUrl?.startsWith("blob:")) { + URL.revokeObjectURL(entry.processedUrl); + } + } + set({ + entries: entries.map((e) => ({ + ...e, + processedUrl: null, + processedSize: null, + status: "pending" as const, + error: null, + })), + batchZipBlob: null, + batchZipFilename: null, + error: null, + }); + }, + + reset: () => { + revokeEntries(get().entries); + set({ + entries: [], + selectedIndex: 0, + batchZipBlob: null, + batchZipFilename: null, + processing: false, + error: null, + }); + }, +})); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pnpm test:unit tests/unit/web/stores.test.ts +``` + +Expected: All FileStore tests pass. API lib tests should still pass unchanged. + +- [ ] **Step 5: Run typecheck to catch compile errors in consumers** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck +``` + +Expected: Type errors in tool-page.tsx, strip-metadata-settings.tsx, and other files that reference old store fields (`jobId`, `originalBlobUrl` as direct state). These will be fixed in subsequent tasks. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/stores/file-store.ts tests/unit/web/stores.test.ts +git commit -m "feat: rewrite file-store with FileEntry model for multi-image support" +``` + +--- + +## Task 3: Build ThumbnailStrip component + +**Files:** +- Create: `apps/web/src/components/common/thumbnail-strip.tsx` + +- [ ] **Step 1: Create ThumbnailStrip component** + +```typescript +// apps/web/src/components/common/thumbnail-strip.tsx +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 containerRef = useRef(null); + const selectedRef = useRef(null); + + // Auto-scroll to keep selected thumbnail visible + 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 ( + + ); + })} +
+ ); +} +``` + +- [ ] **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.