Files
SnapOtter/docs/superpowers/plans/2026-03-23-multi-image-ux.md
T
Siddharth Kumar Sah fdaf5656b2 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.
2026-03-23 15:14:34 +08:00

48 KiB

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

cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image
pnpm add fflate --filter @stirling-image/web
  • Step 2: Verify installation
pnpm ls fflate --filter @stirling-image/web

Expected: fflate version listed.

  • Step 3: Commit
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:

// 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
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:

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<FileEntry>) => 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<FileState>((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
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
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
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

// 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<HTMLDivElement>(null);
  const selectedRef = useRef<HTMLButtonElement>(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 (
    <div
      ref={containerRef}
      className="flex gap-1.5 px-3 py-2 overflow-x-auto border-t border-border bg-muted/30"
      style={{ scrollBehavior: "smooth" }}
    >
      {entries.map((entry, i) => {
        const isSelected = i === selectedIndex;
        const isCompleted = entry.status === "completed";
        const isFailed = entry.status === "failed";

        return (
          <button
            key={`${entry.file.name}-${i}`}
            ref={isSelected ? selectedRef : undefined}
            onClick={() => onSelect(i)}
            className={`relative shrink-0 rounded overflow-hidden transition-all ${
              isSelected
                ? "outline outline-2 outline-primary outline-offset-1"
                : "hover:outline hover:outline-1 hover:outline-border"
            }`}
            style={{ width: 52, height: 38 }}
            title={entry.file.name}
          >
            <img
              src={entry.processedUrl ?? entry.blobUrl}
              alt={entry.file.name}
              className="w-full h-full object-cover"
              draggable={false}
            />
            {isCompleted && (
              <div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
                <CheckCircle2 className="h-2.5 w-2.5 text-white" />
              </div>
            )}
            {isFailed && (
              <div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-red-500 rounded-full flex items-center justify-center">
                <XCircle className="h-2.5 w-2.5 text-white" />
              </div>
            )}
          </button>
        );
      })}
    </div>
  );
}
  • Step 2: Verify it compiles
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
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

// 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<HTMLDivElement>(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 (
    <div
      ref={containerRef}
      className="flex flex-col w-full h-full"
      onKeyDown={hasMultiple ? handleKeyDown : undefined}
      tabIndex={hasMultiple ? 0 : undefined}
    >
      {/* Main viewer with navigation arrows */}
      <div className="flex-1 relative flex items-center justify-center">
        {/* Left arrow */}
        {hasMultiple && hasPrev && (
          <button
            onClick={navigatePrev}
            className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
            aria-label="Previous image"
          >
            <ChevronLeft className="h-4 w-4" />
          </button>
        )}

        {/* Image or Before/After */}
        <div className="w-full h-full">
          {hasProcessed ? (
            <BeforeAfterSlider
              beforeSrc={currentEntry.blobUrl}
              afterSrc={currentEntry.processedUrl!}
              beforeSize={currentEntry.originalSize}
              afterSize={currentEntry.processedSize ?? undefined}
            />
          ) : (
            <ImageViewer
              src={currentEntry.blobUrl}
              filename={currentEntry.file.name}
              fileSize={currentEntry.file.size}
            />
          )}
        </div>

        {/* Right arrow */}
        {hasMultiple && hasNext && (
          <button
            onClick={navigateNext}
            className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
            aria-label="Next image"
          >
            <ChevronRight className="h-4 w-4" />
          </button>
        )}

        {/* Counter badge */}
        {hasMultiple && (
          <div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
            {selectedIndex + 1} / {entries.length}
          </div>
        )}
      </div>

      {/* Thumbnail filmstrip */}
      <ThumbnailStrip
        entries={entries}
        selectedIndex={selectedIndex}
        onSelect={setSelectedIndex}
      />
    </div>
  );
}
  • Step 2: Commit
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 <MultiImageViewer /> 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:

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:

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
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.

  • jobIdsetJobId is still available but is a no-op for batch.

  • Step 3: Commit

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:

cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck

Fix any type errors found.

  • Step 2: Commit any fixes
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<string, MetadataResult> 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.
// Key changes in the component:
const { entries, selectedIndex, files } = useFileStore();

// Cache metadata per file to avoid re-fetching
const [metadataCache, setMetadataCache] = useState<Map<string, MetadataResult>>(new Map());
const [metadata, setMetadata] = useState<MetadataResult | null>(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
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck
  • Step 3: Commit
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:

// 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):

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:

// 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:

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
pnpm test:integration

Expected: Existing tests pass (batch route changes are additive).

  • Step 3: Commit
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.

// Add to the hook, alongside processFiles:

const processAllFiles = useCallback(
  async (files: File[], settings: Record<string, unknown>) => {
    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:

return {
  processFiles,
  processAllFiles,
  processing,
  error,
  downloadUrl: processedUrl,
  originalSize,
  processedSize,
  progress,
};
  • Step 2: Verify typecheck passes
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck
  • Step 3: Commit
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:

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:

<button type="submit" disabled={!hasFile || processing} className="...">
  {files.length > 1 ? `Strip Metadata (${files.length} files)` : "Strip Metadata"}
</button>
  • 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
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image/apps/web && pnpm typecheck
  • Step 4: Commit
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

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
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
docker compose -f docker/docker-compose.yml down
  • Step 5: Final commit if any fixes were needed
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

pnpm test:unit

Expected: All tests pass.

  • Step 2: Run integration tests
pnpm test:integration

Expected: All tests pass. The batch endpoint change (clientJobId) is backward compatible.

  • Step 3: Run typecheck across all packages
pnpm typecheck

Expected: No type errors.