mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
||
import { Download, Link, Unlink } 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";
|
||
|
||
type ResizeTab = "presets" | "custom" | "scale";
|
||
type FitMode = "cover" | "contain" | "fill";
|
||
|
||
const FIT_LABELS: Record<FitMode, string> = {
|
||
cover: "Crop to fit",
|
||
contain: "Fit inside",
|
||
fill: "Stretch",
|
||
};
|
||
|
||
// Group presets by platform
|
||
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
||
|
||
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>("");
|
||
const [height, setHeight] = useState<string>("");
|
||
const [percentage, setPercentage] = useState<string>("50");
|
||
const [fit, setFit] = useState<FitMode>("cover");
|
||
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) {
|
||
setSelectedPreset(null);
|
||
setWidth("");
|
||
setHeight("");
|
||
} else {
|
||
setSelectedPreset(key);
|
||
setWidth(String(preset.width));
|
||
setHeight(String(preset.height));
|
||
}
|
||
};
|
||
|
||
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 (
|
||
<div className="space-y-4">
|
||
{/* Tab selector */}
|
||
<div>
|
||
<div className="flex gap-1">
|
||
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
||
Custom Size
|
||
</button>
|
||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||
Scale
|
||
</button>
|
||
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||
Presets
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Presets tab */}
|
||
{tab === "presets" && (
|
||
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
|
||
{platforms.map((platform) => (
|
||
<div key={platform}>
|
||
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
|
||
<div className="space-y-1">
|
||
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
|
||
const key = `${preset.platform}-${preset.name}`;
|
||
const isSelected = selectedPreset === key;
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => handlePreset(preset)}
|
||
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
|
||
isSelected
|
||
? "border-primary bg-primary/10 text-foreground"
|
||
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
||
}`}
|
||
>
|
||
<span>{preset.name}</span>
|
||
<span className="text-xs tabular-nums">
|
||
{preset.width} × {preset.height}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Don't enlarge */}
|
||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={withoutEnlargement}
|
||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||
className="rounded"
|
||
/>
|
||
Don't enlarge
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custom Size tab */}
|
||
{tab === "custom" && (
|
||
<div className="space-y-3">
|
||
<div className="flex items-end gap-2">
|
||
<div className="flex-1">
|
||
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
|
||
Width (px)
|
||
</label>
|
||
<input
|
||
id="resize-width"
|
||
type="number"
|
||
value={width}
|
||
onChange={(e) => setWidth(e.target.value)}
|
||
placeholder="Auto"
|
||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setLockAspect(!lockAspect)}
|
||
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
||
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||
>
|
||
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
||
</button>
|
||
<div className="flex-1">
|
||
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
||
Height (px)
|
||
</label>
|
||
<input
|
||
id="resize-height"
|
||
type="number"
|
||
value={height}
|
||
onChange={(e) => setHeight(e.target.value)}
|
||
placeholder="Auto"
|
||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Fit mode */}
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">Fit Mode</p>
|
||
<div className="flex gap-1 mt-1">
|
||
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
|
||
<button
|
||
key={f}
|
||
type="button"
|
||
onClick={() => setFit(f)}
|
||
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||
>
|
||
{FIT_LABELS[f]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Don't enlarge */}
|
||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||
<input
|
||
type="checkbox"
|
||
checked={withoutEnlargement}
|
||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||
className="rounded"
|
||
/>
|
||
Don't enlarge
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
{/* Scale tab */}
|
||
{tab === "scale" && (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
|
||
Scale (%)
|
||
</label>
|
||
<input
|
||
id="resize-scale"
|
||
type="number"
|
||
value={percentage}
|
||
onChange={(e) => setPercentage(e.target.value)}
|
||
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 gap-1">
|
||
{[25, 50, 75].map((pct) => (
|
||
<button
|
||
key={pct}
|
||
type="button"
|
||
onClick={() => setPercentage(String(pct))}
|
||
className={`flex-1 text-xs py-1.5 rounded ${
|
||
percentage === String(pct)
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-muted text-muted-foreground"
|
||
}`}
|
||
>
|
||
{pct}%
|
||
</button>
|
||
))}
|
||
</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>}
|
||
|
||
{/* Process button */}
|
||
{processing ? (
|
||
<ProgressCard
|
||
active={processing}
|
||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||
label="Resizing"
|
||
stage={progress.stage}
|
||
percent={progress.percent}
|
||
elapsed={progress.elapsed}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="submit"
|
||
data-testid="resize-submit"
|
||
disabled={!canProcess}
|
||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
>
|
||
{files.length > 1 ? `Resize (${files.length} files)` : "Resize"}
|
||
</button>
|
||
)}
|
||
|
||
{/* Download */}
|
||
{downloadUrl && (
|
||
<a
|
||
href={downloadUrl}
|
||
download
|
||
data-testid="resize-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>
|
||
)}
|
||
</form>
|
||
);
|
||
}
|