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:
@@ -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)
|
||||
: 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 (
|
||||
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
||||
{/* Side-by-side images */}
|
||||
@@ -36,11 +45,14 @@ export function SideBySideComparison({
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Original
|
||||
</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
|
||||
src={beforeSrc}
|
||||
alt="Original"
|
||||
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
@@ -51,23 +63,26 @@ export function SideBySideComparison({
|
||||
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
||||
{beforeDims && (
|
||||
<p>
|
||||
{beforeDims.w} × {beforeDims.h}
|
||||
{beforeDims.w} x {beforeDims.h}
|
||||
</p>
|
||||
)}
|
||||
{beforeSize != null && <p>{formatSize(beforeSize)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processed */}
|
||||
{/* Resized */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Processed
|
||||
Resized
|
||||
</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
|
||||
src={afterSrc}
|
||||
alt="Processed"
|
||||
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
||||
alt="Resized"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
@@ -78,7 +93,7 @@ export function SideBySideComparison({
|
||||
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
||||
{afterDims && (
|
||||
<p>
|
||||
{afterDims.w} × {afterDims.h}
|
||||
{afterDims.w} x {afterDims.h}
|
||||
</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() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("border");
|
||||
|
||||
const [borderWidth, setBorderWidth] = useState(10);
|
||||
@@ -16,7 +16,12 @@ export function BorderSettings() {
|
||||
const [shadowBlur, setShadowBlur] = useState(0);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { borderWidth, borderColor, cornerRadius, padding, shadowBlur });
|
||||
const settings = { borderWidth, borderColor, cornerRadius, padding, shadowBlur };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -84,7 +89,7 @@ export function BorderSettings() {
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Add Border
|
||||
{files.length > 1 ? `Apply Border (${files.length} files)` : "Apply Border"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ColorSettingsProps {
|
||||
|
||||
export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor(toolId);
|
||||
|
||||
const [tab, setTab] = useState<Tab>(() => {
|
||||
@@ -37,7 +37,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
const [effect, setEffect] = useState<Effect>("none");
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
const settings = {
|
||||
brightness,
|
||||
contrast,
|
||||
saturation,
|
||||
@@ -45,7 +45,12 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
green,
|
||||
blue,
|
||||
effect,
|
||||
});
|
||||
};
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -215,7 +220,7 @@ export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
disabled={!hasFile || !hasChanges || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Apply
|
||||
{files.length > 1 ? `Apply (${files.length} files)` : "Apply"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ type CompressMode = "quality" | "targetSize";
|
||||
|
||||
export function CompressSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("compress");
|
||||
|
||||
const [mode, setMode] = useState<CompressMode>("quality");
|
||||
@@ -22,7 +22,11 @@ export function CompressSettings() {
|
||||
} else {
|
||||
settings.targetSizeKb = Number(targetSizeKb);
|
||||
}
|
||||
processFiles(files, settings);
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -124,7 +128,7 @@ export function CompressSettings() {
|
||||
disabled={!hasFile || !canProcess || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Compress
|
||||
{files.length > 1 ? `Compress (${files.length} files)` : "Compress"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
|
||||
|
||||
export function ConvertSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("convert");
|
||||
|
||||
const [format, setFormat] = useState<string>("png");
|
||||
@@ -28,7 +28,11 @@ export function ConvertSettings() {
|
||||
if (isLossy) {
|
||||
settings.quality = quality;
|
||||
}
|
||||
processFiles(files, settings);
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -118,7 +122,7 @@ export function ConvertSettings() {
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Convert
|
||||
{files.length > 1 ? `Convert (${files.length} files)` : "Convert"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const ASPECT_PRESETS = [
|
||||
|
||||
export function CropSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("crop");
|
||||
|
||||
const [left, setLeft] = useState("0");
|
||||
@@ -31,12 +31,17 @@ export function CropSettings() {
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
const settings = {
|
||||
left: Number(left),
|
||||
top: Number(top),
|
||||
width: Number(width),
|
||||
height: Number(height),
|
||||
});
|
||||
};
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -141,7 +146,7 @@ export function CropSettings() {
|
||||
disabled={!hasFile || !hasSize || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Crop
|
||||
{files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProgressCard } from "@/components/common/progress-card";
|
||||
|
||||
export function ReplaceColorSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("replace-color");
|
||||
|
||||
const [sourceColor, setSourceColor] = useState("#FF0000");
|
||||
@@ -15,7 +15,12 @@ export function ReplaceColorSettings() {
|
||||
const [tolerance, setTolerance] = useState(30);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { sourceColor, targetColor, makeTransparent, tolerance });
|
||||
const settings = { sourceColor, targetColor, makeTransparent, tolerance };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -81,7 +86,7 @@ export function ReplaceColorSettings() {
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Replace Color
|
||||
{files.length > 1 ? `Replace Color (${files.length} files)` : "Replace Color"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
||||
|
||||
export function ResizeSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("resize");
|
||||
|
||||
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||
const [tab, setTab] = useState<ResizeTab>("presets");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [width, setWidth] = useState<string>("");
|
||||
const [height, setHeight] = useState<string>("");
|
||||
@@ -56,7 +56,11 @@ export function ResizeSettings() {
|
||||
settings.withoutEnlargement = withoutEnlargement;
|
||||
}
|
||||
|
||||
processFiles(files, settings);
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -80,15 +84,15 @@ export function ResizeSettings() {
|
||||
{/* Tab selector */}
|
||||
<div>
|
||||
<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")}>
|
||||
Custom Size
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||
Scale
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||||
Presets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -252,7 +256,7 @@ export function ResizeSettings() {
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Resize
|
||||
{files.length > 1 ? `Resize (${files.length} files)` : "Resize"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import {
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RotateCw,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
RotateCcw as ResetIcon,
|
||||
} from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
|
||||
@@ -23,7 +22,7 @@ interface RotateSettingsProps {
|
||||
|
||||
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("rotate");
|
||||
|
||||
const [angle, setAngle] = useState(0);
|
||||
@@ -35,29 +34,20 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
||||
}, [angle, flipH, flipV, onPreviewTransform]);
|
||||
|
||||
const rotateLeft = () => setAngle((a) => {
|
||||
const next = a - 90;
|
||||
return next < -180 ? next + 360 : next;
|
||||
});
|
||||
const rotateRight = () => setAngle((a) => {
|
||||
const next = a + 90;
|
||||
return next > 180 ? next - 360 : next;
|
||||
});
|
||||
|
||||
const setAngleClamped = useCallback((val: number) => {
|
||||
// Clamp to -180..180
|
||||
const clamped = Math.max(-180, Math.min(180, Math.round(val)));
|
||||
setAngle(clamped);
|
||||
}, []);
|
||||
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
|
||||
const rotateRight = () => setAngle((a) => (a + 90) % 360);
|
||||
|
||||
const handleProcess = () => {
|
||||
// Convert -180..180 to 0..360 for the backend
|
||||
const backendAngle = angle < 0 ? angle + 360 : angle;
|
||||
processFiles(files, {
|
||||
angle: backendAngle,
|
||||
const settings = {
|
||||
angle,
|
||||
horizontal: flipH,
|
||||
vertical: flipV,
|
||||
});
|
||||
};
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -68,12 +58,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
if (hasFile && hasChanges && !processing) handleProcess();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAngle(0);
|
||||
setFlipH(false);
|
||||
setFlipV(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 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"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
90° Left
|
||||
90 Left
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
90° Right
|
||||
90 Right
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Angle control */}
|
||||
{/* Angle slider */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Fine Angle</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
<label className="text-xs text-muted-foreground">Angle</label>
|
||||
<span className="text-xs font-mono text-foreground">{angle} deg</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
min={0}
|
||||
max={360}
|
||||
value={angle}
|
||||
onChange={(e) => setAngle(Number(e.target.value))}
|
||||
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>
|
||||
|
||||
{/* Flip buttons */}
|
||||
@@ -172,17 +130,6 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
</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 && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -202,7 +149,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
disabled={!hasFile || !hasChanges || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Apply
|
||||
{files.length > 1 ? `Apply (${files.length} files)` : "Apply"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, ChevronDown, ChevronRight, Loader2, MapPin, AlertTriangle } from "lucide-react";
|
||||
import { Download, Loader2, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
|
||||
function getToken(): string {
|
||||
@@ -18,139 +18,8 @@ interface MetadataResult {
|
||||
xmp?: Record<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() {
|
||||
const { files } = useFileStore();
|
||||
const { entries, selectedIndex, files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("strip-metadata");
|
||||
|
||||
@@ -160,24 +29,32 @@ export function StripMetadataSettings() {
|
||||
const [stripIcc, setStripIcc] = useState(false);
|
||||
const [stripXmp, setStripXmp] = useState(false);
|
||||
|
||||
// Metadata inspection state
|
||||
const [metadataCache, setMetadataCache] = useState<Map<string, MetadataResult>>(new Map());
|
||||
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
|
||||
const [inspecting, setInspecting] = useState(false);
|
||||
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(() => {
|
||||
if (files.length === 0) {
|
||||
if (!currentFile || !fileKey) {
|
||||
setMetadata(null);
|
||||
setInspectError(null);
|
||||
lastInspectedFile.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = `${file.name}-${file.size}-${file.lastModified}`;
|
||||
if (lastInspectedFile.current === fileKey) return;
|
||||
lastInspectedFile.current = fileKey;
|
||||
// Check cache first
|
||||
const cached = metadataCache.get(fileKey);
|
||||
if (cached) {
|
||||
setMetadata(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
@@ -186,7 +63,7 @@ export function StripMetadataSettings() {
|
||||
setMetadata(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("file", currentFile);
|
||||
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
@@ -199,6 +76,7 @@ export function StripMetadataSettings() {
|
||||
}
|
||||
const data: MetadataResult = await res.json();
|
||||
setMetadata(data);
|
||||
setMetadataCache((prev) => new Map(prev).set(fileKey!, data));
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
|
||||
@@ -208,7 +86,16 @@ export function StripMetadataSettings() {
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [files]);
|
||||
}, [currentFile, fileKey]);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStripAllChange = (checked: boolean) => {
|
||||
setStripAll(checked);
|
||||
@@ -231,90 +118,76 @@ export function StripMetadataSettings() {
|
||||
if (hasFile && !processing) handleProcess();
|
||||
};
|
||||
|
||||
const hasExif = metadata?.exif && Object.keys(metadata.exif).length > 0;
|
||||
const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0;
|
||||
const hasIcc = metadata?.icc && Object.keys(metadata.icc).length > 0;
|
||||
const hasXmp = metadata?.xmp && Object.keys(metadata.xmp).length > 0;
|
||||
const hasAnyMetadata = hasExif || hasGps || hasIcc || hasXmp;
|
||||
const sectionCount = [hasExif, hasGps, hasIcc, hasXmp].filter(Boolean).length;
|
||||
|
||||
// GPS coordinates for display
|
||||
const gpsLat = metadata?.gps?.["_latitude"] as number | undefined;
|
||||
const gpsLon = metadata?.gps?.["_longitude"] as number | undefined;
|
||||
const renderMetadataSection = (title: string, key: string, data: Record<string, unknown> | null | undefined) => {
|
||||
if (!data || Object.keys(data).length === 0) return null;
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Metadata Display */}
|
||||
{hasFile && (
|
||||
{/* Metadata inspection */}
|
||||
{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">
|
||||
<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 && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Reading metadata...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
{hasGps && (
|
||||
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
This image contains GPS location data. Consider stripping it for privacy.
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{hasFile && hasAnyMetadata && <div className="border-t border-border" />}
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Strip All */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||
@@ -342,9 +215,6 @@ export function StripMetadataSettings() {
|
||||
className="rounded"
|
||||
/>
|
||||
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 className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
@@ -356,9 +226,6 @@ export function StripMetadataSettings() {
|
||||
className="rounded"
|
||||
/>
|
||||
Strip GPS (location data)
|
||||
{hasGps && !stripAll && (
|
||||
<span className="ml-auto text-[10px] text-amber-500">location found</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<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() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("text-overlay");
|
||||
|
||||
const [text, setText] = useState("Your Text Here");
|
||||
@@ -18,7 +18,12 @@ export function TextOverlaySettings() {
|
||||
const [shadow, setShadow] = useState(true);
|
||||
|
||||
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;
|
||||
@@ -102,7 +107,7 @@ export function TextOverlaySettings() {
|
||||
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"
|
||||
>
|
||||
Add Text
|
||||
{files.length > 1 ? `Apply Overlay (${files.length} files)` : "Apply Overlay"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-ri
|
||||
|
||||
export function WatermarkTextSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("watermark-text");
|
||||
|
||||
const [text, setText] = useState("Sample Watermark");
|
||||
@@ -19,7 +19,12 @@ export function WatermarkTextSettings() {
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
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;
|
||||
@@ -106,7 +111,7 @@ export function WatermarkTextSettings() {
|
||||
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"
|
||||
>
|
||||
Add Watermark
|
||||
{files.length > 1 ? `Apply Watermark (${files.length} files)` : "Apply Watermark"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user