mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
refactor: replace TOOL_FIELDS with shared Controls components (DRY)
Extract a *Controls subcomponent from all 16 pipeline-compatible tool settings components. Each Controls component holds the UI state and settings controls, accepts an onChange callback, and uses useRef to prevent infinite re-render loops. The standalone *Settings components become thin wrappers that add useToolProcessor, useFileStore, and action buttons. pipeline-step-settings.tsx is rewritten from ~675 lines to ~55 lines: the entire TOOL_FIELDS declarative map and generic renderer are deleted and replaced with direct imports of the Controls components. Pipeline steps now render the exact same UI as standalone tool pages. Special cases: - CropControls: numeric inputs for pipeline (standalone uses canvas) - RotateControls: resetSignal prop for post-processing reset - ColorControls: accepts toolId for tab selection - StripMetadataControls: checkboxes only (no file inspection) - RemoveBgControls: already extracted, unchanged
This commit is contained in:
@@ -1,25 +1,25 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function BlurFacesSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("blur-faces");
|
||||
export interface BlurFacesControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function BlurFacesControls({ onChange }: BlurFacesControlsProps) {
|
||||
const [blurRadius, setBlurRadius] = useState(30);
|
||||
const [sensitivity, setSensitivity] = useState(50);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
blurRadius,
|
||||
sensitivity: sensitivity / 100,
|
||||
});
|
||||
};
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ blurRadius, sensitivity: sensitivity / 100 });
|
||||
}, [blurRadius, sensitivity]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -68,6 +68,25 @@ export function BlurFacesSettings() {
|
||||
<span>Fewer false positives</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlurFacesSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("blur-faces");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<BlurFacesControls onChange={setSettings} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,38 +1,28 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function BorderSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("border");
|
||||
export interface BorderControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function BorderControls({ onChange }: BorderControlsProps) {
|
||||
const [borderWidth, setBorderWidth] = useState(10);
|
||||
const [borderColor, setBorderColor] = useState("#000000");
|
||||
const [cornerRadius, setCornerRadius] = useState(0);
|
||||
const [padding, setPadding] = useState(0);
|
||||
const [shadowBlur, setShadowBlur] = useState(0);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { borderWidth, borderColor, cornerRadius, padding, shadowBlur };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ borderWidth, borderColor, cornerRadius, padding, shadowBlur });
|
||||
}, [borderWidth, borderColor, cornerRadius, padding, shadowBlur]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -120,6 +110,38 @@ export function BorderSettings() {
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorderSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("border");
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<BorderControls onChange={setSettings} />
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
@@ -7,25 +7,13 @@ import { useFileStore } from "@/stores/file-store";
|
||||
type Tab = "basic" | "channels" | "effects";
|
||||
type Effect = "none" | "grayscale" | "sepia" | "invert";
|
||||
|
||||
interface ColorSettingsProps {
|
||||
/** The specific tool ID to use for processing */
|
||||
interface ColorControlsProps {
|
||||
toolId: string;
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
onPreviewFilter?: (filter: string) => void;
|
||||
}
|
||||
|
||||
export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor(toolId);
|
||||
|
||||
export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorControlsProps) {
|
||||
const [tab, setTab] = useState<Tab>(() => {
|
||||
if (toolId === "color-channels") return "channels";
|
||||
if (toolId === "color-effects") return "effects";
|
||||
@@ -45,6 +33,14 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
// Effects
|
||||
const [effect, setEffect] = useState<Effect>("none");
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// Report settings on change
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ brightness, contrast, saturation, red, green, blue, effect });
|
||||
}, [brightness, contrast, saturation, red, green, blue, effect]);
|
||||
|
||||
// Emit CSS filter for live preview
|
||||
const hasChannelChanges = red !== 100 || green !== 100 || blue !== 100;
|
||||
useEffect(() => {
|
||||
@@ -70,24 +66,6 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
onPreviewFilter,
|
||||
]);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = {
|
||||
brightness,
|
||||
contrast,
|
||||
saturation,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
effect,
|
||||
};
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges =
|
||||
brightness !== 0 ||
|
||||
contrast !== 0 ||
|
||||
@@ -103,13 +81,8 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
{ id: "effects", label: "Effects" },
|
||||
];
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && hasChanges && !processing) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<>
|
||||
{/* Hidden SVG filter for color channel preview */}
|
||||
{hasChannelChanges && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
@@ -235,6 +208,65 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
Reset All
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColorSettingsProps {
|
||||
/** The specific tool ID to use for processing */
|
||||
toolId: string;
|
||||
onPreviewFilter?: (filter: string) => void;
|
||||
}
|
||||
|
||||
export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor(toolId);
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
red: 100,
|
||||
green: 100,
|
||||
blue: 100,
|
||||
effect: "none",
|
||||
});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges =
|
||||
settings.brightness !== 0 ||
|
||||
settings.contrast !== 0 ||
|
||||
settings.saturation !== 0 ||
|
||||
settings.red !== 100 ||
|
||||
settings.green !== 100 ||
|
||||
settings.blue !== 100 ||
|
||||
settings.effect !== "none";
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && hasChanges && !processing) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<ColorControls toolId={toolId} onChange={setSettings} onPreviewFilter={onPreviewFilter} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,52 +1,35 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type CompressMode = "quality" | "targetSize";
|
||||
|
||||
export function CompressSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("compress");
|
||||
export interface CompressControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function CompressControls({ onChange }: CompressControlsProps) {
|
||||
const [mode, setMode] = useState<CompressMode>("quality");
|
||||
const [quality, setQuality] = useState(75);
|
||||
const [targetSizeKb, setTargetSizeKb] = useState("");
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { mode };
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "quality") {
|
||||
settings.quality = quality;
|
||||
onChangeRef.current?.({ mode, quality });
|
||||
} else {
|
||||
settings.targetSizeKb = Number(targetSizeKb);
|
||||
onChangeRef.current?.({ mode, targetSizeKb: Number(targetSizeKb) });
|
||||
}
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = mode === "quality" || (mode === "targetSize" && Number(targetSizeKb) > 0);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && canProcess && !processing) handleProcess();
|
||||
};
|
||||
}, [mode, quality, targetSizeKb]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Compression Mode</p>
|
||||
@@ -106,6 +89,45 @@ export function CompressSettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompressSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("compress");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess =
|
||||
settings.mode === "quality" ||
|
||||
(settings.mode === "targetSize" && Number(settings.targetSizeKb) > 0);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && canProcess && !processing) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<CompressControls onChange={setSettings} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,67 +1,37 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
|
||||
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
|
||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif"];
|
||||
|
||||
export function ConvertSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("convert");
|
||||
export interface ConvertControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ConvertControls({ onChange }: ConvertControlsProps) {
|
||||
const [format, setFormat] = useState<string>("png");
|
||||
const [quality, setQuality] = useState(85);
|
||||
|
||||
// Detect source format from filename
|
||||
const sourceFile = files[0];
|
||||
const sourceExt = sourceFile
|
||||
? sourceFile.name.split(".").pop()?.toLowerCase() || "unknown"
|
||||
: "none";
|
||||
const isLossy = LOSSY_FORMATS.includes(format);
|
||||
|
||||
const isLossy = LOSSY_FORMATS.has(format);
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
const handleProcess = () => {
|
||||
useEffect(() => {
|
||||
const settings: Record<string, unknown> = { format };
|
||||
if (isLossy) {
|
||||
settings.quality = quality;
|
||||
}
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && !processing) handleProcess();
|
||||
};
|
||||
onChangeRef.current?.(settings);
|
||||
}, [format, quality, isLossy]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Source format */}
|
||||
{hasFile && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Source Format</p>
|
||||
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
|
||||
{sourceExt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Target format */}
|
||||
<div>
|
||||
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
|
||||
@@ -101,6 +71,58 @@ export function ConvertSettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConvertSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("convert");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
// Detect source format from filename
|
||||
const sourceFile = files[0];
|
||||
const sourceExt = sourceFile
|
||||
? sourceFile.name.split(".").pop()?.toLowerCase() || "unknown"
|
||||
: "none";
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && !processing) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Source format */}
|
||||
{hasFile && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Source Format</p>
|
||||
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
|
||||
{sourceExt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConvertControls onChange={setSettings} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Crop } from "react-image-crop";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -311,3 +311,91 @@ export function CropSettings({
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pipeline-only crop controls (numeric inputs, no canvas) ──────────
|
||||
|
||||
export interface CropControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function CropControls({ onChange }: CropControlsProps) {
|
||||
const [left, setLeft] = useState(0);
|
||||
const [top, setTop] = useState(0);
|
||||
const [width, setWidth] = useState("");
|
||||
const [height, setHeight] = useState("");
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({
|
||||
left,
|
||||
top,
|
||||
width: width ? Number(width) : undefined,
|
||||
height: height ? Number(height) : undefined,
|
||||
});
|
||||
}, [left, top, width, height]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="pipeline-crop-left" className="text-xs text-muted-foreground">
|
||||
Left offset (px)
|
||||
</label>
|
||||
<input
|
||||
id="pipeline-crop-left"
|
||||
type="number"
|
||||
value={left}
|
||||
onChange={(e) => setLeft(Number(e.target.value))}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pipeline-crop-top" className="text-xs text-muted-foreground">
|
||||
Top offset (px)
|
||||
</label>
|
||||
<input
|
||||
id="pipeline-crop-top"
|
||||
type="number"
|
||||
value={top}
|
||||
onChange={(e) => setTop(Number(e.target.value))}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pipeline-crop-width" className="text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="pipeline-crop-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
min={1}
|
||||
placeholder="Required"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pipeline-crop-height" className="text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="pipeline-crop-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
min={1}
|
||||
placeholder="Required"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function GifToolsSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("gif-tools");
|
||||
export interface GifToolsControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function GifToolsControls({ onChange }: GifToolsControlsProps) {
|
||||
const [mode, setMode] = useState<"resize" | "extract">("resize");
|
||||
const [width, setWidth] = useState("");
|
||||
const [height, setHeight] = useState("");
|
||||
const [extractFrame, setExtractFrame] = useState("0");
|
||||
const [optimize, setOptimize] = useState(false);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "extract") {
|
||||
settings.extractFrame = Number(extractFrame);
|
||||
onChangeRef.current?.({ extractFrame: Number(extractFrame) });
|
||||
} else {
|
||||
const settings: Record<string, unknown> = { optimize };
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
settings.optimize = optimize;
|
||||
onChangeRef.current?.(settings);
|
||||
}
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
}, [mode, width, height, extractFrame, optimize]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -112,6 +114,25 @@ export function GifToolsSettings() {
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">Frame 0 is the first frame</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GifToolsSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("gif-tools");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GifToolsControls onChange={setSettings} />
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
|
||||
@@ -1,493 +1,26 @@
|
||||
import { BlurFacesControls } from "./blur-faces-settings";
|
||||
import { BorderControls } from "./border-settings";
|
||||
import { ColorControls } from "./color-settings";
|
||||
import { CompressControls } from "./compress-settings";
|
||||
import { ConvertControls } from "./convert-settings";
|
||||
import { CropControls } from "./crop-settings";
|
||||
import { GifToolsControls } from "./gif-tools-settings";
|
||||
import { RemoveBgControls } from "./remove-bg-settings";
|
||||
import { ReplaceColorControls } from "./replace-color-settings";
|
||||
import { ResizeControls } from "./resize-settings";
|
||||
import { RotateControls } from "./rotate-settings";
|
||||
import { SmartCropControls } from "./smart-crop-settings";
|
||||
import { StripMetadataControls } from "./strip-metadata-settings";
|
||||
import { TextOverlayControls } from "./text-overlay-settings";
|
||||
import { UpscaleControls } from "./upscale-settings";
|
||||
import { WatermarkTextControls } from "./watermark-text-settings";
|
||||
|
||||
type FieldType = "number" | "select" | "boolean" | "text" | "color";
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
defaultValue?: unknown;
|
||||
showWhen?: (settings: Record<string, unknown>) => boolean;
|
||||
}
|
||||
|
||||
const TOOL_FIELDS: Record<string, FieldDef[]> = {
|
||||
resize: [
|
||||
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Auto" },
|
||||
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Auto" },
|
||||
{
|
||||
key: "percentage",
|
||||
label: "Scale (%)",
|
||||
type: "number",
|
||||
min: 1,
|
||||
placeholder: "Use instead of width/height",
|
||||
},
|
||||
{
|
||||
key: "fit",
|
||||
label: "Fit Mode",
|
||||
type: "select",
|
||||
defaultValue: "contain",
|
||||
options: [
|
||||
{ value: "contain", label: "Fit inside" },
|
||||
{ value: "cover", label: "Crop to fit" },
|
||||
{ value: "fill", label: "Stretch" },
|
||||
],
|
||||
},
|
||||
{ key: "withoutEnlargement", label: "Don't enlarge", type: "boolean", defaultValue: false },
|
||||
],
|
||||
|
||||
crop: [
|
||||
{ key: "left", label: "Left offset (px)", type: "number", min: 0, placeholder: "0" },
|
||||
{ key: "top", label: "Top offset (px)", type: "number", min: 0, placeholder: "0" },
|
||||
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Required" },
|
||||
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Required" },
|
||||
],
|
||||
|
||||
rotate: [
|
||||
{
|
||||
key: "angle",
|
||||
label: "Angle (degrees)",
|
||||
type: "number",
|
||||
min: -360,
|
||||
max: 360,
|
||||
step: 90,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ key: "horizontal", label: "Flip horizontal", type: "boolean", defaultValue: false },
|
||||
{ key: "vertical", label: "Flip vertical", type: "boolean", defaultValue: false },
|
||||
],
|
||||
|
||||
convert: [
|
||||
{
|
||||
key: "format",
|
||||
label: "Format",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "jpg", label: "JPEG" },
|
||||
{ value: "png", label: "PNG" },
|
||||
{ value: "webp", label: "WebP" },
|
||||
{ value: "avif", label: "AVIF" },
|
||||
{ value: "tiff", label: "TIFF" },
|
||||
{ value: "gif", label: "GIF" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
label: "Quality (1-100)",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 100,
|
||||
placeholder: "Auto",
|
||||
},
|
||||
],
|
||||
|
||||
compress: [
|
||||
{
|
||||
key: "mode",
|
||||
label: "Mode",
|
||||
type: "select",
|
||||
defaultValue: "quality",
|
||||
options: [
|
||||
{ value: "quality", label: "Quality" },
|
||||
{ value: "targetSize", label: "Target size" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
label: "Quality (1-100)",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 100,
|
||||
placeholder: "80",
|
||||
showWhen: (s) => s.mode !== "targetSize",
|
||||
},
|
||||
{
|
||||
key: "targetSizeKb",
|
||||
label: "Target size (KB)",
|
||||
type: "number",
|
||||
min: 1,
|
||||
placeholder: "e.g. 200",
|
||||
showWhen: (s) => s.mode === "targetSize",
|
||||
},
|
||||
],
|
||||
|
||||
"strip-metadata": [
|
||||
{ key: "stripAll", label: "Strip all metadata", type: "boolean", defaultValue: true },
|
||||
{
|
||||
key: "stripExif",
|
||||
label: "Strip EXIF",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
showWhen: (s) => !s.stripAll,
|
||||
},
|
||||
{
|
||||
key: "stripGps",
|
||||
label: "Strip GPS",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
showWhen: (s) => !s.stripAll,
|
||||
},
|
||||
{
|
||||
key: "stripIcc",
|
||||
label: "Strip ICC profile",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
showWhen: (s) => !s.stripAll,
|
||||
},
|
||||
{
|
||||
key: "stripXmp",
|
||||
label: "Strip XMP",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
showWhen: (s) => !s.stripAll,
|
||||
},
|
||||
],
|
||||
|
||||
"brightness-contrast": [
|
||||
{
|
||||
key: "brightness",
|
||||
label: "Brightness",
|
||||
type: "number",
|
||||
min: -100,
|
||||
max: 100,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ key: "contrast", label: "Contrast", type: "number", min: -100, max: 100, defaultValue: 0 },
|
||||
],
|
||||
|
||||
saturation: [
|
||||
{
|
||||
key: "saturation",
|
||||
label: "Saturation",
|
||||
type: "number",
|
||||
min: -100,
|
||||
max: 100,
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
|
||||
"color-channels": [
|
||||
{ key: "red", label: "Red", type: "number", min: 0, max: 200, defaultValue: 100 },
|
||||
{ key: "green", label: "Green", type: "number", min: 0, max: 200, defaultValue: 100 },
|
||||
{ key: "blue", label: "Blue", type: "number", min: 0, max: 200, defaultValue: 100 },
|
||||
],
|
||||
|
||||
"color-effects": [
|
||||
{
|
||||
key: "effect",
|
||||
label: "Effect",
|
||||
type: "select",
|
||||
defaultValue: "none",
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "grayscale", label: "Grayscale" },
|
||||
{ value: "sepia", label: "Sepia" },
|
||||
{ value: "invert", label: "Invert" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"replace-color": [
|
||||
{ key: "sourceColor", label: "Source color", type: "color", defaultValue: "#FF0000" },
|
||||
{ key: "targetColor", label: "Target color", type: "color", defaultValue: "#00FF00" },
|
||||
{
|
||||
key: "makeTransparent",
|
||||
label: "Make transparent instead",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
key: "tolerance",
|
||||
label: "Tolerance (0-255)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 255,
|
||||
defaultValue: 30,
|
||||
},
|
||||
],
|
||||
|
||||
"watermark-text": [
|
||||
{ key: "text", label: "Watermark text", type: "text", placeholder: "Your watermark" },
|
||||
{ key: "fontSize", label: "Font size", type: "number", min: 8, max: 200, defaultValue: 48 },
|
||||
{ key: "color", label: "Color", type: "color", defaultValue: "#000000" },
|
||||
{ key: "opacity", label: "Opacity (%)", type: "number", min: 0, max: 100, defaultValue: 50 },
|
||||
{
|
||||
key: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
defaultValue: "center",
|
||||
options: [
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
{ value: "tiled", label: "Tiled" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "rotation",
|
||||
label: "Rotation (degrees)",
|
||||
type: "number",
|
||||
min: -360,
|
||||
max: 360,
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
|
||||
"watermark-image": [
|
||||
{
|
||||
key: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
defaultValue: "bottom-right",
|
||||
options: [
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
{ key: "opacity", label: "Opacity (%)", type: "number", min: 0, max: 100, defaultValue: 50 },
|
||||
{ key: "scale", label: "Scale (%)", type: "number", min: 1, max: 100, defaultValue: 25 },
|
||||
],
|
||||
|
||||
"text-overlay": [
|
||||
{ key: "text", label: "Text", type: "text", placeholder: "Your text" },
|
||||
{ key: "fontSize", label: "Font size", type: "number", min: 8, max: 200, defaultValue: 48 },
|
||||
{ key: "color", label: "Color", type: "color", defaultValue: "#FFFFFF" },
|
||||
{
|
||||
key: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
defaultValue: "bottom",
|
||||
options: [
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
],
|
||||
},
|
||||
{ key: "backgroundBox", label: "Background box", type: "boolean", defaultValue: false },
|
||||
{
|
||||
key: "backgroundColor",
|
||||
label: "Box color",
|
||||
type: "color",
|
||||
defaultValue: "#000000",
|
||||
showWhen: (s) => !!s.backgroundBox,
|
||||
},
|
||||
{ key: "shadow", label: "Text shadow", type: "boolean", defaultValue: true },
|
||||
],
|
||||
|
||||
border: [
|
||||
{ key: "borderWidth", label: "Width (px)", type: "number", min: 0, max: 200, defaultValue: 10 },
|
||||
{ key: "borderColor", label: "Color", type: "color", defaultValue: "#000000" },
|
||||
{
|
||||
key: "cornerRadius",
|
||||
label: "Corner radius",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 500,
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ key: "padding", label: "Padding (px)", type: "number", min: 0, max: 200, defaultValue: 0 },
|
||||
{ key: "shadowBlur", label: "Shadow blur", type: "number", min: 0, max: 50, defaultValue: 0 },
|
||||
{ key: "shadowColor", label: "Shadow color", type: "color", defaultValue: "#00000080" },
|
||||
],
|
||||
|
||||
split: [
|
||||
{ key: "columns", label: "Columns", type: "number", min: 1, max: 10, defaultValue: 2 },
|
||||
{ key: "rows", label: "Rows", type: "number", min: 1, max: 10, defaultValue: 2 },
|
||||
],
|
||||
|
||||
"blur-faces": [
|
||||
{ key: "blurRadius", label: "Blur radius", type: "number", min: 1, max: 100, defaultValue: 30 },
|
||||
{
|
||||
key: "sensitivity",
|
||||
label: "Sensitivity (0-1)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
defaultValue: 0.5,
|
||||
},
|
||||
],
|
||||
|
||||
upscale: [
|
||||
{
|
||||
key: "scale",
|
||||
label: "Scale factor",
|
||||
type: "select",
|
||||
defaultValue: "2",
|
||||
options: [
|
||||
{ value: "2", label: "2x" },
|
||||
{ value: "3", label: "3x" },
|
||||
{ value: "4", label: "4x" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"smart-crop": [
|
||||
{ key: "width", label: "Width (px)", type: "number", min: 1, placeholder: "Required" },
|
||||
{ key: "height", label: "Height (px)", type: "number", min: 1, placeholder: "Required" },
|
||||
],
|
||||
|
||||
vectorize: [
|
||||
{
|
||||
key: "colorMode",
|
||||
label: "Color mode",
|
||||
type: "select",
|
||||
defaultValue: "bw",
|
||||
options: [
|
||||
{ value: "bw", label: "Black & White" },
|
||||
{ value: "color", label: "Color" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "threshold",
|
||||
label: "Threshold (0-255)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 255,
|
||||
defaultValue: 128,
|
||||
},
|
||||
{
|
||||
key: "detail",
|
||||
label: "Detail",
|
||||
type: "select",
|
||||
defaultValue: "medium",
|
||||
options: [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"svg-to-raster": [
|
||||
{ key: "width", label: "Width (px)", type: "number", min: 1, max: 8192, defaultValue: 1024 },
|
||||
{ key: "height", label: "Height (px)", type: "number", min: 1, max: 8192, placeholder: "Auto" },
|
||||
{
|
||||
key: "outputFormat",
|
||||
label: "Format",
|
||||
type: "select",
|
||||
defaultValue: "png",
|
||||
options: [
|
||||
{ value: "png", label: "PNG" },
|
||||
{ value: "jpg", label: "JPEG" },
|
||||
{ value: "webp", label: "WebP" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"gif-tools": [
|
||||
{ key: "width", label: "Width (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
|
||||
{ key: "height", label: "Height (px)", type: "number", min: 1, max: 4096, placeholder: "Auto" },
|
||||
{
|
||||
key: "extractFrame",
|
||||
label: "Extract frame #",
|
||||
type: "number",
|
||||
min: 0,
|
||||
placeholder: "All frames",
|
||||
},
|
||||
{ key: "optimize", label: "Optimize", type: "boolean", defaultValue: false },
|
||||
],
|
||||
|
||||
"image-to-pdf": [
|
||||
{
|
||||
key: "pageSize",
|
||||
label: "Page size",
|
||||
type: "select",
|
||||
defaultValue: "A4",
|
||||
options: [
|
||||
{ value: "A4", label: "A4" },
|
||||
{ value: "Letter", label: "Letter" },
|
||||
{ value: "A3", label: "A3" },
|
||||
{ value: "A5", label: "A5" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "orientation",
|
||||
label: "Orientation",
|
||||
type: "select",
|
||||
defaultValue: "portrait",
|
||||
options: [
|
||||
{ value: "portrait", label: "Portrait" },
|
||||
{ value: "landscape", label: "Landscape" },
|
||||
],
|
||||
},
|
||||
{ key: "margin", label: "Margin (mm)", type: "number", min: 0, max: 100, defaultValue: 20 },
|
||||
],
|
||||
|
||||
"bulk-rename": [
|
||||
{
|
||||
key: "pattern",
|
||||
label: "Pattern",
|
||||
type: "text",
|
||||
placeholder: "image-{{index}}",
|
||||
defaultValue: "image-{{index}}",
|
||||
},
|
||||
{ key: "startIndex", label: "Start index", type: "number", min: 0, defaultValue: 1 },
|
||||
],
|
||||
|
||||
ocr: [
|
||||
{
|
||||
key: "engine",
|
||||
label: "Engine",
|
||||
type: "select",
|
||||
defaultValue: "tesseract",
|
||||
options: [
|
||||
{ value: "tesseract", label: "Tesseract" },
|
||||
{ value: "paddleocr", label: "PaddleOCR" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
label: "Language",
|
||||
type: "select",
|
||||
defaultValue: "en",
|
||||
options: [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "de", label: "German" },
|
||||
{ value: "fr", label: "French" },
|
||||
{ value: "es", label: "Spanish" },
|
||||
{ value: "zh", label: "Chinese" },
|
||||
{ value: "ja", label: "Japanese" },
|
||||
{ value: "ko", label: "Korean" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
"qr-generate": [
|
||||
{ key: "text", label: "Content", type: "text", placeholder: "URL or text" },
|
||||
{ key: "size", label: "Size (px)", type: "number", min: 100, max: 2000, defaultValue: 400 },
|
||||
{
|
||||
key: "errorCorrection",
|
||||
label: "Error correction",
|
||||
type: "select",
|
||||
defaultValue: "M",
|
||||
options: [
|
||||
{ value: "L", label: "Low (7%)" },
|
||||
{ value: "M", label: "Medium (15%)" },
|
||||
{ value: "Q", label: "Quartile (25%)" },
|
||||
{ value: "H", label: "High (30%)" },
|
||||
],
|
||||
},
|
||||
{ key: "foreground", label: "Foreground", type: "color", defaultValue: "#000000" },
|
||||
{ key: "background", label: "Background", type: "color", defaultValue: "#FFFFFF" },
|
||||
],
|
||||
|
||||
// remove-background uses its own shared controls component (DRY)
|
||||
favicon: [],
|
||||
"color-palette": [],
|
||||
"barcode-read": [],
|
||||
info: [],
|
||||
"erase-object": [],
|
||||
};
|
||||
const COLOR_TOOL_IDS = new Set([
|
||||
"brightness-contrast",
|
||||
"saturation",
|
||||
"color-channels",
|
||||
"color-effects",
|
||||
]);
|
||||
|
||||
interface PipelineStepSettingsProps {
|
||||
toolId: string;
|
||||
@@ -496,179 +29,27 @@ interface PipelineStepSettingsProps {
|
||||
}
|
||||
|
||||
export function PipelineStepSettings({ toolId, settings, onChange }: PipelineStepSettingsProps) {
|
||||
// Tools with their own shared controls component (DRY - same UI as standalone page)
|
||||
if (toolId === "remove-background") {
|
||||
if (toolId === "resize") return <ResizeControls onChange={onChange} />;
|
||||
if (toolId === "crop") return <CropControls onChange={onChange} />;
|
||||
if (toolId === "rotate") return <RotateControls onChange={onChange} />;
|
||||
if (toolId === "convert") return <ConvertControls onChange={onChange} />;
|
||||
if (toolId === "compress") return <CompressControls onChange={onChange} />;
|
||||
if (toolId === "strip-metadata") return <StripMetadataControls onChange={onChange} />;
|
||||
if (toolId === "border") return <BorderControls onChange={onChange} />;
|
||||
if (toolId === "watermark-text") return <WatermarkTextControls onChange={onChange} />;
|
||||
if (toolId === "text-overlay") return <TextOverlayControls onChange={onChange} />;
|
||||
if (toolId === "replace-color") return <ReplaceColorControls onChange={onChange} />;
|
||||
if (toolId === "smart-crop") return <SmartCropControls onChange={onChange} />;
|
||||
if (toolId === "gif-tools") return <GifToolsControls onChange={onChange} />;
|
||||
if (toolId === "upscale") return <UpscaleControls onChange={onChange} />;
|
||||
if (toolId === "blur-faces") return <BlurFacesControls onChange={onChange} />;
|
||||
if (toolId === "remove-background")
|
||||
return <RemoveBgControls settings={settings} onChange={onChange} />;
|
||||
}
|
||||
|
||||
const fields = TOOL_FIELDS[toolId];
|
||||
|
||||
if (!fields || fields.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No configurable settings. Defaults will be used.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const updateField = (key: string, value: unknown) => {
|
||||
const next = { ...settings };
|
||||
if (value === undefined || value === "") {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const visibleFields = fields.filter((f) => !f.showWhen || f.showWhen(settings));
|
||||
if (COLOR_TOOL_IDS.has(toolId)) return <ColorControls toolId={toolId} onChange={onChange} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{visibleFields.map((field) => {
|
||||
const raw = settings[field.key];
|
||||
const value = raw !== undefined ? raw : field.defaultValue;
|
||||
|
||||
switch (field.type) {
|
||||
case "number":
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`pipeline-${field.key}`}
|
||||
type="number"
|
||||
value={value != null && value !== "" ? Number(value) : ""}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
field.key,
|
||||
e.target.value === "" ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "text":
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`pipeline-${field.key}`}
|
||||
type="text"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => updateField(field.key, e.target.value || undefined)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select": {
|
||||
const opts = field.options ?? [];
|
||||
// Use button group for <= 4 options, dropdown for more
|
||||
if (opts.length <= 4) {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<p className="text-xs text-muted-foreground">{field.label}</p>
|
||||
<div className="flex gap-1 mt-0.5">
|
||||
{opts.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => updateField(field.key, opt.value)}
|
||||
className={`flex-1 text-xs py-1.5 rounded transition-colors ${
|
||||
String(value) === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label htmlFor={`pipeline-${field.key}`} className="text-xs text-muted-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<select
|
||||
id={`pipeline-${field.key}`}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{!value && <option value="">Select...</option>}
|
||||
{opts.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<label
|
||||
key={field.key}
|
||||
className="flex items-center gap-2 text-sm text-foreground cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => updateField(field.key, e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{field.label}
|
||||
</label>
|
||||
);
|
||||
|
||||
case "color":
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label
|
||||
htmlFor={`pipeline-${field.key}-text`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-0.5">
|
||||
<input
|
||||
id={`pipeline-${field.key}-picker`}
|
||||
type="color"
|
||||
value={String(value || "#000000").slice(0, 7)}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
className="h-8 w-8 rounded border border-border cursor-pointer bg-background"
|
||||
/>
|
||||
<input
|
||||
id={`pipeline-${field.key}-text`}
|
||||
type="text"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => updateField(field.key, e.target.value || undefined)}
|
||||
placeholder="#000000"
|
||||
className="flex-1 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No configurable settings. Defaults will be used.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,27 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function ReplaceColorSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("replace-color");
|
||||
export interface ReplaceColorControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ReplaceColorControls({ onChange }: ReplaceColorControlsProps) {
|
||||
const [sourceColor, setSourceColor] = useState("#FF0000");
|
||||
const [targetColor, setTargetColor] = useState("#00FF00");
|
||||
const [makeTransparent, setMakeTransparent] = useState(false);
|
||||
const [tolerance, setTolerance] = useState(30);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { sourceColor, targetColor, makeTransparent, tolerance };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ sourceColor, targetColor, makeTransparent, tolerance });
|
||||
}, [sourceColor, targetColor, makeTransparent, tolerance]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -100,6 +90,38 @@ export function ReplaceColorSettings() {
|
||||
<span>Wide range</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplaceColorSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("replace-color");
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ReplaceColorControls onChange={setSettings} />
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
||||
import { Download, Link, Unlink } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
@@ -17,11 +17,11 @@ const FIT_LABELS: Record<FitMode, string> = {
|
||||
// Group presets by platform
|
||||
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
||||
|
||||
export function ResizeSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("resize");
|
||||
export interface ResizeControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [width, setWidth] = useState<string>("");
|
||||
@@ -31,6 +31,24 @@ export function ResizeSettings() {
|
||||
const [lockAspect, setLockAspect] = useState(true);
|
||||
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
if (tab === "scale") {
|
||||
settings.percentage = Number(percentage);
|
||||
} else {
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
settings.fit = tab === "presets" ? "cover" : fit;
|
||||
settings.withoutEnlargement = withoutEnlargement;
|
||||
}
|
||||
onChangeRef.current?.(settings);
|
||||
}, [tab, width, height, percentage, fit, withoutEnlargement]);
|
||||
|
||||
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
||||
const key = `${preset.platform}-${preset.name}`;
|
||||
if (selectedPreset === key) {
|
||||
@@ -44,41 +62,11 @@ export function ResizeSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
|
||||
if (tab === "scale") {
|
||||
settings.percentage = Number(percentage);
|
||||
} else {
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
settings.fit = tab === "presets" ? "cover" : fit;
|
||||
settings.withoutEnlargement = withoutEnlargement;
|
||||
}
|
||||
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess =
|
||||
hasFile &&
|
||||
!processing &&
|
||||
(tab === "scale" ? Number(percentage) > 0 : Boolean(width) || Boolean(height));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
const tabClass = (t: ResizeTab) =>
|
||||
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Tab selector */}
|
||||
<div>
|
||||
<div className="flex gap-1">
|
||||
@@ -243,6 +231,42 @@ export function ResizeSettings() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResizeSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("resize");
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const tab = settings.percentage !== undefined ? "scale" : "other";
|
||||
const canProcess =
|
||||
hasFile &&
|
||||
!processing &&
|
||||
(tab === "scale"
|
||||
? Number(settings.percentage) > 0
|
||||
: Boolean(settings.width) || Boolean(settings.height));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<ResizeControls onChange={setSettings} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -10,14 +10,13 @@ export interface PreviewTransform {
|
||||
flipV: boolean;
|
||||
}
|
||||
|
||||
interface RotateSettingsProps {
|
||||
export interface RotateControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
onPreviewTransform?: (transform: PreviewTransform) => void;
|
||||
resetSignal?: number;
|
||||
}
|
||||
|
||||
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } = useToolProcessor("rotate");
|
||||
|
||||
export function RotateControls({ onChange, onPreviewTransform, resetSignal }: RotateControlsProps) {
|
||||
// Quick rotation in 90° steps: 0, 90, 180, 270
|
||||
const [rotation, setRotation] = useState(0);
|
||||
// Fine straighten adjustment: -45 to +45
|
||||
@@ -27,22 +26,31 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
|
||||
const totalAngle = rotation + straighten;
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
// Report settings on change
|
||||
useEffect(() => {
|
||||
const backendAngle = ((totalAngle % 360) + 360) % 360;
|
||||
onChangeRef.current?.({ angle: backendAngle, horizontal: flipH, vertical: flipV });
|
||||
}, [totalAngle, flipH, flipV]);
|
||||
|
||||
// Emit preview transform on every change
|
||||
useEffect(() => {
|
||||
onPreviewTransform?.({ rotate: totalAngle, flipH, flipV });
|
||||
}, [totalAngle, flipH, flipV, onPreviewTransform]);
|
||||
|
||||
// Reset controls after successful processing
|
||||
const prevProcessing = useRef(processing);
|
||||
// Reset controls when resetSignal increments
|
||||
useEffect(() => {
|
||||
if (prevProcessing.current && !processing && !error) {
|
||||
if (resetSignal !== undefined && resetSignal > 0) {
|
||||
setRotation(0);
|
||||
setStraighten(0);
|
||||
setFlipH(false);
|
||||
setFlipV(false);
|
||||
}
|
||||
prevProcessing.current = processing;
|
||||
}, [processing, error]);
|
||||
}, [resetSignal]);
|
||||
|
||||
// Display angle normalized to 0-359
|
||||
const displayAngle = ((totalAngle % 360) + 360) % 360;
|
||||
@@ -68,28 +76,8 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const backendAngle = ((totalAngle % 360) + 360) % 360;
|
||||
const settings = {
|
||||
angle: backendAngle,
|
||||
horizontal: flipH,
|
||||
vertical: flipV,
|
||||
};
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges = totalAngle !== 0 || flipH || flipV;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && hasChanges && !processing) handleProcess();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setRotation(0);
|
||||
setStraighten(0);
|
||||
@@ -98,7 +86,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Quick rotate */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Rotate</p>
|
||||
@@ -232,6 +220,56 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
Reset all changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RotateSettingsProps {
|
||||
onPreviewTransform?: (transform: PreviewTransform) => void;
|
||||
}
|
||||
|
||||
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } = useToolProcessor("rotate");
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
const [resetSignal, setResetSignal] = useState(0);
|
||||
|
||||
// Reset controls after successful processing
|
||||
const prevProcessing = useRef(processing);
|
||||
useEffect(() => {
|
||||
if (prevProcessing.current && !processing && !error) {
|
||||
setResetSignal((s) => s + 1);
|
||||
}
|
||||
prevProcessing.current = processing;
|
||||
}, [processing, error]);
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges =
|
||||
(settings.angle !== undefined && settings.angle !== 0) ||
|
||||
settings.horizontal === true ||
|
||||
settings.vertical === true;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasFile && hasChanges && !processing) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<RotateControls
|
||||
onChange={setSettings}
|
||||
onPreviewTransform={onPreviewTransform}
|
||||
resetSignal={resetSignal}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
@@ -13,15 +13,24 @@ const ASPECT_PRESETS = [
|
||||
{ label: "Custom", w: 0, h: 0 },
|
||||
];
|
||||
|
||||
export function SmartCropSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("smart-crop");
|
||||
export interface SmartCropControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function SmartCropControls({ onChange }: SmartCropControlsProps) {
|
||||
const [width, setWidth] = useState("1080");
|
||||
const [height, setHeight] = useState("1080");
|
||||
const [preset, setPreset] = useState("1:1 Square");
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ width: Number(width), height: Number(height) });
|
||||
}, [width, height]);
|
||||
|
||||
const handlePreset = (label: string) => {
|
||||
setPreset(label);
|
||||
const p = ASPECT_PRESETS.find((a) => a.label === label);
|
||||
@@ -31,17 +40,6 @@ export function SmartCropSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
if (w > 0 && h > 0) {
|
||||
processFiles(files, { width: w, height: h });
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = Number(width) > 0 && Number(height) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Aspect ratio preset */}
|
||||
@@ -104,6 +102,31 @@ export function SmartCropSettings() {
|
||||
Uses entropy-based attention detection to find the most interesting region of the image and
|
||||
crops to it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartCropSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("smart-crop");
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
const w = Number(settings.width);
|
||||
const h = Number(settings.height);
|
||||
if (w > 0 && h > 0) {
|
||||
processFiles(files, { width: w, height: h });
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = Number(settings.width) > 0 && Number(settings.height) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SmartCropControls onChange={setSettings} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
@@ -172,17 +172,140 @@ function MetadataGrid({
|
||||
);
|
||||
}
|
||||
|
||||
export function StripMetadataSettings() {
|
||||
const { entries, selectedIndex, files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("strip-metadata");
|
||||
interface StripMetadataControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
/** Passed from parent to preserve field-count badges in checkbox labels */
|
||||
metadata?: MetadataResult | null;
|
||||
hasExif?: boolean;
|
||||
hasGps?: boolean;
|
||||
}
|
||||
|
||||
export function StripMetadataControls({
|
||||
onChange,
|
||||
metadata,
|
||||
hasExif,
|
||||
hasGps,
|
||||
}: StripMetadataControlsProps) {
|
||||
const [stripAll, setStripAll] = useState(true);
|
||||
const [stripExif, setStripExif] = useState(false);
|
||||
const [stripGps, setStripGps] = useState(false);
|
||||
const [stripIcc, setStripIcc] = useState(false);
|
||||
const [stripXmp, setStripXmp] = useState(false);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// Report settings on change
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ stripAll, stripExif, stripGps, stripIcc, stripXmp });
|
||||
}, [stripAll, stripExif, stripGps, stripIcc, stripXmp]);
|
||||
|
||||
const handleStripAllChange = (checked: boolean) => {
|
||||
setStripAll(checked);
|
||||
if (checked) {
|
||||
setStripExif(false);
|
||||
setStripGps(false);
|
||||
setStripIcc(false);
|
||||
setStripXmp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Strip All */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripAll}
|
||||
onChange={(e) => handleStripAllChange(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip All Metadata
|
||||
</label>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Individual options */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Or select specific metadata:</p>
|
||||
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripExif}
|
||||
onChange={(e) => setStripExif(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
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"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripGps}
|
||||
onChange={(e) => setStripGps(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
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"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripIcc}
|
||||
onChange={(e) => setStripIcc(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip ICC (color profile)
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripXmp}
|
||||
onChange={(e) => setStripXmp(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip XMP (extensible metadata)
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StripMetadataSettings() {
|
||||
const { entries, selectedIndex, files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("strip-metadata");
|
||||
|
||||
const [stripSettings, setStripSettings] = useState<Record<string, unknown>>({
|
||||
stripAll: true,
|
||||
stripExif: false,
|
||||
stripGps: false,
|
||||
stripIcc: false,
|
||||
stripXmp: false,
|
||||
});
|
||||
|
||||
// Per-file metadata cache (from feature branch)
|
||||
const [metadataCache, setMetadataCache] = useState<Map<string, MetadataResult>>(new Map());
|
||||
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
|
||||
@@ -241,18 +364,8 @@ export function StripMetadataSettings() {
|
||||
return () => controller.abort();
|
||||
}, [currentFile, fileKey, metadataCache]);
|
||||
|
||||
const handleStripAllChange = (checked: boolean) => {
|
||||
setStripAll(checked);
|
||||
if (checked) {
|
||||
setStripExif(false);
|
||||
setStripGps(false);
|
||||
setStripIcc(false);
|
||||
setStripXmp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { stripAll, stripExif, stripGps, stripIcc, stripXmp });
|
||||
processFiles(files, stripSettings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -359,83 +472,12 @@ export function StripMetadataSettings() {
|
||||
|
||||
{hasFile && hasAnyMetadata && <div className="border-t border-border" />}
|
||||
|
||||
{/* Strip All */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripAll}
|
||||
onChange={(e) => handleStripAllChange(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip All Metadata
|
||||
</label>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Individual options */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Or select specific metadata:</p>
|
||||
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripExif}
|
||||
onChange={(e) => setStripExif(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
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"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripGps}
|
||||
onChange={(e) => setStripGps(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
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"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripIcc}
|
||||
onChange={(e) => setStripIcc(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip ICC (color profile)
|
||||
</label>
|
||||
|
||||
<label
|
||||
className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripXmp}
|
||||
onChange={(e) => setStripXmp(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip XMP (extensible metadata)
|
||||
</label>
|
||||
</div>
|
||||
<StripMetadataControls
|
||||
onChange={setStripSettings}
|
||||
metadata={metadata}
|
||||
hasExif={!!hasExif}
|
||||
hasGps={!!hasGps}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function TextOverlaySettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("text-overlay");
|
||||
export interface TextOverlayControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function TextOverlayControls({ onChange }: TextOverlayControlsProps) {
|
||||
const [text, setText] = useState("Your Text Here");
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
const [color, setColor] = useState("#FFFFFF");
|
||||
@@ -25,16 +17,22 @@ export function TextOverlaySettings() {
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
const [shadow, setShadow] = useState(true);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { text, fontSize, color, position, backgroundBox, backgroundColor, shadow };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({
|
||||
text,
|
||||
fontSize,
|
||||
color,
|
||||
position,
|
||||
backgroundBox,
|
||||
backgroundColor,
|
||||
shadow,
|
||||
});
|
||||
}, [text, fontSize, color, position, backgroundBox, backgroundColor, shadow]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -132,6 +130,38 @@ export function TextOverlaySettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextOverlaySettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("text-overlay");
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TextOverlayControls onChange={setSettings} />
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -156,7 +186,7 @@ export function TextOverlaySettings() {
|
||||
type="button"
|
||||
data-testid="text-overlay-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing || !text}
|
||||
disabled={!hasFile || processing || !settings.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"
|
||||
>
|
||||
{files.length > 1 ? `Apply Overlay (${files.length} files)` : "Apply Overlay"}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const QUICK_SCALES = [2, 3, 4, 6, 8];
|
||||
|
||||
export function UpscaleSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("upscale");
|
||||
export interface UpscaleControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function UpscaleControls({ onChange }: UpscaleControlsProps) {
|
||||
const [scale, setScale] = useState(2);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { scale });
|
||||
};
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ scale });
|
||||
}, [scale]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -53,6 +56,25 @@ export function UpscaleSettings() {
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpscaleSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("upscale");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<UpscaleControls onChange={setSettings} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -82,7 +104,7 @@ export function UpscaleSettings() {
|
||||
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"
|
||||
>
|
||||
{`Upscale ${scale}x`}
|
||||
{`Upscale ${(settings.scale as number) ?? 2}x`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
||||
|
||||
export function WatermarkTextSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("watermark-text");
|
||||
export interface WatermarkTextControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function WatermarkTextControls({ onChange }: WatermarkTextControlsProps) {
|
||||
const [text, setText] = useState("Sample Watermark");
|
||||
const [fontSize, setFontSize] = useState(48);
|
||||
const [color, setColor] = useState("#000000");
|
||||
@@ -26,16 +18,14 @@ export function WatermarkTextSettings() {
|
||||
const [position, setPosition] = useState<Position>("center");
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { text, fontSize, color, opacity, position, rotation };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.({ text, fontSize, color, opacity, position, rotation });
|
||||
}, [text, fontSize, color, opacity, position, rotation]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -138,6 +128,38 @@ export function WatermarkTextSettings() {
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WatermarkTextSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("watermark-text");
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<WatermarkTextControls onChange={setSettings} />
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -162,7 +184,7 @@ export function WatermarkTextSettings() {
|
||||
type="button"
|
||||
data-testid="watermark-text-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing || !text}
|
||||
disabled={!hasFile || processing || !settings.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"
|
||||
>
|
||||
{files.length > 1 ? `Apply Watermark (${files.length} files)` : "Apply Watermark"}
|
||||
|
||||
Reference in New Issue
Block a user