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 -
+
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 -
+
Processed { 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 (
{/* Quick rotate buttons */} @@ -86,7 +70,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm" > - 90° Left + 90 Left
- {/* 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° - - 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>(new Map()); const [metadata, setMetadata] = useState(null); const [inspecting, setInspecting] = useState(false); const [inspectError, setInspectError] = useState(null); - const lastInspectedFile = useRef(null); - // Auto-fetch metadata when files change + // Collapsible sections + const [expandedSections, setExpandedSections] = useState>(new Set()); + + const currentFile = entries[selectedIndex]?.file ?? null; + const fileKey = currentFile ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` : null; + + // Auto-fetch metadata for the selected file useEffect(() => { - if (files.length === 0) { + if (!currentFile || !fileKey) { setMetadata(null); setInspectError(null); - lastInspectedFile.current = null; return; } - const file = files[0]; - const fileKey = `${file.name}-${file.size}-${file.lastModified}`; - if (lastInspectedFile.current === fileKey) return; - lastInspectedFile.current = fileKey; + // Check cache first + const cached = metadataCache.get(fileKey); + if (cached) { + setMetadata(cached); + return; + } const controller = new AbortController(); (async () => { @@ -186,7 +63,7 @@ export function StripMetadataSettings() { setMetadata(null); try { const formData = new FormData(); - formData.append("file", file); + formData.append("file", currentFile); const res = await fetch("/api/v1/tools/strip-metadata/inspect", { method: "POST", headers: { Authorization: `Bearer ${getToken()}` }, @@ -199,6 +76,7 @@ export function StripMetadataSettings() { } const data: MetadataResult = await res.json(); setMetadata(data); + setMetadataCache((prev) => new Map(prev).set(fileKey!, data)); } catch (err) { if ((err as Error).name === "AbortError") return; setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata"); @@ -208,7 +86,16 @@ export function StripMetadataSettings() { })(); return () => controller.abort(); - }, [files]); + }, [currentFile, fileKey]); + + const toggleSection = (section: string) => { + setExpandedSections((prev) => { + const next = new Set(prev); + if (next.has(section)) next.delete(section); + else next.add(section); + return next; + }); + }; const handleStripAllChange = (checked: boolean) => { setStripAll(checked); @@ -231,90 +118,76 @@ export function StripMetadataSettings() { if (hasFile && !processing) handleProcess(); }; - const hasExif = metadata?.exif && Object.keys(metadata.exif).length > 0; const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0; - const hasIcc = metadata?.icc && Object.keys(metadata.icc).length > 0; - const hasXmp = metadata?.xmp && Object.keys(metadata.xmp).length > 0; - const hasAnyMetadata = hasExif || hasGps || hasIcc || hasXmp; - const sectionCount = [hasExif, hasGps, hasIcc, hasXmp].filter(Boolean).length; - // GPS coordinates for display - const gpsLat = metadata?.gps?.["_latitude"] as number | undefined; - const gpsLon = metadata?.gps?.["_longitude"] as number | undefined; + const renderMetadataSection = (title: string, key: string, data: Record | null | undefined) => { + if (!data || Object.keys(data).length === 0) return null; + const expanded = expandedSections.has(key); + return ( +
+ + {expanded && ( +
+ {Object.entries(data).map(([k, v]) => ( +
+ {k}: + {String(v)} +
+ ))} +
+ )} +
+ ); + }; return ( - {/* Metadata Display */} - {hasFile && ( + {/* Metadata inspection */} + {inspecting && ( +
+ + Inspecting metadata... +
+ )} + + {inspectError &&

{inspectError}

} + + {metadata && (
- + - {inspecting && ( -
- - Reading metadata... -
- )} - - {inspectError && ( -

{inspectError}

- )} - - {metadata && !hasAnyMetadata && !inspecting && ( -

- No metadata found in this image. -

- )} - - {metadata && hasAnyMetadata && ( -
- {/* GPS warning banner */} - {hasGps && gpsLat !== undefined && gpsLon !== undefined && ( -
- - - Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)} - -
- )} - - {hasExif && ( - !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`} - defaultOpen - > - - - )} - - {hasGps && ( - !k.startsWith("_")).length} fields`}> - - - )} - - {hasIcc && ( - - - - )} - - {hasXmp && ( - - - - )} - -

- {sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found + {hasGps && ( +

+ +

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

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

EXIF: {metadata.exifError}

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

No metadata found in this file.

+ )}
)} - {hasFile && hasAnyMetadata &&
} +
{/* Strip All */}
)} @@ -295,45 +311,14 @@ export function ToolPage() {
)} - {/* Main area: Dropzone / Image Viewer / Before-After */} + {/* Main area: Dropzone / MultiImageViewer */}
{isNoDropzone ? (

Configure settings and generate.

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

Configure settings and generate.

- ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl ? ( - - ) : hasFile && originalBlobUrl ? ( - + ) : hasFile ? ( + ) : ( 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(); }); });