diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts
index 62ea134c..041014f2 100644
--- a/apps/api/src/routes/batch.ts
+++ b/apps/api/src/routes/batch.ts
@@ -42,6 +42,7 @@ export async function registerBatchRoutes(
// Parse multipart: collect all files and the settings field
const files: ParsedFile[] = [];
let settingsRaw: string | null = null;
+ let clientJobId: string | null = null;
try {
const parts = request.parts();
@@ -60,6 +61,8 @@ export async function registerBatchRoutes(
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
+ } else if (part.fieldname === "clientJobId") {
+ clientJobId = part.value as string;
}
}
} catch (err) {
@@ -102,7 +105,7 @@ export async function registerBatchRoutes(
}
// Create a job ID for progress tracking
- const jobId = randomUUID();
+ const jobId = clientJobId || randomUUID();
const progress: JobProgress = {
jobId,
@@ -120,6 +123,7 @@ export async function registerBatchRoutes(
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
+ "X-File-Order": files.map(f => f.filename).join(","),
});
// Create ZIP archive that pipes directly to the response
diff --git a/apps/web/package.json b/apps/web/package.json
index c1754444..5bb87aa5 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -12,20 +12,21 @@
},
"dependencies": {
"@stirling-image/shared": "workspace:*",
+ "clsx": "^2.1.0",
+ "fflate": "^0.8.2",
+ "lucide-react": "^0.469.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
- "zustand": "^5.0.0",
- "clsx": "^2.1.0",
"tailwind-merge": "^2.6.0",
- "lucide-react": "^0.469.0"
+ "zustand": "^5.0.0"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
- "@tailwindcss/vite": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
diff --git a/apps/web/src/components/common/multi-image-viewer.tsx b/apps/web/src/components/common/multi-image-viewer.tsx
new file mode 100644
index 00000000..6534841d
--- /dev/null
+++ b/apps/web/src/components/common/multi-image-viewer.tsx
@@ -0,0 +1,53 @@
+import { useCallback } from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { ImageViewer } from "@/components/common/image-viewer";
+import { BeforeAfterSlider } from "@/components/common/before-after-slider";
+import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
+import { useFileStore } from "@/stores/file-store";
+
+export function MultiImageViewer() {
+ const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
+ const currentEntry = entries[selectedIndex];
+ if (!currentEntry) return null;
+
+ const hasMultiple = entries.length > 1;
+ const hasPrev = selectedIndex > 0;
+ const hasNext = selectedIndex < entries.length - 1;
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === "ArrowLeft") { e.preventDefault(); navigatePrev(); }
+ else if (e.key === "ArrowRight") { e.preventDefault(); navigateNext(); }
+ }, [navigateNext, navigatePrev]);
+
+ const hasProcessed = !!currentEntry.processedUrl;
+
+ return (
+
+
+ {hasMultiple && hasPrev && (
+
+ )}
+
+ {hasProcessed ? (
+
+ ) : (
+
+ )}
+
+ {hasMultiple && hasNext && (
+
+ )}
+ {hasMultiple && (
+
+ {selectedIndex + 1} / {entries.length}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/common/side-by-side-comparison.tsx b/apps/web/src/components/common/side-by-side-comparison.tsx
index 488ed255..2e9ff719 100644
--- a/apps/web/src/components/common/side-by-side-comparison.tsx
+++ b/apps/web/src/components/common/side-by-side-comparison.tsx
@@ -27,6 +27,15 @@ export function SideBySideComparison({
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
+ const checkerboard = {
+ backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
+ linear-gradient(-45deg, #ccc 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #ccc 75%),
+ linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
+ backgroundSize: "16px 16px",
+ backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
+ };
+
return (
{/* Side-by-side images */}
@@ -36,11 +45,14 @@ export function SideBySideComparison({
Original
-
+

{
const img = e.currentTarget;
@@ -51,23 +63,26 @@ export function SideBySideComparison({
{beforeDims && (
- {beforeDims.w} × {beforeDims.h}
+ {beforeDims.w} x {beforeDims.h}
)}
{beforeSize != null &&
{formatSize(beforeSize)}
}
- {/* Processed */}
+ {/* Resized */}
- Processed
+ Resized
-
+

{
const img = e.currentTarget;
@@ -78,7 +93,7 @@ export function SideBySideComparison({
{afterDims && (
- {afterDims.w} × {afterDims.h}
+ {afterDims.w} x {afterDims.h}
)}
{afterSize != null &&
{formatSize(afterSize)}
}
diff --git a/apps/web/src/components/common/thumbnail-strip.tsx b/apps/web/src/components/common/thumbnail-strip.tsx
new file mode 100644
index 00000000..aeb5e457
--- /dev/null
+++ b/apps/web/src/components/common/thumbnail-strip.tsx
@@ -0,0 +1,57 @@
+import { useRef, useEffect } from "react";
+import { CheckCircle2, XCircle } from "lucide-react";
+import type { FileEntry } from "@/stores/file-store";
+
+interface ThumbnailStripProps {
+ entries: FileEntry[];
+ selectedIndex: number;
+ onSelect: (index: number) => void;
+}
+
+export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailStripProps) {
+ const selectedRef = useRef
(null);
+
+ useEffect(() => {
+ selectedRef.current?.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ behavior: "smooth",
+ });
+ }, [selectedIndex]);
+
+ if (entries.length <= 1) return null;
+
+ return (
+
+ {entries.map((entry, i) => {
+ const isSelected = i === selectedIndex;
+ const isCompleted = entry.status === "completed";
+ const isFailed = entry.status === "failed";
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/tools/border-settings.tsx b/apps/web/src/components/tools/border-settings.tsx
index 65ee9ae3..e434a644 100644
--- a/apps/web/src/components/tools/border-settings.tsx
+++ b/apps/web/src/components/tools/border-settings.tsx
@@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card";
export function BorderSettings() {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("border");
const [borderWidth, setBorderWidth] = useState(10);
@@ -16,7 +16,12 @@ export function BorderSettings() {
const [shadowBlur, setShadowBlur] = useState(0);
const handleProcess = () => {
- processFiles(files, { borderWidth, borderColor, cornerRadius, padding, shadowBlur });
+ const settings = { borderWidth, borderColor, cornerRadius, padding, shadowBlur };
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -84,7 +89,7 @@ export function BorderSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Add Border
+ {files.length > 1 ? `Apply Border (${files.length} files)` : "Apply Border"}
)}
diff --git a/apps/web/src/components/tools/color-settings.tsx b/apps/web/src/components/tools/color-settings.tsx
index 170df7d4..ccf771fc 100644
--- a/apps/web/src/components/tools/color-settings.tsx
+++ b/apps/web/src/components/tools/color-settings.tsx
@@ -14,7 +14,7 @@ interface ColorSettingsProps {
export function ColorSettings({ toolId }: ColorSettingsProps) {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor(toolId);
const [tab, setTab] = useState(() => {
@@ -37,7 +37,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
const [effect, setEffect] = useState("none");
const handleProcess = () => {
- processFiles(files, {
+ const settings = {
brightness,
contrast,
saturation,
@@ -45,7 +45,12 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
green,
blue,
effect,
- });
+ };
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -215,7 +220,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Apply
+ {files.length > 1 ? `Apply (${files.length} files)` : "Apply"}
)}
diff --git a/apps/web/src/components/tools/compress-settings.tsx b/apps/web/src/components/tools/compress-settings.tsx
index f2f4f31a..460a0b67 100644
--- a/apps/web/src/components/tools/compress-settings.tsx
+++ b/apps/web/src/components/tools/compress-settings.tsx
@@ -8,7 +8,7 @@ type CompressMode = "quality" | "targetSize";
export function CompressSettings() {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("compress");
const [mode, setMode] = useState("quality");
@@ -22,7 +22,11 @@ export function CompressSettings() {
} else {
settings.targetSizeKb = Number(targetSizeKb);
}
- processFiles(files, settings);
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -124,7 +128,7 @@ export function CompressSettings() {
disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Compress
+ {files.length > 1 ? `Compress (${files.length} files)` : "Compress"}
)}
diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx
index 86c744b0..1d7728f8 100644
--- a/apps/web/src/components/tools/convert-settings.tsx
+++ b/apps/web/src/components/tools/convert-settings.tsx
@@ -9,7 +9,7 @@ const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
export function ConvertSettings() {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("convert");
const [format, setFormat] = useState("png");
@@ -28,7 +28,11 @@ export function ConvertSettings() {
if (isLossy) {
settings.quality = quality;
}
- processFiles(files, settings);
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -118,7 +122,7 @@ export function ConvertSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Convert
+ {files.length > 1 ? `Convert (${files.length} files)` : "Convert"}
)}
diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx
index 65a5ff03..88e1b7a8 100644
--- a/apps/web/src/components/tools/crop-settings.tsx
+++ b/apps/web/src/components/tools/crop-settings.tsx
@@ -14,7 +14,7 @@ const ASPECT_PRESETS = [
export function CropSettings() {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("crop");
const [left, setLeft] = useState("0");
@@ -31,12 +31,17 @@ export function CropSettings() {
};
const handleProcess = () => {
- processFiles(files, {
+ const settings = {
left: Number(left),
top: Number(top),
width: Number(width),
height: Number(height),
- });
+ };
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -141,7 +146,7 @@ export function CropSettings() {
disabled={!hasFile || !hasSize || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Crop
+ {files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
)}
diff --git a/apps/web/src/components/tools/replace-color-settings.tsx b/apps/web/src/components/tools/replace-color-settings.tsx
index dec3bbb1..eece1db8 100644
--- a/apps/web/src/components/tools/replace-color-settings.tsx
+++ b/apps/web/src/components/tools/replace-color-settings.tsx
@@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card";
export function ReplaceColorSettings() {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("replace-color");
const [sourceColor, setSourceColor] = useState("#FF0000");
@@ -15,7 +15,12 @@ export function ReplaceColorSettings() {
const [tolerance, setTolerance] = useState(30);
const handleProcess = () => {
- processFiles(files, { sourceColor, targetColor, makeTransparent, tolerance });
+ const settings = { sourceColor, targetColor, makeTransparent, tolerance };
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -81,7 +86,7 @@ export function ReplaceColorSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Replace Color
+ {files.length > 1 ? `Replace Color (${files.length} files)` : "Replace Color"}
)}
diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx
index a9319048..9074e6d7 100644
--- a/apps/web/src/components/tools/resize-settings.tsx
+++ b/apps/web/src/components/tools/resize-settings.tsx
@@ -19,10 +19,10 @@ const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
export function ResizeSettings() {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("resize");
- const [tab, setTab] = useState("custom");
+ const [tab, setTab] = useState("presets");
const [selectedPreset, setSelectedPreset] = useState(null);
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
@@ -56,7 +56,11 @@ export function ResizeSettings() {
settings.withoutEnlargement = withoutEnlargement;
}
- processFiles(files, settings);
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -80,15 +84,15 @@ export function ResizeSettings() {
{/* Tab selector */}
+
-
@@ -252,7 +256,7 @@ export function ResizeSettings() {
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Resize
+ {files.length > 1 ? `Resize (${files.length} files)` : "Resize"}
)}
diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx
index ba809a03..ea709f53 100644
--- a/apps/web/src/components/tools/rotate-settings.tsx
+++ b/apps/web/src/components/tools/rotate-settings.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import {
@@ -7,7 +7,6 @@ import {
RotateCw,
FlipHorizontal,
FlipVertical,
- RotateCcw as ResetIcon,
} from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
@@ -23,7 +22,7 @@ interface RotateSettingsProps {
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
const { files } = useFileStore();
- const { processFiles, processing, error, downloadUrl, progress } =
+ const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("rotate");
const [angle, setAngle] = useState(0);
@@ -35,29 +34,20 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
onPreviewTransform?.({ rotate: angle, flipH, flipV });
}, [angle, flipH, flipV, onPreviewTransform]);
- const rotateLeft = () => setAngle((a) => {
- const next = a - 90;
- return next < -180 ? next + 360 : next;
- });
- const rotateRight = () => setAngle((a) => {
- const next = a + 90;
- return next > 180 ? next - 360 : next;
- });
-
- const setAngleClamped = useCallback((val: number) => {
- // Clamp to -180..180
- const clamped = Math.max(-180, Math.min(180, Math.round(val)));
- setAngle(clamped);
- }, []);
+ const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
+ const rotateRight = () => setAngle((a) => (a + 90) % 360);
const handleProcess = () => {
- // Convert -180..180 to 0..360 for the backend
- const backendAngle = angle < 0 ? angle + 360 : angle;
- processFiles(files, {
- angle: backendAngle,
+ const settings = {
+ angle,
horizontal: flipH,
vertical: flipV,
- });
+ };
+ if (files.length > 1) {
+ processAllFiles(files, settings);
+ } else {
+ processFiles(files, settings);
+ }
};
const hasFile = files.length > 0;
@@ -68,12 +58,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
if (hasFile && hasChanges && !processing) handleProcess();
};
- const handleReset = () => {
- setAngle(0);
- setFlipH(false);
- setFlipV(false);
- };
-
return (
- {/* Angle control */}
+ {/* Angle slider */}
-
-
- setAngleClamped(Number(e.target.value))}
- min={-180}
- max={180}
- className="w-16 px-1.5 py-0.5 rounded border border-border bg-background text-xs text-foreground text-right font-mono tabular-nums"
- />
- °
- {angle !== 0 && (
-
- )}
-
+
+
{angle} deg
setAngle(Number(e.target.value))}
className="w-full mt-1"
/>
-
- -180°
- 0°
- 180°
-
{/* Flip buttons */}
@@ -172,17 +130,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
- {/* Reset all */}
- {hasChanges && (
-
- )}
-
{/* Error */}
{error &&
{error}
}
@@ -202,7 +149,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
- Apply
+ {files.length > 1 ? `Apply (${files.length} files)` : "Apply"}
)}
diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx
index 6d0ac67b..caf54eea 100644
--- a/apps/web/src/components/tools/strip-metadata-settings.tsx
+++ b/apps/web/src/components/tools/strip-metadata-settings.tsx
@@ -1,7 +1,7 @@
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
-import { Download, ChevronDown, ChevronRight, Loader2, MapPin, AlertTriangle } from "lucide-react";
+import { Download, Loader2, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
function getToken(): string {
@@ -18,139 +18,8 @@ interface MetadataResult {
xmp?: Record
| null;
}
-/** Human-friendly labels for common EXIF keys */
-const EXIF_LABELS: Record = {
- Make: "Camera Make",
- Model: "Camera Model",
- Software: "Software",
- DateTime: "Date/Time",
- DateTimeOriginal: "Date Taken",
- DateTimeDigitized: "Date Digitized",
- ExposureTime: "Exposure Time",
- FNumber: "F-Number",
- ISOSpeedRatings: "ISO",
- FocalLength: "Focal Length",
- FocalLengthIn35mmFilm: "Focal Length (35mm)",
- ExposureBiasValue: "Exposure Bias",
- MeteringMode: "Metering Mode",
- Flash: "Flash",
- WhiteBalance: "White Balance",
- ExposureMode: "Exposure Mode",
- SceneCaptureType: "Scene Type",
- Contrast: "Contrast",
- Saturation: "Saturation",
- Sharpness: "Sharpness",
- DigitalZoomRatio: "Digital Zoom",
- ImageWidth: "Width",
- ImageLength: "Height",
- Orientation: "Orientation",
- XResolution: "X Resolution",
- YResolution: "Y Resolution",
- ResolutionUnit: "Resolution Unit",
- ColorSpace: "Color Space",
- PixelXDimension: "Pixel Width",
- PixelYDimension: "Pixel Height",
- Artist: "Artist",
- Copyright: "Copyright",
- ImageDescription: "Description",
- LensMake: "Lens Make",
- LensModel: "Lens Model",
- BodySerialNumber: "Body Serial",
- CameraOwnerName: "Camera Owner",
-};
-
-/** Keys to skip in display (internal/binary/redundant) */
-const SKIP_KEYS = new Set([
- "ExifTag", "GPSTag", "InteroperabilityTag", "MakerNote",
- "PrintImageMatching", "ComponentsConfiguration", "FlashpixVersion",
- "ExifVersion", "FileSource", "SceneType", "UserComment",
- "InteroperabilityIndex", "InteroperabilityVersion",
-]);
-
-function formatExifValue(key: string, value: unknown): string {
- if (value === null || value === undefined) return "N/A";
- if (typeof value === "string") return value;
- if (typeof value === "number") {
- if (key === "ExposureTime" && value > 0 && value < 1) {
- return `1/${Math.round(1 / value)}s`;
- }
- if (key === "FNumber") return `f/${value}`;
- if (key === "FocalLength") return `${value}mm`;
- if (key === "FocalLengthIn35mmFilm") return `${value}mm`;
- return String(value);
- }
- if (Array.isArray(value)) {
- if (typeof value[0] === "number" && value.length <= 4) {
- return value.join(", ");
- }
- return `[${value.length} values]`;
- }
- return String(value);
-}
-
-function CollapsibleSection({
- title,
- badge,
- warning,
- defaultOpen,
- children,
-}: {
- title: string;
- badge?: string;
- warning?: boolean;
- defaultOpen?: boolean;
- children: React.ReactNode;
-}) {
- const [open, setOpen] = useState(defaultOpen ?? false);
-
- return (
-
-
- {open &&
{children}
}
-
- );
-}
-
-function MetadataGrid({ data, labelMap }: { data: Record; labelMap?: Record }) {
- const entries = Object.entries(data).filter(
- ([k, v]) => !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== ""
- );
-
- if (entries.length === 0) {
- return No data
;
- }
-
- return (
-
- {entries.map(([k, v]) => (
-
-
- {labelMap?.[k] ?? k}
-
-
- {formatExifValue(k, v)}
-
-
- ))}
-
- );
-}
-
export function StripMetadataSettings() {
- const { files } = useFileStore();
+ const { entries, selectedIndex, files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("strip-metadata");
@@ -160,24 +29,32 @@ export function StripMetadataSettings() {
const [stripIcc, setStripIcc] = useState(false);
const [stripXmp, setStripXmp] = useState(false);
+ // Metadata inspection state
+ const [metadataCache, setMetadataCache] = useState
)}
- {/* Main area: Dropzone / Image Viewer / Before-After */}
+ {/* Main area: Dropzone / MultiImageViewer */}
{isNoDropzone ? (
Configure settings and generate.
- ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
-
- ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
-
- ) : hasProcessed && originalBlobUrl ? (
-
- ) : hasFile && originalBlobUrl ? (
-
+ ) : hasFile ? (
+
) : (
)}
@@ -403,47 +389,30 @@ export function ToolPage() {
currentToolId={tool.id}
/>
)}
+
+ {/* Batch download */}
+ {entries.length > 1 && hasProcessed && batchZipBlob && (
+
+
+
+
+ )}
- {/* Main area: Dropzone / Image Viewer / Before-After */}
+ {/* Main area: Dropzone / MultiImageViewer */}
{isNoDropzone ? (
Configure settings and generate.
- ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
-
- ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
-
- ) : hasProcessed && originalBlobUrl ? (
-
- ) : hasFile && originalBlobUrl ? (
-
+ ) : hasFile ? (
+
) : (
void;
- setJobId: (id: string) => void;
- setProcessedUrl: (url: string | null) => void;
+ addFiles: (files: File[]) => void;
+ removeFile: (index: number) => void;
+ setSelectedIndex: (index: number) => void;
+ navigateNext: () => void;
+ navigatePrev: () => void;
+ updateEntry: (index: number, patch: Partial) => void;
+ setBatchZip: (blob: Blob, filename: string) => void;
setProcessing: (v: boolean) => void;
setError: (e: string | null) => void;
+ setJobId: (id: string) => void;
+ setProcessedUrl: (url: string | null) => void;
setSizes: (original: number, processed: number) => void;
- /** Clear processed result but keep the original uploaded file. */
undoProcessing: () => void;
reset: () => void;
}
+/**
+ * Compute backward-compat derived values from core state.
+ * Called after every state mutation to keep derived fields in sync.
+ */
+function deriveCompat(entries: FileEntry[], selectedIndex: number) {
+ const entry = entries[selectedIndex];
+ return {
+ files: entries.map((e) => e.file),
+ currentEntry: entry,
+ hasFiles: entries.length > 0,
+ allProcessed:
+ entries.length > 0 && entries.every((e) => e.status === "completed"),
+ selectedFileName: entry ? entry.file.name : null,
+ selectedFileSize: entry ? entry.file.size : null,
+ originalBlobUrl: entry ? entry.blobUrl : null,
+ processedUrl: entry ? entry.processedUrl : null,
+ originalSize: entry ? entry.originalSize : null,
+ processedSize: entry ? entry.processedSize : null,
+ };
+}
+
export const useFileStore = create((set, get) => ({
- files: [],
- jobId: null,
- processedUrl: null,
- originalBlobUrl: null,
+ entries: [],
+ selectedIndex: 0,
+ batchZipBlob: null,
+ batchZipFilename: null,
processing: false,
error: null,
- originalSize: null,
- processedSize: null,
- selectedFileName: null,
- selectedFileSize: null,
+
+ // Initial derived values (empty state)
+ ...deriveCompat([], 0),
+
+ // -- Actions --------------------------------------------------------------
+
setFiles: (files) => {
- // Revoke old blob URL if any
- const old = get().originalBlobUrl;
- if (old) URL.revokeObjectURL(old);
- // Create a blob URL for the first file for before/after preview
- const blobUrl = files.length > 0 ? URL.createObjectURL(files[0]) : null;
- const firstName = files.length > 0 ? files[0].name : null;
- const firstSize = files.length > 0 ? files[0].size : null;
+ revokeEntries(get().entries);
+ const entries = files.map(createEntry);
set({
- files,
+ entries,
+ selectedIndex: 0,
error: null,
- originalBlobUrl: blobUrl,
- selectedFileName: firstName,
- selectedFileSize: firstSize,
+ ...deriveCompat(entries, 0),
});
},
- setJobId: (id) => set({ jobId: id }),
- setProcessedUrl: (url) => set({ processedUrl: url }),
+
+ addFiles: (files) => {
+ const entries = [...get().entries, ...files.map(createEntry)];
+ const idx = get().selectedIndex;
+ set({ entries, ...deriveCompat(entries, idx) });
+ },
+
+ removeFile: (index) => {
+ const { entries, selectedIndex } = get();
+ const removed = entries[index];
+ if (!removed) return;
+
+ URL.revokeObjectURL(removed.blobUrl);
+ if (removed.processedUrl) URL.revokeObjectURL(removed.processedUrl);
+
+ const newEntries = entries.filter((_, i) => i !== index);
+ let newIndex = selectedIndex;
+ if (index < selectedIndex) {
+ newIndex = selectedIndex - 1;
+ } else if (selectedIndex >= newEntries.length && newEntries.length > 0) {
+ newIndex = newEntries.length - 1;
+ } else if (newEntries.length === 0) {
+ newIndex = 0;
+ }
+ set({
+ entries: newEntries,
+ selectedIndex: newIndex,
+ ...deriveCompat(newEntries, newIndex),
+ });
+ },
+
+ setSelectedIndex: (index) => {
+ set({
+ selectedIndex: index,
+ ...deriveCompat(get().entries, index),
+ });
+ },
+
+ navigateNext: () => {
+ const { selectedIndex, entries } = get();
+ if (selectedIndex < entries.length - 1) {
+ const idx = selectedIndex + 1;
+ set({ selectedIndex: idx, ...deriveCompat(entries, idx) });
+ }
+ },
+
+ navigatePrev: () => {
+ const { selectedIndex, entries } = get();
+ if (selectedIndex > 0) {
+ const idx = selectedIndex - 1;
+ set({ selectedIndex: idx, ...deriveCompat(entries, idx) });
+ }
+ },
+
+ updateEntry: (index, patch) => {
+ const entries = [...get().entries];
+ if (!entries[index]) return;
+ entries[index] = { ...entries[index], ...patch };
+ const idx = get().selectedIndex;
+ set({ entries, ...deriveCompat(entries, idx) });
+ },
+
+ setBatchZip: (blob, filename) =>
+ set({ batchZipBlob: blob, batchZipFilename: filename }),
+
setProcessing: (v) => set({ processing: v }),
+
setError: (e) => set({ error: e, processing: false }),
- setSizes: (original, processed) =>
- set({ originalSize: original, processedSize: processed }),
+
+ setJobId: (_id) => {
+ // no-op for backward compat
+ },
+
+ setProcessedUrl: (url) => {
+ const { entries, selectedIndex } = get();
+ if (!entries[selectedIndex]) return;
+ const updated = [...entries];
+ if (url) {
+ updated[selectedIndex] = {
+ ...updated[selectedIndex],
+ processedUrl: url,
+ status: "completed",
+ };
+ } else {
+ updated[selectedIndex] = {
+ ...updated[selectedIndex],
+ processedUrl: null,
+ status: "pending",
+ };
+ }
+ set({ entries: updated, ...deriveCompat(updated, selectedIndex) });
+ },
+
+ setSizes: (original, processed) => {
+ const { entries, selectedIndex } = get();
+ if (!entries[selectedIndex]) return;
+ const updated = [...entries];
+ updated[selectedIndex] = {
+ ...updated[selectedIndex],
+ originalSize: original,
+ processedSize: processed,
+ };
+ set({ entries: updated, ...deriveCompat(updated, selectedIndex) });
+ },
+
undoProcessing: () => {
- set({
+ const { entries, selectedIndex } = get();
+ for (const entry of entries) {
+ if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl);
+ }
+ const resetEntries = entries.map((e) => ({
+ ...e,
processedUrl: null,
- jobId: null,
processedSize: null,
+ status: "pending" as const,
error: null,
+ }));
+ set({
+ entries: resetEntries,
+ error: null,
+ ...deriveCompat(resetEntries, selectedIndex),
});
},
+
reset: () => {
- const old = get().originalBlobUrl;
- if (old) URL.revokeObjectURL(old);
+ revokeEntries(get().entries);
set({
- files: [],
- jobId: null,
- processedUrl: null,
- originalBlobUrl: null,
+ entries: [],
+ selectedIndex: 0,
+ batchZipBlob: null,
+ batchZipFilename: null,
processing: false,
error: null,
- originalSize: null,
- processedSize: null,
- selectedFileName: null,
- selectedFileSize: null,
+ ...deriveCompat([], 0),
});
},
}));
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 7f7aaf6b..3a7213c3 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,3 +1,5 @@
+name: stirling-image
+
services:
stirling-image:
build:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 064e4f7e..a26e0e42 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -174,6 +174,9 @@ importers:
clsx:
specifier: ^2.1.0
version: 2.1.1
+ fflate:
+ specifier: ^0.8.2
+ version: 0.8.2
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@19.2.4)
@@ -3260,6 +3263,9 @@ packages:
picomatch:
optional: true
+ fflate@0.8.2:
+ resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+
figures@2.0.0:
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
engines: {node: '>=4'}
@@ -8283,6 +8289,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fflate@0.8.2: {}
+
figures@2.0.0:
dependencies:
escape-string-regexp: 1.0.5
diff --git a/tests/unit/web/stores.test.ts b/tests/unit/web/stores.test.ts
index 52fece9b..381c1b34 100644
--- a/tests/unit/web/stores.test.ts
+++ b/tests/unit/web/stores.test.ts
@@ -82,295 +82,403 @@ function failResponse(status: number) {
describe("FileStore", () => {
beforeEach(() => {
- // Reset the store to initial state before every test.
- // Zustand keeps state across calls, so we manually reset.
useFileStore.getState().reset();
vi.clearAllMocks();
- // After reset, createObjectURL/revokeObjectURL calls are from reset itself;
- // clear them so each test starts clean.
createObjectURL.mockClear();
revokeObjectURL.mockClear();
+ // Reset the mock to return incrementing URLs
+ let urlCounter = 0;
+ createObjectURL.mockImplementation(
+ (_obj: Blob | MediaSource) => `blob:url-${++urlCounter}`,
+ );
});
// -- Initial state -------------------------------------------------------
- it("has correct initial state (everything null/empty/false)", () => {
+ it("has correct initial state", () => {
const s = useFileStore.getState();
- expect(s.files).toEqual([]);
- expect(s.jobId).toBeNull();
- expect(s.processedUrl).toBeNull();
- expect(s.originalBlobUrl).toBeNull();
+ expect(s.entries).toEqual([]);
+ expect(s.selectedIndex).toBe(0);
+ expect(s.batchZipBlob).toBeNull();
+ expect(s.batchZipFilename).toBeNull();
expect(s.processing).toBe(false);
expect(s.error).toBeNull();
- expect(s.originalSize).toBeNull();
- expect(s.processedSize).toBeNull();
- expect(s.selectedFileName).toBeNull();
- expect(s.selectedFileSize).toBeNull();
});
// -- setFiles -------------------------------------------------------------
- it("setFiles stores files, creates blob URL, sets selectedFileName/Size, clears error", () => {
- // Seed an error first so we can verify it gets cleared
- useFileStore.getState().setError("old error");
- expect(useFileStore.getState().error).toBe("old error");
-
- const file = makeFile("photo.png", 2048);
- useFileStore.getState().setFiles([file]);
-
- const s = useFileStore.getState();
- expect(s.files).toHaveLength(1);
- expect(s.files[0]).toBe(file);
- expect(createObjectURL).toHaveBeenCalledWith(file);
- expect(s.originalBlobUrl).toBe("blob:fake-url");
- expect(s.selectedFileName).toBe("photo.png");
- expect(s.selectedFileSize).toBe(2048);
- expect(s.error).toBeNull(); // error cleared
- });
-
- it("setFiles revokes the previous blob URL when new files are set", () => {
- createObjectURL
- .mockReturnValueOnce("blob:first-url")
- .mockReturnValueOnce("blob:second-url");
-
- useFileStore.getState().setFiles([makeFile("a.png")]);
- expect(useFileStore.getState().originalBlobUrl).toBe("blob:first-url");
-
- useFileStore.getState().setFiles([makeFile("b.png")]);
- expect(revokeObjectURL).toHaveBeenCalledWith("blob:first-url");
- expect(useFileStore.getState().originalBlobUrl).toBe("blob:second-url");
- });
-
- it("setFiles with empty array does NOT create a blob URL", () => {
- useFileStore.getState().setFiles([]);
-
- const s = useFileStore.getState();
- expect(createObjectURL).not.toHaveBeenCalled();
- expect(s.originalBlobUrl).toBeNull();
- expect(s.selectedFileName).toBeNull();
- expect(s.selectedFileSize).toBeNull();
- });
-
- it("setFiles with empty array after prior files still revokes old URL", () => {
- createObjectURL.mockReturnValueOnce("blob:old");
- useFileStore.getState().setFiles([makeFile("old.png")]);
- revokeObjectURL.mockClear();
-
- useFileStore.getState().setFiles([]);
- expect(revokeObjectURL).toHaveBeenCalledWith("blob:old");
- });
-
- it("setFiles uses the FIRST file for blob URL when given multiple files", () => {
- const f1 = makeFile("first.png", 100);
- const f2 = makeFile("second.png", 200);
+ it("setFiles creates entries with blob URLs", () => {
+ const f1 = makeFile("a.png", 100);
+ const f2 = makeFile("b.png", 200);
useFileStore.getState().setFiles([f1, f2]);
- // createObjectURL is called exactly once (only for the first file)
- expect(createObjectURL).toHaveBeenCalledTimes(1);
- // Verify the argument was f1 by identity (same reference)
- expect(createObjectURL.mock.calls[0][0]).toBe(f1);
- expect(useFileStore.getState().selectedFileName).toBe("first.png");
- expect(useFileStore.getState().selectedFileSize).toBe(100);
- });
-
- // -- setJobId -------------------------------------------------------------
-
- it("setJobId stores the job ID", () => {
- useFileStore.getState().setJobId("job-abc");
- expect(useFileStore.getState().jobId).toBe("job-abc");
- });
-
- // -- setProcessedUrl ------------------------------------------------------
-
- it("setProcessedUrl stores a URL", () => {
- useFileStore.getState().setProcessedUrl("blob:processed");
- expect(useFileStore.getState().processedUrl).toBe("blob:processed");
- });
-
- it("setProcessedUrl can clear URL with null", () => {
- useFileStore.getState().setProcessedUrl("blob:x");
- useFileStore.getState().setProcessedUrl(null);
- expect(useFileStore.getState().processedUrl).toBeNull();
- });
-
- // -- setProcessing --------------------------------------------------------
-
- it("setProcessing sets the processing flag", () => {
- useFileStore.getState().setProcessing(true);
- expect(useFileStore.getState().processing).toBe(true);
- useFileStore.getState().setProcessing(false);
- expect(useFileStore.getState().processing).toBe(false);
- });
-
- // -- setError -------------------------------------------------------------
-
- it("setError sets error AND forces processing to false", () => {
- useFileStore.getState().setProcessing(true);
- expect(useFileStore.getState().processing).toBe(true);
-
- useFileStore.getState().setError("something broke");
const s = useFileStore.getState();
- expect(s.error).toBe("something broke");
- expect(s.processing).toBe(false); // critical side-effect
+ expect(s.entries).toHaveLength(2);
+ expect(s.entries[0].file).toBe(f1);
+ expect(s.entries[0].blobUrl).toBe("blob:url-1");
+ expect(s.entries[0].originalSize).toBe(100);
+ expect(s.entries[0].status).toBe("pending");
+ expect(s.entries[0].processedUrl).toBeNull();
+ expect(s.entries[0].processedSize).toBeNull();
+ expect(s.entries[0].error).toBeNull();
+ expect(s.entries[1].file).toBe(f2);
+ expect(s.entries[1].blobUrl).toBe("blob:url-2");
+ expect(createObjectURL).toHaveBeenCalledTimes(2);
});
- it("setError(null) clears error but still forces processing to false", () => {
- useFileStore.getState().setProcessing(true);
- useFileStore.getState().setError(null);
+ it("setFiles revokes old blob URLs", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ const oldUrl = useFileStore.getState().entries[0].blobUrl;
+ revokeObjectURL.mockClear();
+
+ useFileStore.getState().setFiles([makeFile("b.png")]);
+ expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl);
+ });
+
+ it("setFiles clears on empty array", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ revokeObjectURL.mockClear();
+ const oldUrl = useFileStore.getState().entries[0].blobUrl;
+
+ useFileStore.getState().setFiles([]);
+ expect(useFileStore.getState().entries).toEqual([]);
+ expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl);
+ });
+
+ it("setFiles resets selectedIndex to 0", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ useFileStore.getState().setSelectedIndex(1);
+ expect(useFileStore.getState().selectedIndex).toBe(1);
+
+ useFileStore.getState().setFiles([makeFile("c.png")]);
+ expect(useFileStore.getState().selectedIndex).toBe(0);
+ });
+
+ it("setFiles clears error", () => {
+ useFileStore.getState().setError("old error");
+ useFileStore.getState().setFiles([makeFile("a.png")]);
expect(useFileStore.getState().error).toBeNull();
- expect(useFileStore.getState().processing).toBe(false);
});
- // -- setSizes -------------------------------------------------------------
+ // -- addFiles -------------------------------------------------------------
+
+ it("addFiles appends new entries without revoking existing", () => {
+ useFileStore.getState().setFiles([makeFile("a.png", 100)]);
+ revokeObjectURL.mockClear();
+ createObjectURL.mockClear();
+
+ const f2 = makeFile("b.png", 200);
+ useFileStore.getState().addFiles([f2]);
+
+ expect(revokeObjectURL).not.toHaveBeenCalled();
+ expect(useFileStore.getState().entries).toHaveLength(2);
+ expect(useFileStore.getState().entries[1].file).toBe(f2);
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ });
+
+ // -- removeFile -----------------------------------------------------------
+
+ it("removeFile removes entry and revokes its blob URLs", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ const removedUrl = useFileStore.getState().entries[0].blobUrl;
+ revokeObjectURL.mockClear();
+
+ useFileStore.getState().removeFile(0);
+ expect(useFileStore.getState().entries).toHaveLength(1);
+ expect(useFileStore.getState().entries[0].file.name).toBe("b.png");
+ expect(revokeObjectURL).toHaveBeenCalledWith(removedUrl);
+ });
+
+ it("removeFile adjusts selectedIndex when removing before it", () => {
+ useFileStore.getState().setFiles([
+ makeFile("a.png"),
+ makeFile("b.png"),
+ makeFile("c.png"),
+ ]);
+ useFileStore.getState().setSelectedIndex(2);
+
+ useFileStore.getState().removeFile(0);
+ expect(useFileStore.getState().selectedIndex).toBe(1);
+ });
+
+ it("removeFile clamps selectedIndex if it was the last entry", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ useFileStore.getState().setSelectedIndex(1);
+
+ useFileStore.getState().removeFile(1);
+ expect(useFileStore.getState().selectedIndex).toBe(0);
+ });
+
+ it("removeFile revokes processedUrl if present", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ useFileStore.getState().updateEntry(0, {
+ processedUrl: "blob:processed",
+ status: "completed",
+ });
+ revokeObjectURL.mockClear();
+
+ useFileStore.getState().removeFile(0);
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed");
+ });
+
+ // -- Navigation -----------------------------------------------------------
+
+ it("navigateNext advances selectedIndex", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ expect(useFileStore.getState().selectedIndex).toBe(0);
+
+ useFileStore.getState().navigateNext();
+ expect(useFileStore.getState().selectedIndex).toBe(1);
+ });
+
+ it("navigateNext does not exceed bounds", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ useFileStore.getState().setSelectedIndex(1);
+
+ useFileStore.getState().navigateNext();
+ expect(useFileStore.getState().selectedIndex).toBe(1);
+ });
+
+ it("navigatePrev decrements selectedIndex", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ useFileStore.getState().setSelectedIndex(1);
+
+ useFileStore.getState().navigatePrev();
+ expect(useFileStore.getState().selectedIndex).toBe(0);
+ });
+
+ it("navigatePrev does not go below 0", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ useFileStore.getState().navigatePrev();
+ expect(useFileStore.getState().selectedIndex).toBe(0);
+ });
+
+ // -- updateEntry ----------------------------------------------------------
+
+ it("updateEntry merges partial data into the entry at index", () => {
+ useFileStore.getState().setFiles([makeFile("a.png", 500)]);
+ useFileStore.getState().updateEntry(0, {
+ status: "completed",
+ processedUrl: "blob:done",
+ processedSize: 250,
+ });
+
+ const entry = useFileStore.getState().entries[0];
+ expect(entry.status).toBe("completed");
+ expect(entry.processedUrl).toBe("blob:done");
+ expect(entry.processedSize).toBe(250);
+ expect(entry.file.name).toBe("a.png"); // unchanged
+ });
+
+ // -- setBatchZip ----------------------------------------------------------
+
+ it("setBatchZip stores blob and filename", () => {
+ const blob = new Blob(["zip-data"]);
+ useFileStore.getState().setBatchZip(blob, "results.zip");
- it("setSizes sets both originalSize and processedSize", () => {
- useFileStore.getState().setSizes(5000, 2500);
const s = useFileStore.getState();
- expect(s.originalSize).toBe(5000);
- expect(s.processedSize).toBe(2500);
- });
-
- it("setSizes with zero values stores zeros (not null)", () => {
- useFileStore.getState().setSizes(0, 0);
- expect(useFileStore.getState().originalSize).toBe(0);
- expect(useFileStore.getState().processedSize).toBe(0);
+ expect(s.batchZipBlob).toBe(blob);
+ expect(s.batchZipFilename).toBe("results.zip");
});
// -- undoProcessing -------------------------------------------------------
- it("undoProcessing clears processedUrl, jobId, processedSize, error but KEEPS files and originalBlobUrl", () => {
- createObjectURL.mockReturnValueOnce("blob:orig");
-
- // Set up full state
- const file = makeFile("keep-me.png", 3000);
- useFileStore.getState().setFiles([file]);
- useFileStore.getState().setJobId("job-1");
- useFileStore.getState().setProcessedUrl("blob:result");
- useFileStore.getState().setSizes(3000, 1500);
- useFileStore.getState().setError("transient error");
+ it("undoProcessing resets all entries to pending and revokes processed blob URLs", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ useFileStore.getState().updateEntry(0, {
+ status: "completed",
+ processedUrl: "blob:proc-a",
+ processedSize: 50,
+ });
+ useFileStore.getState().updateEntry(1, {
+ status: "completed",
+ processedUrl: "blob:proc-b",
+ processedSize: 75,
+ });
+ revokeObjectURL.mockClear();
useFileStore.getState().undoProcessing();
const s = useFileStore.getState();
- // Cleared
- expect(s.processedUrl).toBeNull();
- expect(s.jobId).toBeNull();
- expect(s.processedSize).toBeNull();
- expect(s.error).toBeNull();
- // Preserved
- expect(s.files).toHaveLength(1);
- expect(s.files[0]).toBe(file);
- expect(s.originalBlobUrl).toBe("blob:orig");
- expect(s.selectedFileName).toBe("keep-me.png");
- expect(s.selectedFileSize).toBe(3000);
- // originalSize is NOT cleared by undoProcessing (only processedSize is)
- expect(s.originalSize).toBe(3000);
+ // All entries reset to pending
+ expect(s.entries[0].status).toBe("pending");
+ expect(s.entries[0].processedUrl).toBeNull();
+ expect(s.entries[0].processedSize).toBeNull();
+ expect(s.entries[0].error).toBeNull();
+ expect(s.entries[1].status).toBe("pending");
+ expect(s.entries[1].processedUrl).toBeNull();
+ // Processed URLs revoked
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-a");
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-b");
});
- it("undoProcessing does NOT revoke the originalBlobUrl", () => {
- createObjectURL.mockReturnValueOnce("blob:keep-alive");
- useFileStore.getState().setFiles([makeFile("x.png")]);
+ it("undoProcessing keeps original blob URLs", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ const origUrl = useFileStore.getState().entries[0].blobUrl;
revokeObjectURL.mockClear();
useFileStore.getState().undoProcessing();
- expect(revokeObjectURL).not.toHaveBeenCalled();
+ // Should NOT revoke original blob URL
+ expect(revokeObjectURL).not.toHaveBeenCalledWith(origUrl);
+ expect(useFileStore.getState().entries[0].blobUrl).toBe(origUrl);
});
// -- reset ----------------------------------------------------------------
- it("reset clears everything and revokes the blob URL", () => {
- createObjectURL.mockReturnValueOnce("blob:to-revoke");
- useFileStore.getState().setFiles([makeFile("doomed.png")]);
- useFileStore.getState().setJobId("job-x");
- useFileStore.getState().setProcessedUrl("blob:proc");
- useFileStore.getState().setProcessing(true);
- useFileStore.getState().setError("oops");
- useFileStore.getState().setSizes(100, 50);
+ it("reset clears everything and revokes all blob URLs", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ useFileStore.getState().updateEntry(0, { processedUrl: "blob:proc" });
+ const origUrl0 = useFileStore.getState().entries[0].blobUrl;
+ const origUrl1 = useFileStore.getState().entries[1].blobUrl;
revokeObjectURL.mockClear();
useFileStore.getState().reset();
- expect(revokeObjectURL).toHaveBeenCalledWith("blob:to-revoke");
+ expect(revokeObjectURL).toHaveBeenCalledWith(origUrl0);
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc");
+ expect(revokeObjectURL).toHaveBeenCalledWith(origUrl1);
const s = useFileStore.getState();
- expect(s.files).toEqual([]);
- expect(s.jobId).toBeNull();
- expect(s.processedUrl).toBeNull();
- expect(s.originalBlobUrl).toBeNull();
+ expect(s.entries).toEqual([]);
+ expect(s.selectedIndex).toBe(0);
+ expect(s.batchZipBlob).toBeNull();
+ expect(s.batchZipFilename).toBeNull();
expect(s.processing).toBe(false);
expect(s.error).toBeNull();
- expect(s.originalSize).toBeNull();
- expect(s.processedSize).toBeNull();
- expect(s.selectedFileName).toBeNull();
- expect(s.selectedFileSize).toBeNull();
});
- it("reset when originalBlobUrl is already null does NOT call revokeObjectURL", () => {
- // Start from a clean state (no files set)
+ it("reset with no entries does not call revokeObjectURL", () => {
revokeObjectURL.mockClear();
useFileStore.getState().reset();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
- // -- State transition sequences -------------------------------------------
+ // -- Backward compat getters ----------------------------------------------
- it("setFiles -> setProcessing(true) -> setError -> processing is false", () => {
- useFileStore.getState().setFiles([makeFile("t.png")]);
- useFileStore.getState().setProcessing(true);
- expect(useFileStore.getState().processing).toBe(true);
+ it("files getter maps entries to File[]", () => {
+ const f1 = makeFile("a.png");
+ const f2 = makeFile("b.png");
+ useFileStore.getState().setFiles([f1, f2]);
- useFileStore.getState().setError("fail");
- expect(useFileStore.getState().processing).toBe(false);
- expect(useFileStore.getState().error).toBe("fail");
+ const s = useFileStore.getState();
+ expect(s.files).toEqual([f1, f2]);
+ expect(s.files[0]).toBe(f1);
});
- it("setFiles -> setProcessing(true) -> setProcessedUrl -> setProcessing(false) (happy path)", () => {
- useFileStore.getState().setFiles([makeFile("t.png")]);
- useFileStore.getState().setProcessing(true);
- expect(useFileStore.getState().processing).toBe(true);
+ it("currentEntry returns entry at selectedIndex", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ useFileStore.getState().setSelectedIndex(1);
- useFileStore.getState().setProcessedUrl("blob:done");
- // processedUrl does NOT auto-clear processing
- expect(useFileStore.getState().processing).toBe(true);
+ expect(useFileStore.getState().currentEntry?.file.name).toBe("b.png");
+ });
- useFileStore.getState().setProcessing(false);
- expect(useFileStore.getState().processing).toBe(false);
+ it("currentEntry returns undefined when no entries", () => {
+ expect(useFileStore.getState().currentEntry).toBeUndefined();
+ });
+
+ it("selectedFileName returns current entry file name", () => {
+ useFileStore.getState().setFiles([makeFile("photo.png")]);
+ expect(useFileStore.getState().selectedFileName).toBe("photo.png");
+ });
+
+ it("selectedFileName returns null when no entries", () => {
+ expect(useFileStore.getState().selectedFileName).toBeNull();
+ });
+
+ it("selectedFileSize returns current entry file size", () => {
+ useFileStore.getState().setFiles([makeFile("photo.png", 2048)]);
+ expect(useFileStore.getState().selectedFileSize).toBe(2048);
+ });
+
+ it("selectedFileSize returns null when no entries", () => {
+ expect(useFileStore.getState().selectedFileSize).toBeNull();
+ });
+
+ it("originalBlobUrl returns current entry blobUrl", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ expect(useFileStore.getState().originalBlobUrl).toBe(
+ useFileStore.getState().entries[0].blobUrl,
+ );
+ });
+
+ it("originalBlobUrl returns null when no entries", () => {
+ expect(useFileStore.getState().originalBlobUrl).toBeNull();
+ });
+
+ it("processedUrl returns current entry processedUrl", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ useFileStore.getState().updateEntry(0, { processedUrl: "blob:done" });
expect(useFileStore.getState().processedUrl).toBe("blob:done");
});
- it("rapid setFiles calls only keep the latest state and revoke each prior URL", () => {
- createObjectURL
- .mockReturnValueOnce("blob:1")
- .mockReturnValueOnce("blob:2")
- .mockReturnValueOnce("blob:3");
-
- useFileStore.getState().setFiles([makeFile("a.png")]);
- useFileStore.getState().setFiles([makeFile("b.png")]);
- useFileStore.getState().setFiles([makeFile("c.png")]);
-
- expect(revokeObjectURL).toHaveBeenCalledWith("blob:1");
- expect(revokeObjectURL).toHaveBeenCalledWith("blob:2");
- expect(revokeObjectURL).toHaveBeenCalledTimes(2);
- expect(useFileStore.getState().originalBlobUrl).toBe("blob:3");
- expect(useFileStore.getState().selectedFileName).toBe("c.png");
+ it("originalSize returns current entry originalSize", () => {
+ useFileStore.getState().setFiles([makeFile("a.png", 999)]);
+ expect(useFileStore.getState().originalSize).toBe(999);
});
- it("setError during processing, then undoProcessing, then retry cycle works", () => {
- useFileStore.getState().setFiles([makeFile("retry.png")]);
- useFileStore.getState().setProcessing(true);
- useFileStore.getState().setError("timeout");
- expect(useFileStore.getState().processing).toBe(false);
+ it("processedSize returns current entry processedSize", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ useFileStore.getState().updateEntry(0, { processedSize: 500 });
+ expect(useFileStore.getState().processedSize).toBe(500);
+ });
- useFileStore.getState().undoProcessing();
- expect(useFileStore.getState().error).toBeNull();
- expect(useFileStore.getState().files).toHaveLength(1);
+ it("hasFiles returns true when entries exist", () => {
+ expect(useFileStore.getState().hasFiles).toBe(false);
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ expect(useFileStore.getState().hasFiles).toBe(true);
+ });
- // Retry
- useFileStore.getState().setProcessing(true);
- expect(useFileStore.getState().processing).toBe(true);
- useFileStore.getState().setProcessedUrl("blob:retry-ok");
- useFileStore.getState().setProcessing(false);
- expect(useFileStore.getState().processedUrl).toBe("blob:retry-ok");
+ it("allProcessed returns true when all entries are completed", () => {
+ useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
+ expect(useFileStore.getState().allProcessed).toBe(false);
+
+ useFileStore.getState().updateEntry(0, { status: "completed" });
+ expect(useFileStore.getState().allProcessed).toBe(false);
+
+ useFileStore.getState().updateEntry(1, { status: "completed" });
+ expect(useFileStore.getState().allProcessed).toBe(true);
+ });
+
+ it("allProcessed returns false when no entries", () => {
+ expect(useFileStore.getState().allProcessed).toBe(false);
+ });
+
+ // -- setProcessedUrl (backward compat, updates current entry) -------------
+
+ it("setProcessedUrl updates current entry processedUrl and status", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ useFileStore.getState().setProcessedUrl("blob:result");
+
+ const entry = useFileStore.getState().entries[0];
+ expect(entry.processedUrl).toBe("blob:result");
+ expect(entry.status).toBe("completed");
+ });
+
+ it("setProcessedUrl with null resets current entry", () => {
+ useFileStore.getState().setFiles([makeFile("a.png")]);
+ useFileStore.getState().setProcessedUrl("blob:result");
+ useFileStore.getState().setProcessedUrl(null);
+
+ const entry = useFileStore.getState().entries[0];
+ expect(entry.processedUrl).toBeNull();
+ expect(entry.status).toBe("pending");
+ });
+
+ // -- setSizes (backward compat, updates current entry) --------------------
+
+ it("setSizes updates current entry sizes", () => {
+ useFileStore.getState().setFiles([makeFile("a.png", 1000)]);
+ useFileStore.getState().setSizes(1000, 500);
+
+ const entry = useFileStore.getState().entries[0];
+ expect(entry.originalSize).toBe(1000);
+ expect(entry.processedSize).toBe(500);
+ });
+
+ // -- setJobId (no-op for compat) ------------------------------------------
+
+ it("setJobId is a no-op (does not throw)", () => {
+ expect(() => useFileStore.getState().setJobId("job-abc")).not.toThrow();
});
});