mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: merge multi-image UX — batch processing, filmstrip navigation, resize/rotate redesign
Integrates feature/multi-image-ux branch with 20 commits including: - Multi-image viewer with arrow navigation and filmstrip thumbnails - Batch processing across all tool settings components - File store rewrite with FileEntry model for multi-image support - Resize settings redesigned with tab-based UI (presets, custom, scale) - Side-by-side comparison for resize results - Per-file metadata caching in strip-metadata - Client-side ZIP extraction via fflate - SSE progress correlation via clientJobId
This commit is contained in:
@@ -42,6 +42,7 @@ export async function registerBatchRoutes(
|
|||||||
// Parse multipart: collect all files and the settings field
|
// Parse multipart: collect all files and the settings field
|
||||||
const files: ParsedFile[] = [];
|
const files: ParsedFile[] = [];
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
|
let clientJobId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
@@ -60,6 +61,8 @@ export async function registerBatchRoutes(
|
|||||||
}
|
}
|
||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
|
} else if (part.fieldname === "clientJobId") {
|
||||||
|
clientJobId = part.value as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -102,7 +105,7 @@ export async function registerBatchRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a job ID for progress tracking
|
// Create a job ID for progress tracking
|
||||||
const jobId = randomUUID();
|
const jobId = clientJobId || randomUUID();
|
||||||
|
|
||||||
const progress: JobProgress = {
|
const progress: JobProgress = {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -120,6 +123,7 @@ export async function registerBatchRoutes(
|
|||||||
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
|
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
"X-Job-Id": jobId,
|
"X-Job-Id": jobId,
|
||||||
|
"X-File-Order": files.map(f => f.filename).join(","),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create ZIP archive that pipes directly to the response
|
// Create ZIP archive that pipes directly to the response
|
||||||
|
|||||||
@@ -12,20 +12,21 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stirling-image/shared": "workspace:*",
|
"@stirling-image/shared": "workspace:*",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.0",
|
"react-router-dom": "^7.1.0",
|
||||||
"zustand": "^5.0.0",
|
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"lucide-react": "^0.469.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col w-full h-full" onKeyDown={hasMultiple ? handleKeyDown : undefined} tabIndex={hasMultiple ? 0 : undefined}>
|
||||||
|
<div className="flex-1 relative flex items-center justify-center">
|
||||||
|
{hasMultiple && hasPrev && (
|
||||||
|
<button onClick={navigatePrev} className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Previous image">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="w-full h-full">
|
||||||
|
{hasProcessed ? (
|
||||||
|
<BeforeAfterSlider beforeSrc={currentEntry.blobUrl} afterSrc={currentEntry.processedUrl!} beforeSize={currentEntry.originalSize} afterSize={currentEntry.processedSize ?? undefined} />
|
||||||
|
) : (
|
||||||
|
<ImageViewer src={currentEntry.blobUrl} filename={currentEntry.file.name} fileSize={currentEntry.file.size} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasMultiple && hasNext && (
|
||||||
|
<button onClick={navigateNext} className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors" aria-label="Next image">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasMultiple && (
|
||||||
|
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
|
||||||
|
{selectedIndex + 1} / {entries.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ThumbnailStrip entries={entries} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ export function SideBySideComparison({
|
|||||||
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
|
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
||||||
{/* Side-by-side images */}
|
{/* Side-by-side images */}
|
||||||
@@ -36,11 +45,14 @@ export function SideBySideComparison({
|
|||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Original
|
Original
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
|
<div
|
||||||
|
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
||||||
|
style={checkerboard}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={beforeSrc}
|
src={beforeSrc}
|
||||||
alt="Original"
|
alt="Original"
|
||||||
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
className="max-w-full max-h-full object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
@@ -51,23 +63,26 @@ export function SideBySideComparison({
|
|||||||
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
||||||
{beforeDims && (
|
{beforeDims && (
|
||||||
<p>
|
<p>
|
||||||
{beforeDims.w} × {beforeDims.h}
|
{beforeDims.w} x {beforeDims.h}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{beforeSize != null && <p>{formatSize(beforeSize)}</p>}
|
{beforeSize != null && <p>{formatSize(beforeSize)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processed */}
|
{/* Resized */}
|
||||||
<div className="flex-1 flex flex-col items-center gap-2">
|
<div className="flex-1 flex flex-col items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Processed
|
Resized
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
|
<div
|
||||||
|
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
||||||
|
style={checkerboard}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={afterSrc}
|
src={afterSrc}
|
||||||
alt="Processed"
|
alt="Resized"
|
||||||
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
className="max-w-full max-h-full object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
@@ -78,7 +93,7 @@ export function SideBySideComparison({
|
|||||||
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
||||||
{afterDims && (
|
{afterDims && (
|
||||||
<p>
|
<p>
|
||||||
{afterDims.w} × {afterDims.h}
|
{afterDims.w} x {afterDims.h}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{afterSize != null && <p>{formatSize(afterSize)}</p>}
|
{afterSize != null && <p>{formatSize(afterSize)}</p>}
|
||||||
|
|||||||
@@ -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<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedRef.current?.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
inline: "nearest",
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
if (entries.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1.5 px-3 py-2 overflow-x-auto border-t border-border bg-muted/30" style={{ scrollBehavior: "smooth" }}>
|
||||||
|
{entries.map((entry, i) => {
|
||||||
|
const isSelected = i === selectedIndex;
|
||||||
|
const isCompleted = entry.status === "completed";
|
||||||
|
const isFailed = entry.status === "failed";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${entry.file.name}-${i}`}
|
||||||
|
ref={isSelected ? selectedRef : undefined}
|
||||||
|
onClick={() => onSelect(i)}
|
||||||
|
className={`relative shrink-0 rounded overflow-hidden transition-all ${
|
||||||
|
isSelected ? "outline outline-2 outline-primary outline-offset-1" : "hover:outline hover:outline-1 hover:outline-border"
|
||||||
|
}`}
|
||||||
|
style={{ width: 52, height: 38 }}
|
||||||
|
title={entry.file.name}
|
||||||
|
>
|
||||||
|
<img src={entry.processedUrl ?? entry.blobUrl} alt={entry.file.name} className="w-full h-full object-cover" draggable={false} />
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="h-2.5 w-2.5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFailed && (
|
||||||
|
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-red-500 rounded-full flex items-center justify-center">
|
||||||
|
<XCircle className="h-2.5 w-2.5 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card";
|
|||||||
|
|
||||||
export function BorderSettings() {
|
export function BorderSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("border");
|
useToolProcessor("border");
|
||||||
|
|
||||||
const [borderWidth, setBorderWidth] = useState(10);
|
const [borderWidth, setBorderWidth] = useState(10);
|
||||||
@@ -16,7 +16,12 @@ export function BorderSettings() {
|
|||||||
const [shadowBlur, setShadowBlur] = useState(0);
|
const [shadowBlur, setShadowBlur] = useState(0);
|
||||||
|
|
||||||
const handleProcess = () => {
|
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;
|
const hasFile = files.length > 0;
|
||||||
@@ -84,7 +89,7 @@ export function BorderSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface ColorSettingsProps {
|
|||||||
|
|
||||||
export function ColorSettings({ toolId }: ColorSettingsProps) {
|
export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor(toolId);
|
useToolProcessor(toolId);
|
||||||
|
|
||||||
const [tab, setTab] = useState<Tab>(() => {
|
const [tab, setTab] = useState<Tab>(() => {
|
||||||
@@ -37,7 +37,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
|||||||
const [effect, setEffect] = useState<Effect>("none");
|
const [effect, setEffect] = useState<Effect>("none");
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
processFiles(files, {
|
const settings = {
|
||||||
brightness,
|
brightness,
|
||||||
contrast,
|
contrast,
|
||||||
saturation,
|
saturation,
|
||||||
@@ -45,7 +45,12 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
|||||||
green,
|
green,
|
||||||
blue,
|
blue,
|
||||||
effect,
|
effect,
|
||||||
});
|
};
|
||||||
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -215,7 +220,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
|||||||
disabled={!hasFile || !hasChanges || processing}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type CompressMode = "quality" | "targetSize";
|
|||||||
|
|
||||||
export function CompressSettings() {
|
export function CompressSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("compress");
|
useToolProcessor("compress");
|
||||||
|
|
||||||
const [mode, setMode] = useState<CompressMode>("quality");
|
const [mode, setMode] = useState<CompressMode>("quality");
|
||||||
@@ -22,7 +22,11 @@ export function CompressSettings() {
|
|||||||
} else {
|
} else {
|
||||||
settings.targetSizeKb = Number(targetSizeKb);
|
settings.targetSizeKb = Number(targetSizeKb);
|
||||||
}
|
}
|
||||||
processFiles(files, settings);
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -124,7 +128,7 @@ export function CompressSettings() {
|
|||||||
disabled={!hasFile || !canProcess || processing}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
|
|||||||
|
|
||||||
export function ConvertSettings() {
|
export function ConvertSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("convert");
|
useToolProcessor("convert");
|
||||||
|
|
||||||
const [format, setFormat] = useState<string>("png");
|
const [format, setFormat] = useState<string>("png");
|
||||||
@@ -28,7 +28,11 @@ export function ConvertSettings() {
|
|||||||
if (isLossy) {
|
if (isLossy) {
|
||||||
settings.quality = quality;
|
settings.quality = quality;
|
||||||
}
|
}
|
||||||
processFiles(files, settings);
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -118,7 +122,7 @@ export function ConvertSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const ASPECT_PRESETS = [
|
|||||||
|
|
||||||
export function CropSettings() {
|
export function CropSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("crop");
|
useToolProcessor("crop");
|
||||||
|
|
||||||
const [left, setLeft] = useState("0");
|
const [left, setLeft] = useState("0");
|
||||||
@@ -31,12 +31,17 @@ export function CropSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
processFiles(files, {
|
const settings = {
|
||||||
left: Number(left),
|
left: Number(left),
|
||||||
top: Number(top),
|
top: Number(top),
|
||||||
width: Number(width),
|
width: Number(width),
|
||||||
height: Number(height),
|
height: Number(height),
|
||||||
});
|
};
|
||||||
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -141,7 +146,7 @@ export function CropSettings() {
|
|||||||
disabled={!hasFile || !hasSize || processing}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card";
|
|||||||
|
|
||||||
export function ReplaceColorSettings() {
|
export function ReplaceColorSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("replace-color");
|
useToolProcessor("replace-color");
|
||||||
|
|
||||||
const [sourceColor, setSourceColor] = useState("#FF0000");
|
const [sourceColor, setSourceColor] = useState("#FF0000");
|
||||||
@@ -15,7 +15,12 @@ export function ReplaceColorSettings() {
|
|||||||
const [tolerance, setTolerance] = useState(30);
|
const [tolerance, setTolerance] = useState(30);
|
||||||
|
|
||||||
const handleProcess = () => {
|
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;
|
const hasFile = files.length > 0;
|
||||||
@@ -81,7 +86,7 @@ export function ReplaceColorSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
|||||||
|
|
||||||
export function ResizeSettings() {
|
export function ResizeSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
useToolProcessor("resize");
|
useToolProcessor("resize");
|
||||||
|
|
||||||
const [tab, setTab] = useState<ResizeTab>("custom");
|
const [tab, setTab] = useState<ResizeTab>("presets");
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
const [width, setWidth] = useState<string>("");
|
const [width, setWidth] = useState<string>("");
|
||||||
const [height, setHeight] = useState<string>("");
|
const [height, setHeight] = useState<string>("");
|
||||||
@@ -56,7 +56,11 @@ export function ResizeSettings() {
|
|||||||
settings.withoutEnlargement = withoutEnlargement;
|
settings.withoutEnlargement = withoutEnlargement;
|
||||||
}
|
}
|
||||||
|
|
||||||
processFiles(files, settings);
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -80,15 +84,15 @@ export function ResizeSettings() {
|
|||||||
{/* Tab selector */}
|
{/* Tab selector */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||||||
|
Presets
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
||||||
Custom Size
|
Custom Size
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||||
Scale
|
Scale
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
|
||||||
Presets
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -252,7 +256,7 @@ export function ResizeSettings() {
|
|||||||
disabled={!canProcess}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
FlipHorizontal,
|
FlipHorizontal,
|
||||||
FlipVertical,
|
FlipVertical,
|
||||||
RotateCcw as ResetIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ interface RotateSettingsProps {
|
|||||||
|
|
||||||
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
useToolProcessor("rotate");
|
useToolProcessor("rotate");
|
||||||
|
|
||||||
const [angle, setAngle] = useState(0);
|
const [angle, setAngle] = useState(0);
|
||||||
@@ -35,29 +34,20 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
||||||
}, [angle, flipH, flipV, onPreviewTransform]);
|
}, [angle, flipH, flipV, onPreviewTransform]);
|
||||||
|
|
||||||
const rotateLeft = () => setAngle((a) => {
|
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
|
||||||
const next = a - 90;
|
const rotateRight = () => setAngle((a) => (a + 90) % 360);
|
||||||
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 handleProcess = () => {
|
const handleProcess = () => {
|
||||||
// Convert -180..180 to 0..360 for the backend
|
const settings = {
|
||||||
const backendAngle = angle < 0 ? angle + 360 : angle;
|
angle,
|
||||||
processFiles(files, {
|
|
||||||
angle: backendAngle,
|
|
||||||
horizontal: flipH,
|
horizontal: flipH,
|
||||||
vertical: flipV,
|
vertical: flipV,
|
||||||
});
|
};
|
||||||
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -68,12 +58,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
if (hasFile && hasChanges && !processing) handleProcess();
|
if (hasFile && hasChanges && !processing) handleProcess();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setAngle(0);
|
|
||||||
setFlipH(false);
|
|
||||||
setFlipV(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Quick rotate buttons */}
|
{/* 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"
|
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"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
90° Left
|
90 Left
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -94,51 +78,25 @@ 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"
|
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"
|
||||||
>
|
>
|
||||||
<RotateCw className="h-4 w-4" />
|
<RotateCw className="h-4 w-4" />
|
||||||
90° Right
|
90 Right
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Angle control */}
|
{/* Angle slider */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-xs text-muted-foreground">Fine Angle</label>
|
<label className="text-xs text-muted-foreground">Angle</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<span className="text-xs font-mono text-foreground">{angle} deg</span>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={angle}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">°</span>
|
|
||||||
{angle !== 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAngle(0)}
|
|
||||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
||||||
title="Reset angle"
|
|
||||||
>
|
|
||||||
<ResetIcon className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={-180}
|
min={0}
|
||||||
max={180}
|
max={360}
|
||||||
step={1}
|
|
||||||
value={angle}
|
value={angle}
|
||||||
onChange={(e) => setAngle(Number(e.target.value))}
|
onChange={(e) => setAngle(Number(e.target.value))}
|
||||||
className="w-full mt-1"
|
className="w-full mt-1"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
|
||||||
<span>-180°</span>
|
|
||||||
<span>0°</span>
|
|
||||||
<span>180°</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flip buttons */}
|
{/* Flip buttons */}
|
||||||
@@ -172,17 +130,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reset all */}
|
|
||||||
{hasChanges && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleReset}
|
|
||||||
className="w-full text-xs text-muted-foreground hover:text-foreground py-1"
|
|
||||||
>
|
|
||||||
Reset all changes
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
@@ -202,7 +149,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
disabled={!hasFile || !hasChanges || processing}
|
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"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
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";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
|
||||||
function getToken(): string {
|
function getToken(): string {
|
||||||
@@ -18,139 +18,8 @@ interface MetadataResult {
|
|||||||
xmp?: Record<string, string> | null;
|
xmp?: Record<string, string> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-friendly labels for common EXIF keys */
|
|
||||||
const EXIF_LABELS: Record<string, string> = {
|
|
||||||
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 (
|
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
|
||||||
<span className="flex-1 text-left">{title}</span>
|
|
||||||
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
|
||||||
{badge && (
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{open && <div className="px-3 pb-2">{children}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetadataGrid({ data, labelMap }: { data: Record<string, unknown>; labelMap?: Record<string, string> }) {
|
|
||||||
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 <p className="text-[10px] text-muted-foreground italic">No data</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-2 gap-y-0.5">
|
|
||||||
{entries.map(([k, v]) => (
|
|
||||||
<div key={k} className="contents">
|
|
||||||
<div className="text-[10px] text-muted-foreground truncate" title={k}>
|
|
||||||
{labelMap?.[k] ?? k}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-foreground font-mono truncate" title={formatExifValue(k, v)}>
|
|
||||||
{formatExifValue(k, v)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StripMetadataSettings() {
|
export function StripMetadataSettings() {
|
||||||
const { files } = useFileStore();
|
const { entries, selectedIndex, files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("strip-metadata");
|
useToolProcessor("strip-metadata");
|
||||||
|
|
||||||
@@ -160,24 +29,32 @@ export function StripMetadataSettings() {
|
|||||||
const [stripIcc, setStripIcc] = useState(false);
|
const [stripIcc, setStripIcc] = useState(false);
|
||||||
const [stripXmp, setStripXmp] = useState(false);
|
const [stripXmp, setStripXmp] = useState(false);
|
||||||
|
|
||||||
|
// Metadata inspection state
|
||||||
|
const [metadataCache, setMetadataCache] = useState<Map<string, MetadataResult>>(new Map());
|
||||||
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
|
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
|
||||||
const [inspecting, setInspecting] = useState(false);
|
const [inspecting, setInspecting] = useState(false);
|
||||||
const [inspectError, setInspectError] = useState<string | null>(null);
|
const [inspectError, setInspectError] = useState<string | null>(null);
|
||||||
const lastInspectedFile = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Auto-fetch metadata when files change
|
// Collapsible sections
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(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(() => {
|
useEffect(() => {
|
||||||
if (files.length === 0) {
|
if (!currentFile || !fileKey) {
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
setInspectError(null);
|
setInspectError(null);
|
||||||
lastInspectedFile.current = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = files[0];
|
// Check cache first
|
||||||
const fileKey = `${file.name}-${file.size}-${file.lastModified}`;
|
const cached = metadataCache.get(fileKey);
|
||||||
if (lastInspectedFile.current === fileKey) return;
|
if (cached) {
|
||||||
lastInspectedFile.current = fileKey;
|
setMetadata(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -186,7 +63,7 @@ export function StripMetadataSettings() {
|
|||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", currentFile);
|
||||||
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
@@ -199,6 +76,7 @@ export function StripMetadataSettings() {
|
|||||||
}
|
}
|
||||||
const data: MetadataResult = await res.json();
|
const data: MetadataResult = await res.json();
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
|
setMetadataCache((prev) => new Map(prev).set(fileKey!, data));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError") return;
|
if ((err as Error).name === "AbortError") return;
|
||||||
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
|
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
|
||||||
@@ -208,7 +86,16 @@ export function StripMetadataSettings() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => controller.abort();
|
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) => {
|
const handleStripAllChange = (checked: boolean) => {
|
||||||
setStripAll(checked);
|
setStripAll(checked);
|
||||||
@@ -231,90 +118,76 @@ export function StripMetadataSettings() {
|
|||||||
if (hasFile && !processing) handleProcess();
|
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 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 renderMetadataSection = (title: string, key: string, data: Record<string, unknown> | null | undefined) => {
|
||||||
const gpsLat = metadata?.gps?.["_latitude"] as number | undefined;
|
if (!data || Object.keys(data).length === 0) return null;
|
||||||
const gpsLon = metadata?.gps?.["_longitude"] as number | undefined;
|
const expanded = expandedSections.has(key);
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSection(key)}
|
||||||
|
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
|
{title}
|
||||||
|
<span className="text-muted-foreground ml-auto">{Object.keys(data).length} fields</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-border px-2.5 py-1.5 space-y-0.5">
|
||||||
|
{Object.entries(data).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex gap-2 text-[11px]">
|
||||||
|
<span className="text-muted-foreground shrink-0">{k}:</span>
|
||||||
|
<span className="text-foreground font-mono break-all">{String(v)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Metadata Display */}
|
{/* Metadata inspection */}
|
||||||
{hasFile && (
|
{inspecting && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Inspecting metadata...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inspectError && <p className="text-xs text-red-500">{inspectError}</p>}
|
||||||
|
|
||||||
|
{metadata && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
|
<label className="text-xs font-medium text-muted-foreground">Current File Metadata</label>
|
||||||
|
|
||||||
{inspecting && (
|
{hasGps && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
Reading metadata...
|
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||||
</div>
|
This image contains GPS location data. Consider stripping it for privacy.
|
||||||
)}
|
|
||||||
|
|
||||||
{inspectError && (
|
|
||||||
<p className="text-[10px] text-red-500">{inspectError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{metadata && !hasAnyMetadata && !inspecting && (
|
|
||||||
<p className="text-xs text-muted-foreground italic py-1">
|
|
||||||
No metadata found in this image.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{metadata && hasAnyMetadata && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{/* GPS warning banner */}
|
|
||||||
{hasGps && gpsLat !== undefined && gpsLon !== undefined && (
|
|
||||||
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
|
|
||||||
<MapPin className="h-3 w-3 text-amber-500 shrink-0" />
|
|
||||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
|
|
||||||
Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasExif && (
|
|
||||||
<CollapsibleSection
|
|
||||||
title="EXIF"
|
|
||||||
badge={`${Object.keys(metadata.exif!).filter(k => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
|
|
||||||
defaultOpen
|
|
||||||
>
|
|
||||||
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasGps && (
|
|
||||||
<CollapsibleSection title="GPS" warning badge={`${Object.keys(metadata.gps!).filter(k => !k.startsWith("_")).length} fields`}>
|
|
||||||
<MetadataGrid data={metadata.gps!} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasIcc && (
|
|
||||||
<CollapsibleSection title="ICC Profile" badge={`${Object.keys(metadata.icc!).length} fields`}>
|
|
||||||
<MetadataGrid data={metadata.icc!} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasXmp && (
|
|
||||||
<CollapsibleSection title="XMP" badge={`${Object.keys(metadata.xmp!).length} fields`}>
|
|
||||||
<MetadataGrid data={metadata.xmp!} />
|
|
||||||
</CollapsibleSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
{sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderMetadataSection("EXIF", "exif", metadata.exif)}
|
||||||
|
{metadata.exifError && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
|
||||||
|
)}
|
||||||
|
{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 && (
|
||||||
|
<p className="text-xs text-muted-foreground">No metadata found in this file.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasFile && hasAnyMetadata && <div className="border-t border-border" />}
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
{/* Strip All */}
|
{/* Strip All */}
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||||
@@ -342,9 +215,6 @@ export function StripMetadataSettings() {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
Strip EXIF (camera info, date, exposure)
|
Strip EXIF (camera info, date, exposure)
|
||||||
{hasExif && !stripAll && (
|
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground">{Object.keys(metadata!.exif!).filter(k => !SKIP_KEYS.has(k)).length} fields</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||||
@@ -356,9 +226,6 @@ export function StripMetadataSettings() {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
Strip GPS (location data)
|
Strip GPS (location data)
|
||||||
{hasGps && !stripAll && (
|
|
||||||
<span className="ml-auto text-[10px] text-amber-500">location found</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card";
|
|||||||
|
|
||||||
export function TextOverlaySettings() {
|
export function TextOverlaySettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("text-overlay");
|
useToolProcessor("text-overlay");
|
||||||
|
|
||||||
const [text, setText] = useState("Your Text Here");
|
const [text, setText] = useState("Your Text Here");
|
||||||
@@ -18,7 +18,12 @@ export function TextOverlaySettings() {
|
|||||||
const [shadow, setShadow] = useState(true);
|
const [shadow, setShadow] = useState(true);
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
processFiles(files, { text, fontSize, color, position, backgroundBox, backgroundColor, shadow });
|
const settings = { text, fontSize, color, position, backgroundBox, backgroundColor, shadow };
|
||||||
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -102,7 +107,7 @@ export function TextOverlaySettings() {
|
|||||||
disabled={!hasFile || processing || !text}
|
disabled={!hasFile || processing || !text}
|
||||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Add Text
|
{files.length > 1 ? `Apply Overlay (${files.length} files)` : "Apply Overlay"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-ri
|
|||||||
|
|
||||||
export function WatermarkTextSettings() {
|
export function WatermarkTextSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("watermark-text");
|
useToolProcessor("watermark-text");
|
||||||
|
|
||||||
const [text, setText] = useState("Sample Watermark");
|
const [text, setText] = useState("Sample Watermark");
|
||||||
@@ -19,7 +19,12 @@ export function WatermarkTextSettings() {
|
|||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
processFiles(files, { text, fontSize, color, opacity, position, rotation });
|
const settings = { text, fontSize, color, opacity, position, rotation };
|
||||||
|
if (files.length > 1) {
|
||||||
|
processAllFiles(files, settings);
|
||||||
|
} else {
|
||||||
|
processFiles(files, settings);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
@@ -106,7 +111,7 @@ export function WatermarkTextSettings() {
|
|||||||
disabled={!hasFile || processing || !text}
|
disabled={!hasFile || processing || !text}
|
||||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
Add Watermark
|
{files.length > 1 ? `Apply Watermark (${files.length} files)` : "Apply Watermark"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -205,8 +205,125 @@ export function useToolProcessor(toolId: string) {
|
|||||||
[toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
[toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const processAllFiles = useCallback(
|
||||||
|
async (files: File[], settings: Record<string, unknown>) => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setError("No files selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length === 1) {
|
||||||
|
processFiles(files, settings);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { updateEntry, setBatchZip } = useFileStore.getState();
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setProcessing(true);
|
||||||
|
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
elapsedRef.current = setInterval(() => {
|
||||||
|
setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const clientJobId = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Open SSE before upload for real-time progress
|
||||||
|
try {
|
||||||
|
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === "batch") {
|
||||||
|
const pct = data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
phase: "processing",
|
||||||
|
percent: pct,
|
||||||
|
stage: data.currentFile
|
||||||
|
? `Processing ${data.currentFile} (${data.completedFiles}/${data.totalFiles})`
|
||||||
|
: `Processing ${data.completedFiles}/${data.totalFiles}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed SSE */ }
|
||||||
|
};
|
||||||
|
es.onerror = () => { es.close(); eventSourceRef.current = null; };
|
||||||
|
} catch { /* SSE failed, proceed without */ }
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) formData.append("file", file);
|
||||||
|
formData.append("settings", JSON.stringify(settings));
|
||||||
|
formData.append("clientJobId", clientJobId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
const response = await fetch(`/api/v1/tools/${toolId}/batch`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
|
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
let errorMsg: string;
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
errorMsg = body.error || body.details || `Batch processing failed: ${response.status}`;
|
||||||
|
} catch { errorMsg = `Batch processing failed: ${response.status}`; }
|
||||||
|
setError(errorMsg);
|
||||||
|
setProcessing(false);
|
||||||
|
setProgress(IDLE_PROGRESS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBlob = await response.blob();
|
||||||
|
setBatchZip(zipBlob, `batch-${toolId}.zip`);
|
||||||
|
|
||||||
|
// Extract files from ZIP using fflate
|
||||||
|
const { unzipSync } = await import("fflate");
|
||||||
|
const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer);
|
||||||
|
const extracted = unzipSync(zipBuffer);
|
||||||
|
|
||||||
|
const fileOrder = response.headers.get("X-File-Order")?.split(",") ?? [];
|
||||||
|
const entries = useFileStore.getState().entries;
|
||||||
|
const extractedNames = Object.keys(extracted);
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
let zipName: string | undefined;
|
||||||
|
if (fileOrder[i] && extracted[fileOrder[i]]) {
|
||||||
|
zipName = fileOrder[i];
|
||||||
|
} else {
|
||||||
|
zipName = extractedNames.find((n) => n === entries[i].file.name) ?? extractedNames[i];
|
||||||
|
}
|
||||||
|
if (zipName && extracted[zipName]) {
|
||||||
|
const blob = new Blob([extracted[zipName] as BlobPart]);
|
||||||
|
updateEntry(i, { processedUrl: URL.createObjectURL(blob), processedSize: blob.size, status: "completed" });
|
||||||
|
} else {
|
||||||
|
updateEntry(i, { status: "failed", error: "File not found in batch results" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessing(false);
|
||||||
|
setProgress(IDLE_PROGRESS);
|
||||||
|
} catch (err) {
|
||||||
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
|
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
|
||||||
|
setError(err instanceof Error ? err.message : "Batch processing failed");
|
||||||
|
setProcessing(false);
|
||||||
|
setProgress(IDLE_PROGRESS);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toolId, processFiles, setProcessing, setError],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processFiles,
|
processFiles,
|
||||||
|
processAllFiles,
|
||||||
processing,
|
processing,
|
||||||
error,
|
error,
|
||||||
downloadUrl: processedUrl,
|
downloadUrl: processedUrl,
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { useMemo, useCallback, useState } from "react";
|
|||||||
import { TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { Dropzone } from "@/components/common/dropzone";
|
import { Dropzone } from "@/components/common/dropzone";
|
||||||
import { ImageViewer } from "@/components/common/image-viewer";
|
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
|
||||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
|
||||||
import { ReviewPanel } from "@/components/common/review-panel";
|
import { ReviewPanel } from "@/components/common/review-panel";
|
||||||
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
|
||||||
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
@@ -52,7 +50,7 @@ import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
|
|||||||
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
|
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
|
||||||
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
|
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
|
||||||
import * as icons from "lucide-react";
|
import * as icons from "lucide-react";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2, Download } from "lucide-react";
|
||||||
|
|
||||||
const COLOR_TOOL_IDS = new Set([
|
const COLOR_TOOL_IDS = new Set([
|
||||||
"brightness-contrast",
|
"brightness-contrast",
|
||||||
@@ -63,7 +61,6 @@ const COLOR_TOOL_IDS = new Set([
|
|||||||
|
|
||||||
// Tools that don't need a file dropzone (they generate content or have custom UI)
|
// Tools that don't need a file dropzone (they generate content or have custom UI)
|
||||||
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
|
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
|
||||||
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop"]);
|
|
||||||
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
|
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
|
||||||
|
|
||||||
function ToolSettingsPanel({
|
function ToolSettingsPanel({
|
||||||
@@ -128,11 +125,13 @@ function FileSelectionInfo({
|
|||||||
selectedFileName,
|
selectedFileName,
|
||||||
selectedFileSize,
|
selectedFileSize,
|
||||||
onClear,
|
onClear,
|
||||||
|
onAddMore,
|
||||||
}: {
|
}: {
|
||||||
files: File[];
|
files: File[];
|
||||||
selectedFileName: string | null;
|
selectedFileName: string | null;
|
||||||
selectedFileSize: number | null;
|
selectedFileSize: number | null;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
onAddMore: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -144,26 +143,16 @@ function FileSelectionInfo({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-foreground">Files ({files.length})</span>
|
||||||
|
<button onClick={onAddMore} className="text-xs text-primary hover:text-primary/80">+ Add more</button>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-foreground bg-muted rounded px-2 py-1.5">
|
<div className="flex items-center gap-1.5 text-xs text-foreground bg-muted rounded px-2 py-1.5">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||||
<span className="truncate flex-1">
|
<span className="truncate flex-1">{selectedFileName ?? files[0].name}</span>
|
||||||
Selected: {selectedFileName ?? files[0].name}
|
<span className="text-muted-foreground shrink-0 ml-1">{formatFileSize(selectedFileSize ?? files[0].size)}</span>
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground shrink-0 ml-1">
|
|
||||||
{formatFileSize(selectedFileSize ?? files[0].size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{files.length > 1 && (
|
<button onClick={onClear} className="text-xs text-muted-foreground hover:text-foreground">Clear all</button>
|
||||||
<p className="text-xs text-muted-foreground px-1">
|
|
||||||
+{files.length - 1} more file{files.length > 2 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClear}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,7 +162,9 @@ export function ToolPage() {
|
|||||||
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
||||||
const {
|
const {
|
||||||
files,
|
files,
|
||||||
|
entries,
|
||||||
setFiles,
|
setFiles,
|
||||||
|
addFiles,
|
||||||
reset,
|
reset,
|
||||||
processedUrl,
|
processedUrl,
|
||||||
originalBlobUrl,
|
originalBlobUrl,
|
||||||
@@ -182,6 +173,8 @@ export function ToolPage() {
|
|||||||
selectedFileName,
|
selectedFileName,
|
||||||
selectedFileSize,
|
selectedFileSize,
|
||||||
undoProcessing,
|
undoProcessing,
|
||||||
|
batchZipBlob,
|
||||||
|
batchZipFilename,
|
||||||
} = useFileStore();
|
} = useFileStore();
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
|
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
|
||||||
@@ -199,6 +192,28 @@ export function ToolPage() {
|
|||||||
undoProcessing();
|
undoProcessing();
|
||||||
}, [undoProcessing]);
|
}, [undoProcessing]);
|
||||||
|
|
||||||
|
const handleAddMore = useCallback(() => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.multiple = true;
|
||||||
|
input.accept = "image/*";
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const newFiles = Array.from((e.target as HTMLInputElement).files || []);
|
||||||
|
if (newFiles.length > 0) addFiles(newFiles);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}, [addFiles]);
|
||||||
|
|
||||||
|
const handleDownloadAll = useCallback(() => {
|
||||||
|
if (!batchZipBlob) return;
|
||||||
|
const url = URL.createObjectURL(batchZipBlob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = batchZipFilename ?? "processed-images.zip";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [batchZipBlob, batchZipFilename]);
|
||||||
|
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -264,6 +279,7 @@ export function ToolPage() {
|
|||||||
selectedFileName={selectedFileName}
|
selectedFileName={selectedFileName}
|
||||||
selectedFileSize={selectedFileSize}
|
selectedFileSize={selectedFileSize}
|
||||||
onClear={reset}
|
onClear={reset}
|
||||||
|
onAddMore={handleAddMore}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -295,45 +311,14 @@ export function ToolPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
{/* Main area: Dropzone / MultiImageViewer */}
|
||||||
<div className="flex-1 flex items-center justify-center p-4">
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
{isNoDropzone ? (
|
{isNoDropzone ? (
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
<p className="text-sm">Configure settings and generate.</p>
|
<p className="text-sm">Configure settings and generate.</p>
|
||||||
</div>
|
</div>
|
||||||
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
|
) : hasFile ? (
|
||||||
<SideBySideComparison
|
<MultiImageViewer />
|
||||||
beforeSrc={originalBlobUrl}
|
|
||||||
afterSrc={processedUrl}
|
|
||||||
beforeSize={originalSize ?? undefined}
|
|
||||||
afterSize={processedSize ?? undefined}
|
|
||||||
/>
|
|
||||||
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
|
|
||||||
<ImageViewer
|
|
||||||
src={processedUrl}
|
|
||||||
filename={processedFileName}
|
|
||||||
fileSize={processedSize ?? 0}
|
|
||||||
/>
|
|
||||||
) : hasProcessed && originalBlobUrl ? (
|
|
||||||
<BeforeAfterSlider
|
|
||||||
beforeSrc={originalBlobUrl}
|
|
||||||
afterSrc={processedUrl}
|
|
||||||
beforeSize={originalSize ?? undefined}
|
|
||||||
afterSize={processedSize ?? undefined}
|
|
||||||
/>
|
|
||||||
) : hasFile && originalBlobUrl ? (
|
|
||||||
<ImageViewer
|
|
||||||
src={originalBlobUrl}
|
|
||||||
filename={selectedFileName ?? files[0].name}
|
|
||||||
fileSize={selectedFileSize ?? files[0].size}
|
|
||||||
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
|
|
||||||
? {
|
|
||||||
cssRotate: previewTransform.rotate,
|
|
||||||
cssFlipH: previewTransform.flipH,
|
|
||||||
cssFlipV: previewTransform.flipV,
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onFiles={handleFiles}
|
onFiles={handleFiles}
|
||||||
@@ -374,6 +359,7 @@ export function ToolPage() {
|
|||||||
selectedFileName={selectedFileName}
|
selectedFileName={selectedFileName}
|
||||||
selectedFileSize={selectedFileSize}
|
selectedFileSize={selectedFileSize}
|
||||||
onClear={reset}
|
onClear={reset}
|
||||||
|
onAddMore={handleAddMore}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -403,47 +389,30 @@ export function ToolPage() {
|
|||||||
currentToolId={tool.id}
|
currentToolId={tool.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Batch download */}
|
||||||
|
{entries.length > 1 && hasProcessed && batchZipBlob && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border-t border-border pt-2" />
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAll}
|
||||||
|
className="w-full py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Download All (ZIP)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main area: Dropzone / Image Viewer / Before-After */}
|
{/* Main area: Dropzone / MultiImageViewer */}
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
{isNoDropzone ? (
|
{isNoDropzone ? (
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
<p className="text-sm">Configure settings and generate.</p>
|
<p className="text-sm">Configure settings and generate.</p>
|
||||||
</div>
|
</div>
|
||||||
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
|
) : hasFile ? (
|
||||||
<SideBySideComparison
|
<MultiImageViewer />
|
||||||
beforeSrc={originalBlobUrl}
|
|
||||||
afterSrc={processedUrl}
|
|
||||||
beforeSize={originalSize ?? undefined}
|
|
||||||
afterSize={processedSize ?? undefined}
|
|
||||||
/>
|
|
||||||
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
|
|
||||||
<ImageViewer
|
|
||||||
src={processedUrl}
|
|
||||||
filename={processedFileName}
|
|
||||||
fileSize={processedSize ?? 0}
|
|
||||||
/>
|
|
||||||
) : hasProcessed && originalBlobUrl ? (
|
|
||||||
<BeforeAfterSlider
|
|
||||||
beforeSrc={originalBlobUrl}
|
|
||||||
afterSrc={processedUrl}
|
|
||||||
beforeSize={originalSize ?? undefined}
|
|
||||||
afterSize={processedSize ?? undefined}
|
|
||||||
/>
|
|
||||||
) : hasFile && originalBlobUrl ? (
|
|
||||||
<ImageViewer
|
|
||||||
src={originalBlobUrl}
|
|
||||||
filename={selectedFileName ?? files[0].name}
|
|
||||||
fileSize={selectedFileSize ?? files[0].size}
|
|
||||||
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
|
|
||||||
? {
|
|
||||||
cssRotate: previewTransform.rotate,
|
|
||||||
cssFlipH: previewTransform.flipH,
|
|
||||||
cssFlipV: previewTransform.flipV,
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onFiles={handleFiles}
|
onFiles={handleFiles}
|
||||||
|
|||||||
@@ -1,83 +1,258 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
interface FileState {
|
export interface FileEntry {
|
||||||
files: File[];
|
file: File;
|
||||||
jobId: string | null;
|
blobUrl: string;
|
||||||
processedUrl: string | null;
|
processedUrl: string | null;
|
||||||
/** Blob URL for the original image (for before/after comparison). */
|
processedSize: number | null;
|
||||||
originalBlobUrl: string | null;
|
originalSize: number;
|
||||||
|
status: "pending" | "processing" | "completed" | "failed";
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createEntry(file: File): FileEntry {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
blobUrl: URL.createObjectURL(file),
|
||||||
|
processedUrl: null,
|
||||||
|
processedSize: null,
|
||||||
|
originalSize: file.size,
|
||||||
|
status: "pending",
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeEntries(entries: FileEntry[]): void {
|
||||||
|
for (const entry of entries) {
|
||||||
|
URL.revokeObjectURL(entry.blobUrl);
|
||||||
|
if (entry.processedUrl) URL.revokeObjectURL(entry.processedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Store
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FileState {
|
||||||
|
entries: FileEntry[];
|
||||||
|
selectedIndex: number;
|
||||||
|
batchZipBlob: Blob | null;
|
||||||
|
batchZipFilename: string | null;
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
originalSize: number | null;
|
|
||||||
processedSize: number | null;
|
// Backward compat getters (computed from entries + selectedIndex)
|
||||||
selectedFileName: string | null;
|
readonly files: File[];
|
||||||
selectedFileSize: number | null;
|
readonly currentEntry: FileEntry | undefined;
|
||||||
|
readonly hasFiles: boolean;
|
||||||
|
readonly allProcessed: boolean;
|
||||||
|
readonly selectedFileName: string | null;
|
||||||
|
readonly selectedFileSize: number | null;
|
||||||
|
readonly originalBlobUrl: string | null;
|
||||||
|
readonly processedUrl: string | null;
|
||||||
|
readonly originalSize: number | null;
|
||||||
|
readonly processedSize: number | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
setFiles: (files: File[]) => void;
|
setFiles: (files: File[]) => void;
|
||||||
setJobId: (id: string) => void;
|
addFiles: (files: File[]) => void;
|
||||||
setProcessedUrl: (url: string | null) => void;
|
removeFile: (index: number) => void;
|
||||||
|
setSelectedIndex: (index: number) => void;
|
||||||
|
navigateNext: () => void;
|
||||||
|
navigatePrev: () => void;
|
||||||
|
updateEntry: (index: number, patch: Partial<FileEntry>) => void;
|
||||||
|
setBatchZip: (blob: Blob, filename: string) => void;
|
||||||
setProcessing: (v: boolean) => void;
|
setProcessing: (v: boolean) => void;
|
||||||
setError: (e: string | null) => void;
|
setError: (e: string | null) => void;
|
||||||
|
setJobId: (id: string) => void;
|
||||||
|
setProcessedUrl: (url: string | null) => void;
|
||||||
setSizes: (original: number, processed: number) => void;
|
setSizes: (original: number, processed: number) => void;
|
||||||
/** Clear processed result but keep the original uploaded file. */
|
|
||||||
undoProcessing: () => void;
|
undoProcessing: () => void;
|
||||||
reset: () => 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<FileState>((set, get) => ({
|
export const useFileStore = create<FileState>((set, get) => ({
|
||||||
files: [],
|
entries: [],
|
||||||
jobId: null,
|
selectedIndex: 0,
|
||||||
processedUrl: null,
|
batchZipBlob: null,
|
||||||
originalBlobUrl: null,
|
batchZipFilename: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
error: null,
|
error: null,
|
||||||
originalSize: null,
|
|
||||||
processedSize: null,
|
// Initial derived values (empty state)
|
||||||
selectedFileName: null,
|
...deriveCompat([], 0),
|
||||||
selectedFileSize: null,
|
|
||||||
|
// -- Actions --------------------------------------------------------------
|
||||||
|
|
||||||
setFiles: (files) => {
|
setFiles: (files) => {
|
||||||
// Revoke old blob URL if any
|
revokeEntries(get().entries);
|
||||||
const old = get().originalBlobUrl;
|
const entries = files.map(createEntry);
|
||||||
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;
|
|
||||||
set({
|
set({
|
||||||
files,
|
entries,
|
||||||
|
selectedIndex: 0,
|
||||||
error: null,
|
error: null,
|
||||||
originalBlobUrl: blobUrl,
|
...deriveCompat(entries, 0),
|
||||||
selectedFileName: firstName,
|
|
||||||
selectedFileSize: firstSize,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
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 }),
|
setProcessing: (v) => set({ processing: v }),
|
||||||
|
|
||||||
setError: (e) => set({ error: e, processing: false }),
|
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: () => {
|
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,
|
processedUrl: null,
|
||||||
jobId: null,
|
|
||||||
processedSize: null,
|
processedSize: null,
|
||||||
|
status: "pending" as const,
|
||||||
error: null,
|
error: null,
|
||||||
|
}));
|
||||||
|
set({
|
||||||
|
entries: resetEntries,
|
||||||
|
error: null,
|
||||||
|
...deriveCompat(resetEntries, selectedIndex),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
const old = get().originalBlobUrl;
|
revokeEntries(get().entries);
|
||||||
if (old) URL.revokeObjectURL(old);
|
|
||||||
set({
|
set({
|
||||||
files: [],
|
entries: [],
|
||||||
jobId: null,
|
selectedIndex: 0,
|
||||||
processedUrl: null,
|
batchZipBlob: null,
|
||||||
originalBlobUrl: null,
|
batchZipFilename: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
error: null,
|
error: null,
|
||||||
originalSize: null,
|
...deriveCompat([], 0),
|
||||||
processedSize: null,
|
|
||||||
selectedFileName: null,
|
|
||||||
selectedFileSize: null,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: stirling-image
|
||||||
|
|
||||||
services:
|
services:
|
||||||
stirling-image:
|
stirling-image:
|
||||||
build:
|
build:
|
||||||
|
|||||||
Generated
+8
@@ -174,6 +174,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.2
|
||||||
|
version: 0.8.2
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.469.0
|
specifier: ^0.469.0
|
||||||
version: 0.469.0(react@19.2.4)
|
version: 0.469.0(react@19.2.4)
|
||||||
@@ -3260,6 +3263,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.2:
|
||||||
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
figures@2.0.0:
|
figures@2.0.0:
|
||||||
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
|
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -8283,6 +8289,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
figures@2.0.0:
|
figures@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 1.0.5
|
escape-string-regexp: 1.0.5
|
||||||
|
|||||||
+327
-219
@@ -82,295 +82,403 @@ function failResponse(status: number) {
|
|||||||
|
|
||||||
describe("FileStore", () => {
|
describe("FileStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset the store to initial state before every test.
|
|
||||||
// Zustand keeps state across calls, so we manually reset.
|
|
||||||
useFileStore.getState().reset();
|
useFileStore.getState().reset();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// After reset, createObjectURL/revokeObjectURL calls are from reset itself;
|
|
||||||
// clear them so each test starts clean.
|
|
||||||
createObjectURL.mockClear();
|
createObjectURL.mockClear();
|
||||||
revokeObjectURL.mockClear();
|
revokeObjectURL.mockClear();
|
||||||
|
// Reset the mock to return incrementing URLs
|
||||||
|
let urlCounter = 0;
|
||||||
|
createObjectURL.mockImplementation(
|
||||||
|
(_obj: Blob | MediaSource) => `blob:url-${++urlCounter}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- Initial state -------------------------------------------------------
|
// -- Initial state -------------------------------------------------------
|
||||||
|
|
||||||
it("has correct initial state (everything null/empty/false)", () => {
|
it("has correct initial state", () => {
|
||||||
const s = useFileStore.getState();
|
const s = useFileStore.getState();
|
||||||
expect(s.files).toEqual([]);
|
expect(s.entries).toEqual([]);
|
||||||
expect(s.jobId).toBeNull();
|
expect(s.selectedIndex).toBe(0);
|
||||||
expect(s.processedUrl).toBeNull();
|
expect(s.batchZipBlob).toBeNull();
|
||||||
expect(s.originalBlobUrl).toBeNull();
|
expect(s.batchZipFilename).toBeNull();
|
||||||
expect(s.processing).toBe(false);
|
expect(s.processing).toBe(false);
|
||||||
expect(s.error).toBeNull();
|
expect(s.error).toBeNull();
|
||||||
expect(s.originalSize).toBeNull();
|
|
||||||
expect(s.processedSize).toBeNull();
|
|
||||||
expect(s.selectedFileName).toBeNull();
|
|
||||||
expect(s.selectedFileSize).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- setFiles -------------------------------------------------------------
|
// -- setFiles -------------------------------------------------------------
|
||||||
|
|
||||||
it("setFiles stores files, creates blob URL, sets selectedFileName/Size, clears error", () => {
|
it("setFiles creates entries with blob URLs", () => {
|
||||||
// Seed an error first so we can verify it gets cleared
|
const f1 = makeFile("a.png", 100);
|
||||||
useFileStore.getState().setError("old error");
|
const f2 = makeFile("b.png", 200);
|
||||||
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);
|
|
||||||
useFileStore.getState().setFiles([f1, f2]);
|
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();
|
const s = useFileStore.getState();
|
||||||
expect(s.error).toBe("something broke");
|
expect(s.entries).toHaveLength(2);
|
||||||
expect(s.processing).toBe(false); // critical side-effect
|
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", () => {
|
it("setFiles revokes old blob URLs", () => {
|
||||||
useFileStore.getState().setProcessing(true);
|
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||||||
useFileStore.getState().setError(null);
|
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().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();
|
const s = useFileStore.getState();
|
||||||
expect(s.originalSize).toBe(5000);
|
expect(s.batchZipBlob).toBe(blob);
|
||||||
expect(s.processedSize).toBe(2500);
|
expect(s.batchZipFilename).toBe("results.zip");
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- undoProcessing -------------------------------------------------------
|
// -- undoProcessing -------------------------------------------------------
|
||||||
|
|
||||||
it("undoProcessing clears processedUrl, jobId, processedSize, error but KEEPS files and originalBlobUrl", () => {
|
it("undoProcessing resets all entries to pending and revokes processed blob URLs", () => {
|
||||||
createObjectURL.mockReturnValueOnce("blob:orig");
|
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
|
||||||
|
useFileStore.getState().updateEntry(0, {
|
||||||
// Set up full state
|
status: "completed",
|
||||||
const file = makeFile("keep-me.png", 3000);
|
processedUrl: "blob:proc-a",
|
||||||
useFileStore.getState().setFiles([file]);
|
processedSize: 50,
|
||||||
useFileStore.getState().setJobId("job-1");
|
});
|
||||||
useFileStore.getState().setProcessedUrl("blob:result");
|
useFileStore.getState().updateEntry(1, {
|
||||||
useFileStore.getState().setSizes(3000, 1500);
|
status: "completed",
|
||||||
useFileStore.getState().setError("transient error");
|
processedUrl: "blob:proc-b",
|
||||||
|
processedSize: 75,
|
||||||
|
});
|
||||||
|
revokeObjectURL.mockClear();
|
||||||
|
|
||||||
useFileStore.getState().undoProcessing();
|
useFileStore.getState().undoProcessing();
|
||||||
|
|
||||||
const s = useFileStore.getState();
|
const s = useFileStore.getState();
|
||||||
// Cleared
|
// All entries reset to pending
|
||||||
expect(s.processedUrl).toBeNull();
|
expect(s.entries[0].status).toBe("pending");
|
||||||
expect(s.jobId).toBeNull();
|
expect(s.entries[0].processedUrl).toBeNull();
|
||||||
expect(s.processedSize).toBeNull();
|
expect(s.entries[0].processedSize).toBeNull();
|
||||||
expect(s.error).toBeNull();
|
expect(s.entries[0].error).toBeNull();
|
||||||
// Preserved
|
expect(s.entries[1].status).toBe("pending");
|
||||||
expect(s.files).toHaveLength(1);
|
expect(s.entries[1].processedUrl).toBeNull();
|
||||||
expect(s.files[0]).toBe(file);
|
// Processed URLs revoked
|
||||||
expect(s.originalBlobUrl).toBe("blob:orig");
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-a");
|
||||||
expect(s.selectedFileName).toBe("keep-me.png");
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-b");
|
||||||
expect(s.selectedFileSize).toBe(3000);
|
|
||||||
// originalSize is NOT cleared by undoProcessing (only processedSize is)
|
|
||||||
expect(s.originalSize).toBe(3000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("undoProcessing does NOT revoke the originalBlobUrl", () => {
|
it("undoProcessing keeps original blob URLs", () => {
|
||||||
createObjectURL.mockReturnValueOnce("blob:keep-alive");
|
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||||||
useFileStore.getState().setFiles([makeFile("x.png")]);
|
const origUrl = useFileStore.getState().entries[0].blobUrl;
|
||||||
revokeObjectURL.mockClear();
|
revokeObjectURL.mockClear();
|
||||||
|
|
||||||
useFileStore.getState().undoProcessing();
|
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 ----------------------------------------------------------------
|
// -- reset ----------------------------------------------------------------
|
||||||
|
|
||||||
it("reset clears everything and revokes the blob URL", () => {
|
it("reset clears everything and revokes all blob URLs", () => {
|
||||||
createObjectURL.mockReturnValueOnce("blob:to-revoke");
|
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
|
||||||
useFileStore.getState().setFiles([makeFile("doomed.png")]);
|
useFileStore.getState().updateEntry(0, { processedUrl: "blob:proc" });
|
||||||
useFileStore.getState().setJobId("job-x");
|
const origUrl0 = useFileStore.getState().entries[0].blobUrl;
|
||||||
useFileStore.getState().setProcessedUrl("blob:proc");
|
const origUrl1 = useFileStore.getState().entries[1].blobUrl;
|
||||||
useFileStore.getState().setProcessing(true);
|
|
||||||
useFileStore.getState().setError("oops");
|
|
||||||
useFileStore.getState().setSizes(100, 50);
|
|
||||||
revokeObjectURL.mockClear();
|
revokeObjectURL.mockClear();
|
||||||
|
|
||||||
useFileStore.getState().reset();
|
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();
|
const s = useFileStore.getState();
|
||||||
expect(s.files).toEqual([]);
|
expect(s.entries).toEqual([]);
|
||||||
expect(s.jobId).toBeNull();
|
expect(s.selectedIndex).toBe(0);
|
||||||
expect(s.processedUrl).toBeNull();
|
expect(s.batchZipBlob).toBeNull();
|
||||||
expect(s.originalBlobUrl).toBeNull();
|
expect(s.batchZipFilename).toBeNull();
|
||||||
expect(s.processing).toBe(false);
|
expect(s.processing).toBe(false);
|
||||||
expect(s.error).toBeNull();
|
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", () => {
|
it("reset with no entries does not call revokeObjectURL", () => {
|
||||||
// Start from a clean state (no files set)
|
|
||||||
revokeObjectURL.mockClear();
|
revokeObjectURL.mockClear();
|
||||||
useFileStore.getState().reset();
|
useFileStore.getState().reset();
|
||||||
expect(revokeObjectURL).not.toHaveBeenCalled();
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- State transition sequences -------------------------------------------
|
// -- Backward compat getters ----------------------------------------------
|
||||||
|
|
||||||
it("setFiles -> setProcessing(true) -> setError -> processing is false", () => {
|
it("files getter maps entries to File[]", () => {
|
||||||
useFileStore.getState().setFiles([makeFile("t.png")]);
|
const f1 = makeFile("a.png");
|
||||||
useFileStore.getState().setProcessing(true);
|
const f2 = makeFile("b.png");
|
||||||
expect(useFileStore.getState().processing).toBe(true);
|
useFileStore.getState().setFiles([f1, f2]);
|
||||||
|
|
||||||
useFileStore.getState().setError("fail");
|
const s = useFileStore.getState();
|
||||||
expect(useFileStore.getState().processing).toBe(false);
|
expect(s.files).toEqual([f1, f2]);
|
||||||
expect(useFileStore.getState().error).toBe("fail");
|
expect(s.files[0]).toBe(f1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setFiles -> setProcessing(true) -> setProcessedUrl -> setProcessing(false) (happy path)", () => {
|
it("currentEntry returns entry at selectedIndex", () => {
|
||||||
useFileStore.getState().setFiles([makeFile("t.png")]);
|
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
|
||||||
useFileStore.getState().setProcessing(true);
|
useFileStore.getState().setSelectedIndex(1);
|
||||||
expect(useFileStore.getState().processing).toBe(true);
|
|
||||||
|
|
||||||
useFileStore.getState().setProcessedUrl("blob:done");
|
expect(useFileStore.getState().currentEntry?.file.name).toBe("b.png");
|
||||||
// processedUrl does NOT auto-clear processing
|
});
|
||||||
expect(useFileStore.getState().processing).toBe(true);
|
|
||||||
|
|
||||||
useFileStore.getState().setProcessing(false);
|
it("currentEntry returns undefined when no entries", () => {
|
||||||
expect(useFileStore.getState().processing).toBe(false);
|
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");
|
expect(useFileStore.getState().processedUrl).toBe("blob:done");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rapid setFiles calls only keep the latest state and revoke each prior URL", () => {
|
it("originalSize returns current entry originalSize", () => {
|
||||||
createObjectURL
|
useFileStore.getState().setFiles([makeFile("a.png", 999)]);
|
||||||
.mockReturnValueOnce("blob:1")
|
expect(useFileStore.getState().originalSize).toBe(999);
|
||||||
.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("setError during processing, then undoProcessing, then retry cycle works", () => {
|
it("processedSize returns current entry processedSize", () => {
|
||||||
useFileStore.getState().setFiles([makeFile("retry.png")]);
|
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||||||
useFileStore.getState().setProcessing(true);
|
useFileStore.getState().updateEntry(0, { processedSize: 500 });
|
||||||
useFileStore.getState().setError("timeout");
|
expect(useFileStore.getState().processedSize).toBe(500);
|
||||||
expect(useFileStore.getState().processing).toBe(false);
|
});
|
||||||
|
|
||||||
useFileStore.getState().undoProcessing();
|
it("hasFiles returns true when entries exist", () => {
|
||||||
expect(useFileStore.getState().error).toBeNull();
|
expect(useFileStore.getState().hasFiles).toBe(false);
|
||||||
expect(useFileStore.getState().files).toHaveLength(1);
|
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||||||
|
expect(useFileStore.getState().hasFiles).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Retry
|
it("allProcessed returns true when all entries are completed", () => {
|
||||||
useFileStore.getState().setProcessing(true);
|
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
|
||||||
expect(useFileStore.getState().processing).toBe(true);
|
expect(useFileStore.getState().allProcessed).toBe(false);
|
||||||
useFileStore.getState().setProcessedUrl("blob:retry-ok");
|
|
||||||
useFileStore.getState().setProcessing(false);
|
useFileStore.getState().updateEntry(0, { status: "completed" });
|
||||||
expect(useFileStore.getState().processedUrl).toBe("blob:retry-ok");
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user