diff --git a/apps/web/src/components/tools/beautify-settings.tsx b/apps/web/src/components/tools/beautify-settings.tsx new file mode 100644 index 00000000..e14645f0 --- /dev/null +++ b/apps/web/src/components/tools/beautify-settings.tsx @@ -0,0 +1,1093 @@ +import { Download, Plus, Upload, X } from "lucide-react"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CollapsibleSection } from "@/components/common/collapsible-section"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { formatHeaders } from "@/lib/api"; +import { useFileStore } from "@/stores/file-store"; + +// -- Preset types ----------------------------------------------------------- + +interface GradientStop { + color: string; + position: number; +} + +interface BeautifyPreset { + name: string; + backgroundType: string; + backgroundColor: string; + gradientStops: GradientStop[]; + gradientAngle: number; + padding: number; + borderRadius: number; + shadowPreset: string; + frame: string; +} + +// -- Presets ---------------------------------------------------------------- + +const PRESETS: BeautifyPreset[] = [ + { + name: "Purple Haze", + backgroundType: "linear-gradient", + backgroundColor: "#667eea", + gradientStops: [ + { color: "#667eea", position: 0 }, + { color: "#764ba2", position: 100 }, + ], + gradientAngle: 135, + padding: 64, + borderRadius: 12, + shadowPreset: "subtle", + frame: "macos-light", + }, + { + name: "Flamingo", + backgroundType: "linear-gradient", + backgroundColor: "#f093fb", + gradientStops: [ + { color: "#f093fb", position: 0 }, + { color: "#f5576c", position: 100 }, + ], + gradientAngle: 135, + padding: 48, + borderRadius: 12, + shadowPreset: "medium", + frame: "none", + }, + { + name: "Ocean", + backgroundType: "linear-gradient", + backgroundColor: "#4facfe", + gradientStops: [ + { color: "#4facfe", position: 0 }, + { color: "#00f2fe", position: 100 }, + ], + gradientAngle: 135, + padding: 64, + borderRadius: 12, + shadowPreset: "subtle", + frame: "browser-light", + }, + { + name: "Midnight", + backgroundType: "linear-gradient", + backgroundColor: "#0f0c29", + gradientStops: [ + { color: "#0f0c29", position: 0 }, + { color: "#302b63", position: 50 }, + { color: "#24243e", position: 100 }, + ], + gradientAngle: 135, + padding: 80, + borderRadius: 16, + shadowPreset: "dramatic", + frame: "macos-dark", + }, + { + name: "Mint", + backgroundType: "linear-gradient", + backgroundColor: "#43e97b", + gradientStops: [ + { color: "#43e97b", position: 0 }, + { color: "#38f9d7", position: 100 }, + ], + gradientAngle: 135, + padding: 48, + borderRadius: 12, + shadowPreset: "subtle", + frame: "none", + }, + { + name: "Sunset", + backgroundType: "linear-gradient", + backgroundColor: "#fa709a", + gradientStops: [ + { color: "#fa709a", position: 0 }, + { color: "#fee140", position: 100 }, + ], + gradientAngle: 135, + padding: 64, + borderRadius: 12, + shadowPreset: "medium", + frame: "none", + }, + { + name: "Clean White", + backgroundType: "solid", + backgroundColor: "#f8fafc", + gradientStops: [ + { color: "#f8fafc", position: 0 }, + { color: "#f8fafc", position: 100 }, + ], + gradientAngle: 135, + padding: 64, + borderRadius: 12, + shadowPreset: "subtle", + frame: "macos-light", + }, + { + name: "No Background", + backgroundType: "transparent", + backgroundColor: "#ffffff", + gradientStops: [ + { color: "#ffffff", position: 0 }, + { color: "#ffffff", position: 100 }, + ], + gradientAngle: 135, + padding: 0, + borderRadius: 0, + shadowPreset: "none", + frame: "none", + }, +]; + +// -- Helpers ---------------------------------------------------------------- + +const SHADOW_CSS: Record = { + subtle: "0px 4px 20px rgba(0,0,0,0.2)", + medium: "0px 10px 40px rgba(0,0,0,0.35)", + dramatic: "0px 20px 80px rgba(0,0,0,0.5)", +}; + +function buildPreviewStyle(settings: Record): React.CSSProperties { + const bg = settings.backgroundType as string; + let background: string | undefined; + + if (bg === "solid") { + background = settings.backgroundColor as string; + } else if (bg === "linear-gradient") { + const stops = settings.gradientStops as GradientStop[]; + const angle = settings.gradientAngle as number; + background = `linear-gradient(${angle}deg, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`; + } else if (bg === "radial-gradient") { + const stops = settings.gradientStops as GradientStop[]; + background = `radial-gradient(circle, ${stops.map((s) => `${s.color} ${s.position}%`).join(", ")})`; + } + + const shadowPreset = settings.shadowPreset as string; + + return { + background, + padding: `${settings.padding}px`, + borderRadius: `${settings.borderRadius}px`, + boxShadow: SHADOW_CSS[shadowPreset], + }; +} + +function presetGradientCSS(preset: BeautifyPreset): string { + if (preset.backgroundType === "transparent") return "transparent"; + if (preset.backgroundType === "solid") return preset.backgroundColor; + const stops = preset.gradientStops.map((s) => `${s.color} ${s.position}%`).join(", "); + return `linear-gradient(${preset.gradientAngle}deg, ${stops})`; +} + +// -- Background tab types --------------------------------------------------- + +type BackgroundTab = "linear-gradient" | "radial-gradient" | "solid" | "image" | "transparent"; +type FrameType = "none" | "macos" | "windows" | "browser" | "iphone" | "macbook" | "ipad"; +type FrameTheme = "light" | "dark"; +type ShadowChip = "none" | "subtle" | "medium" | "dramatic" | "custom"; +type SocialChip = + | "none" + | "twitter" + | "linkedin" + | "instagram-square" + | "instagram-story" + | "facebook" + | "producthunt"; + +const BACKGROUND_TABS: { value: BackgroundTab; label: string }[] = [ + { value: "linear-gradient", label: "Gradient" }, + { value: "solid", label: "Solid" }, + { value: "image", label: "Image" }, + { value: "transparent", label: "None" }, +]; + +const FRAME_TYPES: { value: FrameType; label: string }[] = [ + { value: "none", label: "None" }, + { value: "macos", label: "macOS" }, + { value: "windows", label: "Windows" }, + { value: "browser", label: "Browser" }, + { value: "iphone", label: "iPhone" }, + { value: "macbook", label: "MacBook" }, + { value: "ipad", label: "iPad" }, +]; + +const SHADOW_CHIPS: { value: ShadowChip; label: string }[] = [ + { value: "none", label: "None" }, + { value: "subtle", label: "Subtle" }, + { value: "medium", label: "Medium" }, + { value: "dramatic", label: "Dramatic" }, + { value: "custom", label: "Custom" }, +]; + +const SOCIAL_CHIPS: { value: SocialChip; label: string }[] = [ + { value: "none", label: "Original" }, + { value: "twitter", label: "X/Twitter" }, + { value: "linkedin", label: "LinkedIn" }, + { value: "instagram-square", label: "IG Square" }, + { value: "instagram-story", label: "IG Story" }, + { value: "facebook", label: "Facebook" }, + { value: "producthunt", label: "Product Hunt" }, +]; + +const WATERMARK_POSITIONS = [ + { value: "top-left", label: "Top Left" }, + { value: "top-right", label: "Top Right" }, + { value: "center", label: "Center" }, + { value: "bottom-left", label: "Bottom Left" }, + { value: "bottom-right", label: "Bottom Right" }, +]; + +// -- Controls --------------------------------------------------------------- + +export interface BeautifyControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; + onImageStyle?: (style: React.CSSProperties | null) => void; + onBackgroundImage?: (file: File | null) => void; +} + +export function BeautifyControls({ + settings: initialSettings, + onChange, + onImageStyle, + onBackgroundImage, +}: BeautifyControlsProps) { + // State + const [selectedPreset, setSelectedPreset] = useState("Purple Haze"); + const [backgroundType, setBackgroundType] = useState("linear-gradient"); + const [backgroundColor, setBackgroundColor] = useState("#667eea"); + const [gradientStops, setGradientStops] = useState([ + { color: "#667eea", position: 0 }, + { color: "#764ba2", position: 100 }, + ]); + const [gradientAngle, setGradientAngle] = useState(135); + const [gradientMode, setGradientMode] = useState<"linear" | "radial">("linear"); + const [bgImageFile, setBgImageFile] = useState(null); + const bgInputRef = useRef(null); + + const [frameType, setFrameType] = useState("macos"); + const [frameTheme, setFrameTheme] = useState("light"); + const [frameTitle, setFrameTitle] = useState(""); + + const [padding, setPadding] = useState(64); + const [borderRadius, setBorderRadius] = useState(12); + + const [shadowPreset, setShadowPreset] = useState("subtle"); + const [shadowBlur, setShadowBlur] = useState(20); + const [shadowOffsetX, setShadowOffsetX] = useState(0); + const [shadowOffsetY, setShadowOffsetY] = useState(10); + const [shadowColor, setShadowColor] = useState("#000000"); + const [shadowOpacity, setShadowOpacity] = useState(30); + + const [socialPreset, setSocialPreset] = useState("none"); + + const [watermarkText, setWatermarkText] = useState(""); + const [watermarkPosition, setWatermarkPosition] = useState("bottom-right"); + const [watermarkOpacity, setWatermarkOpacity] = useState(50); + + // Initialize from external settings + const initializedRef = useRef(false); + useEffect(() => { + if (!initialSettings || initializedRef.current) return; + initializedRef.current = true; + if (initialSettings.backgroundType != null) + setBackgroundType(initialSettings.backgroundType as BackgroundTab); + if (initialSettings.backgroundColor != null) + setBackgroundColor(String(initialSettings.backgroundColor)); + if (initialSettings.gradientStops != null) + setGradientStops(initialSettings.gradientStops as GradientStop[]); + if (initialSettings.gradientAngle != null) + setGradientAngle(Number(initialSettings.gradientAngle)); + if (initialSettings.padding != null) setPadding(Number(initialSettings.padding)); + if (initialSettings.borderRadius != null) setBorderRadius(Number(initialSettings.borderRadius)); + if (initialSettings.shadowPreset != null) + setShadowPreset(initialSettings.shadowPreset as ShadowChip); + }, [initialSettings]); + + // Refs for stable callbacks + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }); + + const onImageStyleRef = useRef(onImageStyle); + useEffect(() => { + onImageStyleRef.current = onImageStyle; + }); + + const onBgImageRef = useRef(onBackgroundImage); + useEffect(() => { + onBgImageRef.current = onBackgroundImage; + }); + + // Resolve frame string from type + theme + const resolvedFrame = frameType === "none" ? "none" : (`${frameType}-${frameTheme}` as const); + + // Resolve background type from tab + gradient mode + const resolvedBgType: string = + backgroundType === "linear-gradient" || backgroundType === "radial-gradient" + ? gradientMode === "radial" + ? "radial-gradient" + : "linear-gradient" + : backgroundType; + + // Propagate changes + useEffect(() => { + const vals: Record = { + backgroundType: resolvedBgType, + backgroundColor, + gradientStops, + gradientAngle, + padding, + borderRadius, + shadowPreset, + shadowBlur, + shadowOffsetX, + shadowOffsetY, + shadowColor, + shadowOpacity, + frame: resolvedFrame, + frameTitle: frameTitle || undefined, + socialPreset, + watermarkText: watermarkText || undefined, + watermarkPosition, + watermarkOpacity, + }; + onChangeRef.current?.(vals); + onImageStyleRef.current?.(buildPreviewStyle(vals)); + }, [ + resolvedBgType, + backgroundColor, + gradientStops, + gradientAngle, + padding, + borderRadius, + shadowPreset, + shadowBlur, + shadowOffsetX, + shadowOffsetY, + shadowColor, + shadowOpacity, + resolvedFrame, + frameTitle, + socialPreset, + watermarkText, + watermarkPosition, + watermarkOpacity, + ]); + + const clearPreset = () => setSelectedPreset(null); + + const applyPreset = (preset: BeautifyPreset) => { + setSelectedPreset(preset.name); + const bgTab: BackgroundTab = + preset.backgroundType === "radial-gradient" + ? "linear-gradient" + : (preset.backgroundType as BackgroundTab); + setBackgroundType(bgTab); + setGradientMode(preset.backgroundType === "radial-gradient" ? "radial" : "linear"); + setBackgroundColor(preset.backgroundColor); + setGradientStops(preset.gradientStops); + setGradientAngle(preset.gradientAngle); + setPadding(preset.padding); + setBorderRadius(preset.borderRadius); + setShadowPreset(preset.shadowPreset as ShadowChip); + + // Parse frame into type + theme + if (preset.frame === "none") { + setFrameType("none"); + } else { + const parts = preset.frame.split("-"); + const theme = parts.pop() as FrameTheme; + const type = parts.join("-") as FrameType; + setFrameType(type); + setFrameTheme(theme); + } + }; + + const handleStopColorChange = (index: number, color: string) => { + clearPreset(); + setGradientStops((prev) => prev.map((s, i) => (i === index ? { ...s, color } : s))); + }; + + const handleAddStop = () => { + clearPreset(); + setGradientStops((prev) => [...prev, { color: "#ffffff", position: 50 }]); + }; + + const handleRemoveStop = (index: number) => { + if (gradientStops.length <= 2) return; + clearPreset(); + setGradientStops((prev) => prev.filter((_, i) => i !== index)); + }; + + const hasFrameTitle = frameType === "macos" || frameType === "windows" || frameType === "browser"; + + return ( +
+ {/* 1. Quick Presets */} + +
+ {PRESETS.map((preset) => ( + + ))} +
+
+ + {/* 2. Background */} + +
+ {/* Tab buttons */} +
+ {BACKGROUND_TABS.map((tab) => ( + + ))} +
+ + {/* Gradient mode */} + {(backgroundType === "linear-gradient" || backgroundType === "radial-gradient") && ( +
+
+ + +
+ + {/* Color stops */} +
+

Color Stops

+ {gradientStops.map((stop, i) => ( +
+ handleStopColorChange(i, e.target.value)} + className="h-6 w-6 rounded border border-border cursor-pointer appearance-none bg-transparent shrink-0" + /> + + {stop.color} + + {gradientStops.length > 2 && ( + + )} +
+ ))} + +
+ + {/* Angle (linear only) */} + {gradientMode === "linear" && ( +
+
+ + {gradientAngle}deg +
+ { + clearPreset(); + setGradientAngle(Number(e.target.value)); + }} + className="w-full mt-1" + /> +
+ )} +
+ )} + + {/* Solid mode */} + {backgroundType === "solid" && ( +
+ +
+ { + clearPreset(); + setBackgroundColor(e.target.value); + }} + className="h-6 w-6 rounded border border-border cursor-pointer appearance-none bg-transparent" + /> + + {backgroundColor} + +
+
+ )} + + {/* Image mode */} + {backgroundType === "image" && ( +
+ { + const file = e.target.files?.[0] ?? null; + setBgImageFile(file); + onBgImageRef.current?.(file); + }} + className="hidden" + /> + +
+ )} +
+
+ + {/* 3. Device Frame */} + +
+
+ {FRAME_TYPES.map((ft) => ( + + ))} +
+ + {frameType !== "none" && ( + <> +
+ + +
+ + {hasFrameTitle && ( +
+ + setFrameTitle(e.target.value)} + placeholder="Window title" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ )} + + )} +
+
+ + {/* 4. Spacing */} + +
+
+
+ + {padding}px +
+ { + clearPreset(); + setPadding(Number(e.target.value)); + }} + className="w-full mt-1" + /> +
+ +
+
+ + {borderRadius}px +
+ { + clearPreset(); + setBorderRadius(Number(e.target.value)); + }} + className="w-full mt-1" + /> +
+
+
+ + {/* 5. Shadow */} + +
+
+ {SHADOW_CHIPS.map((chip) => ( + + ))} +
+ + {shadowPreset === "custom" && ( +
+
+
+ + {shadowBlur}px +
+ setShadowBlur(Number(e.target.value))} + className="w-full mt-1" + /> +
+ +
+
+
+ + {shadowOffsetX} +
+ setShadowOffsetX(Number(e.target.value))} + className="w-full mt-1" + /> +
+
+
+ + {shadowOffsetY} +
+ setShadowOffsetY(Number(e.target.value))} + className="w-full mt-1" + /> +
+
+ +
+ +
+ setShadowColor(e.target.value)} + className="h-6 w-6 rounded border border-border cursor-pointer appearance-none bg-transparent" + /> + {shadowColor} +
+
+ +
+
+ + {shadowOpacity}% +
+ setShadowOpacity(Number(e.target.value))} + className="w-full mt-1" + /> +
+
+ )} +
+
+ + {/* 6. Export Size */} + +
+ {SOCIAL_CHIPS.map((chip) => ( + + ))} +
+
+ + {/* 7. Watermark */} + +
+
+ + setWatermarkText(e.target.value)} + placeholder="Your watermark text" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+ +
+ + +
+ +
+
+ + {watermarkOpacity}% +
+ setWatermarkOpacity(Number(e.target.value))} + className="w-full mt-1" + /> +
+
+
+
+ ); +} + +// -- Settings Panel --------------------------------------------------------- + +export function BeautifySettings({ + onImageStyle, +}: { + onImageStyle?: (style: React.CSSProperties | null) => void; +}) { + const { files, setProcessedUrl, setSizes, setJobId } = useFileStore(); + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + useToolProcessor("beautify"); + + const [settings, setSettings] = useState>({}); + const [bgImageFile, setBgImageFile] = useState(null); + const [manualDownloadUrl, setManualDownloadUrl] = useState(null); + const [manualProcessing, setManualProcessing] = useState(false); + const [manualError, setManualError] = useState(null); + + const handleSettingsChange = useCallback((s: Record) => { + setSettings(s); + }, []); + + const handleBgImageChange = useCallback((file: File | null) => { + setBgImageFile(file); + }, []); + + const hasFile = files.length > 0; + const isImageBg = settings.backgroundType === "image"; + const isProcessing = processing || manualProcessing; + const displayError = error || manualError; + const displayDownloadUrl = manualDownloadUrl || downloadUrl; + + const handleProcess = async () => { + if (!hasFile) return; + + // For image background mode, use manual FormData (like compose-settings) + if (isImageBg && bgImageFile) { + setManualProcessing(true); + setManualError(null); + setManualDownloadUrl(null); + + try { + const formData = new FormData(); + formData.append("file", files[0]); + formData.append("backgroundImage", bgImageFile); + formData.append("settings", JSON.stringify(settings)); + + const res = await fetch("/api/v1/tools/beautify", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Processing failed: ${res.status}`); + } + + const result = await res.json(); + setJobId(result.jobId); + setProcessedUrl(result.downloadUrl); + setManualDownloadUrl(result.downloadUrl); + setSizes(result.originalSize, result.processedSize); + } catch (err) { + setManualError(err instanceof Error ? err.message : "Processing failed"); + } finally { + setManualProcessing(false); + } + return; + } + + // Standard processing via useToolProcessor + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (hasFile && !isProcessing) handleProcess(); + }; + + return ( +
+ + + {displayError &&

{displayError}

} + + {processing ? ( + + ) : ( + + )} + + {displayDownloadUrl && ( + + + Download + + )} + + ); +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 431e8983..168c32d8 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -194,6 +194,9 @@ const SplitCanvas = lazy(() => const BorderSettings = lazy(() => import("@/components/tools/border-settings").then((m) => ({ default: m.BorderSettings })), ); +const BeautifySettings = lazy(() => + import("@/components/tools/beautify-settings").then((m) => ({ default: m.BeautifySettings })), +); const SvgToRasterSettings = lazy(() => import("@/components/tools/svg-to-raster-settings").then((m) => ({ default: m.SvgToRasterSettings, @@ -413,6 +416,10 @@ export const toolRegistry = new Map([ { displayMode: "interactive-split", Settings: SplitSettings, ResultsPanel: SplitCanvas }, ], ["border", { displayMode: "live-preview", livePreview: true, Settings: BorderSettings as never }], + [ + "beautify", + { displayMode: "live-preview", livePreview: true, Settings: BeautifySettings as never }, + ], // Format & Conversion ["svg-to-raster", { displayMode: "before-after", Settings: SvgToRasterSettings }],