diff --git a/apps/web/src/components/tools/blur-faces-settings.tsx b/apps/web/src/components/tools/blur-faces-settings.tsx index 6f64474f..4465bfb5 100644 --- a/apps/web/src/components/tools/blur-faces-settings.tsx +++ b/apps/web/src/components/tools/blur-faces-settings.tsx @@ -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) => 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 (
@@ -68,6 +68,25 @@ export function BlurFacesSettings() { Fewer false positives
+ + ); +} + +export function BlurFacesSettings() { + const { files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("blur-faces"); + const [settings, setSettings] = useState>({}); + + const handleProcess = () => { + processFiles(files, settings); + }; + + const hasFile = files.length > 0; + + return ( +
+ {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/border-settings.tsx b/apps/web/src/components/tools/border-settings.tsx index 778501d9..da30031a 100644 --- a/apps/web/src/components/tools/border-settings.tsx +++ b/apps/web/src/components/tools/border-settings.tsx @@ -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) => 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 (
@@ -120,6 +110,38 @@ export function BorderSettings() { className="w-full mt-1" />
+
+ ); +} + +export function BorderSettings() { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("border"); + + const [settings, setSettings] = useState>({}); + + const handleProcess = () => { + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const hasFile = files.length > 0; + + return ( +
+ {error &&

{error}

} diff --git a/apps/web/src/components/tools/color-settings.tsx b/apps/web/src/components/tools/color-settings.tsx index 2010116c..75c20ead 100644 --- a/apps/web/src/components/tools/color-settings.tsx +++ b/apps/web/src/components/tools/color-settings.tsx @@ -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) => 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(() => { 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("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 ( -
+ <> {/* Hidden SVG filter for color channel preview */} {hasChannelChanges && ( @@ -235,6 +208,65 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) { Reset All )} + + ); +} + +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>({ + 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 ( +
+ {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/compress-settings.tsx b/apps/web/src/components/tools/compress-settings.tsx index 5b7b3c08..983ee2e4 100644 --- a/apps/web/src/components/tools/compress-settings.tsx +++ b/apps/web/src/components/tools/compress-settings.tsx @@ -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) => void; +} +export function CompressControls({ onChange }: CompressControlsProps) { const [mode, setMode] = useState("quality"); const [quality, setQuality] = useState(75); const [targetSizeKb, setTargetSizeKb] = useState(""); - const handleProcess = () => { - const settings: Record = { 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 ( - +
{/* Mode toggle */}

Compression Mode

@@ -106,6 +89,45 @@ export function CompressSettings() { />
)} +
+ ); +} + +export function CompressSettings() { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("compress"); + const [settings, setSettings] = useState>({}); + + 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 ( + + {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx index 4c12105e..2217b300 100644 --- a/apps/web/src/components/tools/convert-settings.tsx +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -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) => void; +} +export function ConvertControls({ onChange }: ConvertControlsProps) { const [format, setFormat] = useState("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 = { 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 ( - - {/* Source format */} - {hasFile && ( -
-

Source Format

-
- {sourceExt} -
-
- )} - +
{/* Target format */}
)} +
+ ); +} + +export function ConvertSettings() { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("convert"); + const [settings, setSettings] = useState>({}); + + // 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 ( + + {/* Source format */} + {hasFile && ( +
+

Source Format

+
+ {sourceExt} +
+
+ )} + + {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx index 750291ac..01c988ba 100644 --- a/apps/web/src/components/tools/crop-settings.tsx +++ b/apps/web/src/components/tools/crop-settings.tsx @@ -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({ ); } + +// ── Pipeline-only crop controls (numeric inputs, no canvas) ────────── + +export interface CropControlsProps { + onChange?: (settings: Record) => 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 ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ ); +} diff --git a/apps/web/src/components/tools/gif-tools-settings.tsx b/apps/web/src/components/tools/gif-tools-settings.tsx index 0a1f8772..5c06ecd4 100644 --- a/apps/web/src/components/tools/gif-tools-settings.tsx +++ b/apps/web/src/components/tools/gif-tools-settings.tsx @@ -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) => 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 = {}; + 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 = { 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 (
@@ -112,6 +114,25 @@ export function GifToolsSettings() {

Frame 0 is the first frame

)} +
+ ); +} + +export function GifToolsSettings() { + const { files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("gif-tools"); + const [settings, setSettings] = useState>({}); + + const handleProcess = () => { + processFiles(files, settings); + }; + + const hasFile = files.length > 0; + + return ( +
+ {error &&

{error}

} diff --git a/apps/web/src/components/tools/pipeline-step-settings.tsx b/apps/web/src/components/tools/pipeline-step-settings.tsx index 0940981d..09025855 100644 --- a/apps/web/src/components/tools/pipeline-step-settings.tsx +++ b/apps/web/src/components/tools/pipeline-step-settings.tsx @@ -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) => boolean; -} - -const TOOL_FIELDS: Record = { - 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 ; + if (toolId === "crop") return ; + if (toolId === "rotate") return ; + if (toolId === "convert") return ; + if (toolId === "compress") return ; + if (toolId === "strip-metadata") return ; + if (toolId === "border") return ; + if (toolId === "watermark-text") return ; + if (toolId === "text-overlay") return ; + if (toolId === "replace-color") return ; + if (toolId === "smart-crop") return ; + if (toolId === "gif-tools") return ; + if (toolId === "upscale") return ; + if (toolId === "blur-faces") return ; + if (toolId === "remove-background") return ; - } - - const fields = TOOL_FIELDS[toolId]; - - if (!fields || fields.length === 0) { - return ( -

- No configurable settings. Defaults will be used. -

- ); - } - - 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 ; return ( -
- {visibleFields.map((field) => { - const raw = settings[field.key]; - const value = raw !== undefined ? raw : field.defaultValue; - - switch (field.type) { - case "number": - return ( -
- - - 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" - /> -
- ); - - case "text": - return ( -
- - 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" - /> -
- ); - - case "select": { - const opts = field.options ?? []; - // Use button group for <= 4 options, dropdown for more - if (opts.length <= 4) { - return ( -
-

{field.label}

-
- {opts.map((opt) => ( - - ))} -
-
- ); - } - return ( -
- - -
- ); - } - - case "boolean": - return ( - - ); - - case "color": - return ( -
- -
- updateField(field.key, e.target.value)} - className="h-8 w-8 rounded border border-border cursor-pointer bg-background" - /> - 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" - /> -
-
- ); - - default: - return null; - } - })} -
+

+ No configurable settings. Defaults will be used. +

); } diff --git a/apps/web/src/components/tools/replace-color-settings.tsx b/apps/web/src/components/tools/replace-color-settings.tsx index 30d71e73..6d9ff506 100644 --- a/apps/web/src/components/tools/replace-color-settings.tsx +++ b/apps/web/src/components/tools/replace-color-settings.tsx @@ -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) => 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 (
@@ -100,6 +90,38 @@ export function ReplaceColorSettings() { Wide range
+ + ); +} + +export function ReplaceColorSettings() { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("replace-color"); + + const [settings, setSettings] = useState>({}); + + const handleProcess = () => { + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const hasFile = files.length > 0; + + return ( +
+ {error &&

{error}

} diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx index 2b44e83f..00a7bfd1 100644 --- a/apps/web/src/components/tools/resize-settings.tsx +++ b/apps/web/src/components/tools/resize-settings.tsx @@ -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 = { // 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) => void; +} +export function ResizeControls({ onChange }: ResizeControlsProps) { const [tab, setTab] = useState("custom"); const [selectedPreset, setSelectedPreset] = useState(null); const [width, setWidth] = useState(""); @@ -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 = {}; + 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 = {}; - - 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 ( -
+
{/* Tab selector */}
@@ -243,6 +231,42 @@ export function ResizeSettings() {
)} +
+ ); +} + +export function ResizeSettings() { + const { files } = useFileStore(); + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + useToolProcessor("resize"); + + const [settings, setSettings] = useState>({}); + + 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 ( + + {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx index 756e3791..6ceb0f87 100644 --- a/apps/web/src/components/tools/rotate-settings.tsx +++ b/apps/web/src/components/tools/rotate-settings.tsx @@ -10,14 +10,13 @@ export interface PreviewTransform { flipV: boolean; } -interface RotateSettingsProps { +export interface RotateControlsProps { + onChange?: (settings: Record) => 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 ( - +
{/* Quick rotate */}

Rotate

@@ -232,6 +220,56 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { Reset all changes )} +
+ ); +} + +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>({}); + 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 ( + + {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/smart-crop-settings.tsx b/apps/web/src/components/tools/smart-crop-settings.tsx index 20ac83a0..2dfd11bd 100644 --- a/apps/web/src/components/tools/smart-crop-settings.tsx +++ b/apps/web/src/components/tools/smart-crop-settings.tsx @@ -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) => 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 (
{/* 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.

+
+ ); +} + +export function SmartCropSettings() { + const { files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("smart-crop"); + + const [settings, setSettings] = useState>({}); + + 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 ( +
+ {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx index ecb1006f..7f8a3090 100644 --- a/apps/web/src/components/tools/strip-metadata-settings.tsx +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -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) => 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 */} + + +
+ + {/* Individual options */} +
+

Or select specific metadata:

+ + + + + + + + +
+ + ); +} + +export function StripMetadataSettings() { + const { entries, selectedIndex, files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("strip-metadata"); + + const [stripSettings, setStripSettings] = useState>({ + stripAll: true, + stripExif: false, + stripGps: false, + stripIcc: false, + stripXmp: false, + }); + // Per-file metadata cache (from feature branch) const [metadataCache, setMetadataCache] = useState>(new Map()); const [metadata, setMetadata] = useState(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 &&
} - {/* Strip All */} - - -
- - {/* Individual options */} -
-

Or select specific metadata:

- - - - - - - - -
+ {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/text-overlay-settings.tsx b/apps/web/src/components/tools/text-overlay-settings.tsx index c173a638..a9363ed9 100644 --- a/apps/web/src/components/tools/text-overlay-settings.tsx +++ b/apps/web/src/components/tools/text-overlay-settings.tsx @@ -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) => 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 (
@@ -132,6 +130,38 @@ export function TextOverlaySettings() { />
)} +
+ ); +} + +export function TextOverlaySettings() { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("text-overlay"); + + const [settings, setSettings] = useState>({}); + + const handleProcess = () => { + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const hasFile = files.length > 0; + + return ( +
+ {error &&

{error}

} @@ -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"} diff --git a/apps/web/src/components/tools/upscale-settings.tsx b/apps/web/src/components/tools/upscale-settings.tsx index 50738509..11188c75 100644 --- a/apps/web/src/components/tools/upscale-settings.tsx +++ b/apps/web/src/components/tools/upscale-settings.tsx @@ -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) => 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 (
@@ -53,6 +56,25 @@ export function UpscaleSettings() { className="w-full mt-2" />
+
+ ); +} + +export function UpscaleSettings() { + const { files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("upscale"); + const [settings, setSettings] = useState>({}); + + const handleProcess = () => { + processFiles(files, settings); + }; + + const hasFile = files.length > 0; + + return ( +
+ {/* Error */} {error &&

{error}

} @@ -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`} )} diff --git a/apps/web/src/components/tools/watermark-text-settings.tsx b/apps/web/src/components/tools/watermark-text-settings.tsx index 6ee3d7c4..4aa76a72 100644 --- a/apps/web/src/components/tools/watermark-text-settings.tsx +++ b/apps/web/src/components/tools/watermark-text-settings.tsx @@ -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) => 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("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 (
@@ -138,6 +128,38 @@ export function WatermarkTextSettings() { className="w-full mt-1" />
+
+ ); +} + +export function WatermarkTextSettings() { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("watermark-text"); + + const [settings, setSettings] = useState>({}); + + const handleProcess = () => { + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const hasFile = files.length > 0; + + return ( +
+ {error &&

{error}

} @@ -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"}