import { SOCIAL_MEDIA_PRESETS } from "@snapotter/shared"; import { Download, Info } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { largestRatioBox, pairedDimension, RESIZE_RATIO_PRESETS } from "@/lib/aspect-ratio"; import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; type ResizeTab = "presets" | "custom" | "scale" | "content-aware"; type FitMode = "cover" | "contain" | "fill"; const FIT_MODES: FitMode[] = ["cover", "contain", "fill"]; // Group presets by platform const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))]; function HintIcon({ text }: { text: string }) { return ( {text} ); } export interface ResizeControlsProps { settings?: Record; onChange?: (settings: Record) => void; } export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) { const { t } = useTranslation(); const { currentEntry } = useFileStore(); const [tab, setTab] = useState("custom"); const [selectedPreset, setSelectedPreset] = useState(null); const [width, setWidth] = useState(""); const [height, setHeight] = useState(""); const [percentage, setPercentage] = useState("50"); const [fit, setFit] = useState("cover"); // "free" = independent width/height (default, unchanged behavior). "original" = // lock to the source image's ratio. Otherwise a RESIZE_RATIO_PRESETS id (e.g. "16:9"). const [ratioId, setRatioId] = useState("free"); const [withoutEnlargement, setWithoutEnlargement] = useState(false); const contentAware = tab === "content-aware"; const [protectFaces, setProtectFaces] = useState(false); const [blurRadius, setBlurRadius] = useState(4); const [sobelThreshold, setSobelThreshold] = useState(2); const [squareMode, setSquareMode] = useState(false); const initializedRef = useRef(false); useEffect(() => { if (!initialSettings || initializedRef.current) return; initializedRef.current = true; if (initialSettings.width != null) setWidth(String(initialSettings.width)); if (initialSettings.height != null) setHeight(String(initialSettings.height)); if (initialSettings.percentage != null) setPercentage(String(initialSettings.percentage)); if (initialSettings.fit != null) setFit(initialSettings.fit as FitMode); if (initialSettings.withoutEnlargement != null) setWithoutEnlargement(Boolean(initialSettings.withoutEnlargement)); if (initialSettings.protectFaces != null) setProtectFaces(Boolean(initialSettings.protectFaces)); if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius)); if (initialSettings.sobelThreshold != null) setSobelThreshold(Number(initialSettings.sobelThreshold)); if (initialSettings.square != null) setSquareMode(Boolean(initialSettings.square)); if (initialSettings.contentAware) setTab("content-aware"); else if (initialSettings.percentage != null) setTab("scale"); }, [initialSettings]); const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }); useEffect(() => { const settings: Record = {}; if (contentAware) { settings.contentAware = true; if (!squareMode) { if (width) settings.width = Number(width); if (height) settings.height = Number(height); } settings.protectFaces = protectFaces; settings.blurRadius = blurRadius; settings.sobelThreshold = sobelThreshold; settings.square = squareMode; } else 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, protectFaces, blurRadius, sobelThreshold, squareMode, contentAware, ]); // Resolve a chip id to a numeric ratio (width / height), or null when it // shouldn't lock (Free, or Original before the source dimensions are known). const ratioValueFor = (id: string): number | null => { if (id === "free") return null; if (id === "original") { const w = currentEntry?.originalWidth; const h = currentEntry?.originalHeight; return w && h ? w / h : null; } return RESIZE_RATIO_PRESETS.find((p) => p.id === id)?.value ?? null; }; // Linking only applies on the Custom tab; the width/height inputs are shared // with the Content-Aware tab, which manages its dimensions independently. const handleWidthChange = (raw: string) => { setWidth(raw); if (tab !== "custom") return; const r = ratioValueFor(ratioId); const n = Number(raw); if (r && raw !== "" && Number.isFinite(n) && n > 0) { setHeight(String(pairedDimension(n, r, "width"))); } }; const handleHeightChange = (raw: string) => { setHeight(raw); if (tab !== "custom") return; const r = ratioValueFor(ratioId); const n = Number(raw); if (r && raw !== "" && Number.isFinite(n) && n > 0) { setWidth(String(pairedDimension(n, r, "height"))); } }; const handleRatioSelect = (id: string) => { setRatioId(id); const r = ratioValueFor(id); if (!r) return; const w = Number(width); const h = Number(height); if (width !== "" && Number.isFinite(w) && w > 0) { // Keep the width the user already has, snap height to the ratio. setHeight(String(pairedDimension(w, r, "width"))); } else if (height !== "" && Number.isFinite(h) && h > 0) { setWidth(String(pairedDimension(h, r, "height"))); } else if (currentEntry?.originalWidth && currentEntry?.originalHeight) { // Nothing typed yet: prefill the largest box of this ratio that fits. const box = largestRatioBox(currentEntry.originalWidth, currentEntry.originalHeight, r); setWidth(String(box.width)); setHeight(String(box.height)); } }; 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"}`; const ratioChips: { id: string; label: string }[] = [ { id: "free", label: t.toolSettings.resize.ratioFree }, { id: "original", label: t.toolSettings.resize.ratioOriginal }, ...RESIZE_RATIO_PRESETS.map((p) => ({ id: p.id, label: p.id })), ]; const dimensionInputs = (
handleWidthChange(e.target.value)} placeholder="Auto" disabled={squareMode && contentAware} className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground disabled:opacity-50" />
handleHeightChange(e.target.value)} placeholder="Auto" disabled={squareMode && contentAware} className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground disabled:opacity-50" />
); const enlargementCheckbox = ( ); return (
{/* Tab selector */}
{/* Presets tab */} {tab === "presets" && (
{platforms.map((platform) => (

{platform}

{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { const key = `${preset.platform}-${preset.name}`; const isSelected = selectedPreset === key; return ( ); })}
))} {enlargementCheckbox}
)} {/* Custom Size tab */} {tab === "custom" && (
{dimensionInputs} {/* Aspect ratio */}

{t.toolSettings.resize.aspectRatio}

{ratioChips.map((chip) => ( ))}
{/* Fit mode */}

{t.toolSettings.resize.fitMode}

{FIT_MODES.map((f) => ( ))}
{enlargementCheckbox}
)} {/* Scale tab */} {tab === "scale" && (
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" />
{[25, 50, 75].map((pct) => ( ))}
)} {/* Content-aware tab */} {contentAware && (
{dimensionInputs} {/* Square mode */} {/* Face protection */} {/* Blur radius */}
{blurRadius}
setBlurRadius(Number(e.target.value))} className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary" />
{/* Sobel threshold */}
{sobelThreshold}
setSobelThreshold(Number(e.target.value))} className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary" />
)}
); } export function ResizeSettings() { const { t } = useTranslation(); const { files } = useFileStore(); const standardResize = useToolProcessor("resize"); const contentAwareResize = useToolProcessor("content-aware-resize"); const [settings, setSettings] = useState>({}); const [isContentAware, setIsContentAware] = useState(false); const handleSettingsChange = useCallback((newSettings: Record) => { setSettings(newSettings); setIsContentAware(!!newSettings.contentAware); }, []); const active = isContentAware ? contentAwareResize : standardResize; const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = active; 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 && (isContentAware ? Boolean(settings.width) || Boolean(settings.height) || Boolean(settings.square) : 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}

} {/* Process button */} {processing ? ( ) : ( )} {/* Download */} {downloadUrl && ( {t.common.download} )} ); }