Files
SnapOtter/apps/web/src/components/tools/smart-crop-settings.tsx
T
Siddharth Kumar Sah 1c05bc76e5 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
2026-03-28 16:04:42 +08:00

179 lines
5.5 KiB
TypeScript

import { Download } from "lucide-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 ASPECT_PRESETS = [
{ label: "1:1 Square", w: 1080, h: 1080 },
{ label: "16:9 Landscape", w: 1920, h: 1080 },
{ label: "9:16 Portrait", w: 1080, h: 1920 },
{ label: "4:3 Standard", w: 1440, h: 1080 },
{ label: "3:2 Photo", w: 1620, h: 1080 },
{ label: "Custom", w: 0, h: 0 },
];
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);
if (p && p.w > 0) {
setWidth(String(p.w));
setHeight(String(p.h));
}
};
return (
<div className="space-y-4">
{/* Aspect ratio preset */}
<div>
<label htmlFor="smart-crop-preset" className="text-sm font-medium text-muted-foreground">
Target Aspect Ratio
</label>
<select
id="smart-crop-preset"
value={preset}
onChange={(e) => handlePreset(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
{ASPECT_PRESETS.map((p) => (
<option key={p.label} value={p.label}>
{p.label}
</option>
))}
</select>
</div>
{/* Width / Height */}
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor="smart-crop-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="smart-crop-width"
type="number"
value={width}
onChange={(e) => {
setWidth(e.target.value);
setPreset("Custom");
}}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex-1">
<label htmlFor="smart-crop-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="smart-crop-height"
type="number"
value={height}
onChange={(e) => {
setHeight(e.target.value);
setPreset("Custom");
}}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
{/* Info */}
<p className="text-[10px] text-muted-foreground">
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>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Cropped: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
{/* Process button */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Smart cropping"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="smart-crop-submit"
onClick={handleProcess}
disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Smart Crop
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="smart-crop-download"
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}