diff --git a/apps/web/src/components/tools/meme-generator-settings.tsx b/apps/web/src/components/tools/meme-generator-settings.tsx new file mode 100644 index 00000000..8356758b --- /dev/null +++ b/apps/web/src/components/tools/meme-generator-settings.tsx @@ -0,0 +1,1016 @@ +import { + AlignCenter, + AlignLeft, + AlignRight, + ArrowLeft, + Download, + ImagePlus, + Laugh, + Loader2, + Search, + Sparkles, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { formatHeaders } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +// ── Types ──────────────────────────────────────────────────────────── + +interface TemplateTextBox { + id: string; + x: number; + y: number; + width: number; + height: number; + defaultText?: string; +} + +interface MemeTemplate { + id: string; + name: string; + aliases: string[]; + tags: string[]; + category: string; + filename: string; + width: number; + height: number; + popularity: number; + textBoxes: TemplateTextBox[]; +} + +interface TemplateManifest { + version: number; + categories: string[]; + templates: MemeTemplate[]; +} + +type Phase = "gallery" | "layout-picker" | "editor" | "result"; +type TextLayout = "top-bottom" | "top-only" | "bottom-only" | "center" | "side-by-side"; + +interface TextBoxValue { + id: string; + text: string; +} + +// ── Constants ──────────────────────────────────────────────────────── + +const FONT_OPTIONS = [ + { value: "anton", label: "Anton" }, + { value: "arial-black", label: "Arial Black" }, + { value: "comic-sans", label: "Comic Sans" }, + { value: "montserrat", label: "Montserrat" }, + { value: "bebas-neue", label: "Bebas Neue" }, + { value: "permanent-marker", label: "Permanent Marker" }, + { value: "roboto", label: "Roboto Black" }, +] as const; + +const FONT_FAMILY_MAP: Record = { + anton: "'Anton', 'Impact', sans-serif", + "arial-black": "'Arial Black', 'Anton', sans-serif", + "comic-sans": "'Comic Sans MS', cursive", + montserrat: "'Montserrat Black', 'Anton', sans-serif", + "bebas-neue": "'Bebas Neue', 'Anton', sans-serif", + "permanent-marker": "'Permanent Marker', cursive", + roboto: "'Roboto Black', 'Anton', sans-serif", +}; + +const CATEGORIES = [ + { id: "all", label: "All" }, + { id: "reaction", label: "Reaction" }, + { id: "comparison", label: "Comparison" }, + { id: "opinion", label: "Opinion" }, + { id: "animals", label: "Animals" }, + { id: "classic", label: "Classic" }, +]; + +const PRESET_LAYOUTS: Record< + TextLayout, + { label: string; description: string; boxes: TemplateTextBox[] } +> = { + "top-bottom": { + label: "Top + Bottom", + description: "Classic meme layout", + boxes: [ + { id: "top", x: 5, y: 2, width: 90, height: 20, defaultText: "Top text" }, + { id: "bottom", x: 5, y: 78, width: 90, height: 20, defaultText: "Bottom text" }, + ], + }, + "top-only": { + label: "Top Only", + description: "Text at the top", + boxes: [{ id: "top", x: 5, y: 2, width: 90, height: 25, defaultText: "Top text" }], + }, + "bottom-only": { + label: "Bottom Only", + description: "Text at the bottom", + boxes: [{ id: "bottom", x: 5, y: 75, width: 90, height: 23, defaultText: "Bottom text" }], + }, + center: { + label: "Center", + description: "Text in the middle", + boxes: [{ id: "center", x: 10, y: 35, width: 80, height: 30, defaultText: "Center text" }], + }, + "side-by-side": { + label: "Side by Side", + description: "Left and right text", + boxes: [ + { id: "left", x: 2, y: 35, width: 46, height: 30, defaultText: "Left text" }, + { id: "right", x: 52, y: 35, width: 46, height: 30, defaultText: "Right text" }, + ], + }, +}; + +const INPUT_CLASS = + "w-full px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"; + +// ── Font loading ───────────────────────────────────────────────────── + +const FONT_FACES = [ + { family: "Anton", file: "Anton-Regular.ttf" }, + { family: "Bebas Neue", file: "BebasNeue-Regular.ttf" }, + { family: "Permanent Marker", file: "PermanentMarker-Regular.ttf" }, + { family: "Montserrat Black", file: "Montserrat-Black.ttf" }, + { family: "Roboto Black", file: "Roboto-Black.ttf" }, +]; + +function useFontLoader() { + useEffect(() => { + const id = "meme-generator-fonts"; + if (document.getElementById(id)) return; + + const css = FONT_FACES.map( + (f) => + `@font-face { font-family: '${f.family}'; src: url('/api/v1/meme-templates/fonts/${f.file}') format('truetype'); font-display: swap; }`, + ).join("\n"); + + const style = document.createElement("style"); + style.id = id; + style.textContent = css; + document.head.appendChild(style); + + return () => { + const el = document.getElementById(id); + if (el) el.remove(); + }; + }, []); +} + +// ── Subcomponents ──────────────────────────────────────────────────── + +function TextPreviewOverlay({ + boxes, + textValues, + fontFamily, + fontSize, + textColor, + strokeColor, + textAlign, + allCaps, +}: { + boxes: TemplateTextBox[]; + textValues: TextBoxValue[]; + fontFamily: string; + fontSize: number; + textColor: string; + strokeColor: string; + textAlign: string; + allCaps: boolean; +}) { + const cssFontFamily = FONT_FAMILY_MAP[fontFamily] ?? FONT_FAMILY_MAP.anton; + + return ( + <> + {boxes.map((box) => { + const value = textValues.find((v) => v.id === box.id); + const text = value?.text || box.defaultText || ""; + const displayText = allCaps ? text.toUpperCase() : text; + // Auto-size: scale from box height, or use explicit fontSize + const autoSize = `clamp(12px, ${box.height * 0.6}cqi, 72px)`; + const appliedSize = fontSize > 0 ? `${fontSize}px` : autoSize; + + return ( +
+ + {displayText} + +
+ ); + })} + + ); +} + +// ── Gallery Phase ──────────────────────────────────────────────────── + +function TemplateGallery({ + templates, + onSelect, + onUploadCustom, +}: { + templates: MemeTemplate[]; + onSelect: (t: MemeTemplate) => void; + onUploadCustom: () => void; +}) { + const [search, setSearch] = useState(""); + const [category, setCategory] = useState("all"); + + const filtered = useMemo(() => { + let result = templates; + + if (category !== "all") { + result = result.filter((t) => t.category === category); + } + + if (search.trim()) { + const q = search.toLowerCase().trim(); + result = result.filter( + (t) => + t.name.toLowerCase().includes(q) || + t.aliases.some((a) => a.toLowerCase().includes(q)) || + t.tags.some((tag) => tag.toLowerCase().includes(q)), + ); + } + + return result; + }, [templates, search, category]); + + const categoryCounts = useMemo(() => { + const counts: Record = { all: templates.length }; + for (const t of templates) { + counts[t.category] = (counts[t.category] ?? 0) + 1; + } + return counts; + }, [templates]); + + return ( +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search templates..." + className={cn(INPUT_CLASS, "pl-8")} + /> +
+ + {/* Categories */} +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Upload custom */} + + + {/* Template grid */} +
+ {filtered.map((t) => ( + + ))} +
+ + {filtered.length === 0 && ( +
+ + No templates match your search +
+ )} +
+ ); +} + +// ── Layout Picker (custom image) ───────────────────────────────────── + +function LayoutPicker({ + customImageUrl, + onSelect, + onBack, +}: { + customImageUrl: string; + onSelect: (layout: TextLayout) => void; + onBack: () => void; +}) { + const [selected, setSelected] = useState("top-bottom"); + + return ( +
+ + +

Choose a text layout

+ +
+ {( + Object.entries(PRESET_LAYOUTS) as [TextLayout, (typeof PRESET_LAYOUTS)[TextLayout]][] + ).map(([key, layout]) => ( + + ))} +
+ + +
+ ); +} + +// ── Editor Phase ───────────────────────────────────────────────────── + +interface EditorSettings { + textValues: TextBoxValue[]; + fontFamily: string; + fontSize: number; + textColor: string; + strokeColor: string; + textAlign: string; + allCaps: boolean; +} + +function MemeEditor({ + imageSrc, + textBoxes, + initialSettings, + onBack, + onGenerate, +}: { + imageSrc: string; + textBoxes: TemplateTextBox[]; + initialSettings: EditorSettings | null; + onBack: () => void; + onGenerate: (settings: EditorSettings) => void; +}) { + const [textValues, setTextValues] = useState( + () => initialSettings?.textValues ?? textBoxes.map((b) => ({ id: b.id, text: "" })), + ); + const [fontFamily, setFontFamily] = useState(initialSettings?.fontFamily ?? "anton"); + const [fontSize, setFontSize] = useState(initialSettings?.fontSize ?? 0); // 0 = auto + const [textColor, setTextColor] = useState(initialSettings?.textColor ?? "#ffffff"); + const [strokeColor, setStrokeColor] = useState(initialSettings?.strokeColor ?? "#000000"); + const [textAlign, setTextAlign] = useState(initialSettings?.textAlign ?? "center"); + const [allCaps, setAllCaps] = useState(initialSettings?.allCaps ?? true); + const [generating, setGenerating] = useState(false); + + const updateText = useCallback((id: string, text: string) => { + setTextValues((prev) => prev.map((v) => (v.id === id ? { ...v, text } : v))); + }, []); + + const handleGenerate = useCallback(() => { + setGenerating(true); + onGenerate({ textValues, fontFamily, fontSize, textColor, strokeColor, textAlign, allCaps }); + }, [textValues, fontFamily, fontSize, textColor, strokeColor, textAlign, allCaps, onGenerate]); + + return ( +
+ + +
+ {/* Preview */} +
+ Template preview + +
+ + {/* Settings */} +
+ {/* Text inputs */} + {textBoxes.map((box) => { + const val = textValues.find((v) => v.id === box.id); + return ( +
+ + updateText(box.id, e.target.value)} + placeholder={box.defaultText || box.id} + className={INPUT_CLASS} + /> +
+ ); + })} + + {/* Font picker */} +
+ + +
+ + {/* Font size */} +
+
+ + + {fontSize === 0 ? "Auto" : `${fontSize}px`} + +
+ setFontSize(Number(e.target.value))} + className="w-full" + /> +
+ + {/* Colors */} +
+
+ +
+ setTextColor(e.target.value)} + className="w-8 h-8 rounded border border-border shrink-0 cursor-pointer" + /> + setTextColor(e.target.value)} + className="flex-1 px-1.5 py-1 rounded border border-border bg-background text-xs text-foreground font-mono" + /> +
+
+
+ +
+ setStrokeColor(e.target.value)} + className="w-8 h-8 rounded border border-border shrink-0 cursor-pointer" + /> + setStrokeColor(e.target.value)} + className="flex-1 px-1.5 py-1 rounded border border-border bg-background text-xs text-foreground font-mono" + /> +
+
+
+ + {/* Alignment */} +
+ Alignment +
+ {(["left", "center", "right"] as const).map((align) => { + const Icon = + align === "left" ? AlignLeft : align === "right" ? AlignRight : AlignCenter; + return ( + + ); + })} +
+
+ + {/* All caps */} + + + {/* Generate */} + +
+
+
+ ); +} + +// ── Result Phase ───────────────────────────────────────────────────── + +function MemeResult({ + downloadUrl, + onEdit, + onNew, +}: { + downloadUrl: string; + onEdit: () => void; + onNew: () => void; +}) { + return ( +
+
+ Generated meme +
+ +
+ + + Download + + + +
+
+ ); +} + +// ── Main Component ─────────────────────────────────────────────────── + +export function MemeGeneratorSettings() { + useFontLoader(); + + const [phase, setPhase] = useState("gallery"); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Selected template (template mode) + const [selectedTemplate, setSelectedTemplate] = useState(null); + + // Custom image state + const [customFile, setCustomFile] = useState(null); + const [customImageUrl, setCustomImageUrl] = useState(null); + const [customLayout, setCustomLayout] = useState(null); + + // Editor state (preserved across edit/result transitions) + const [lastEditorSettings, setLastEditorSettings] = useState(null); + + // Result + const [resultUrl, setResultUrl] = useState(null); + + const fileInputRef = useRef(null); + + // Fetch templates on mount + useEffect(() => { + let cancelled = false; + setLoading(true); + + fetch("/api/v1/meme-templates", { headers: formatHeaders() }) + .then((res) => { + if (!res.ok) throw new Error(`Failed to load templates: ${res.status}`); + return res.json(); + }) + .then((data: TemplateManifest) => { + if (!cancelled) { + setTemplates(data.templates); + setLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load templates"); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + // Cleanup custom image blob URL + useEffect(() => { + return () => { + if (customImageUrl) URL.revokeObjectURL(customImageUrl); + }; + }, [customImageUrl]); + + // ── Handlers ────────────────────────────────────────────────────── + + const handleSelectTemplate = useCallback((t: MemeTemplate) => { + setSelectedTemplate(t); + setCustomFile(null); + setCustomImageUrl(null); + setCustomLayout(null); + setLastEditorSettings(null); + setResultUrl(null); + setPhase("editor"); + }, []); + + const handleUploadCustom = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setCustomFile(file); + setCustomImageUrl(URL.createObjectURL(file)); + setSelectedTemplate(null); + setLastEditorSettings(null); + setResultUrl(null); + setPhase("layout-picker"); + // Reset the input so the same file can be re-selected + e.target.value = ""; + }, []); + + const handleLayoutSelect = useCallback((layout: TextLayout) => { + setCustomLayout(layout); + setPhase("editor"); + }, []); + + const handleBackToGallery = useCallback(() => { + setPhase("gallery"); + setSelectedTemplate(null); + setCustomFile(null); + if (customImageUrl) { + URL.revokeObjectURL(customImageUrl); + setCustomImageUrl(null); + } + setCustomLayout(null); + setLastEditorSettings(null); + setResultUrl(null); + }, [customImageUrl]); + + const handleGenerate = useCallback( + async (settings: { + textValues: TextBoxValue[]; + fontFamily: string; + fontSize: number; + textColor: string; + strokeColor: string; + textAlign: string; + allCaps: boolean; + }) => { + setLastEditorSettings(settings); + setError(null); + + try { + const apiSettings = { + templateId: selectedTemplate?.id, + textLayout: customLayout ?? "top-bottom", + textBoxes: settings.textValues, + fontFamily: settings.fontFamily, + fontSize: settings.fontSize > 0 ? settings.fontSize : undefined, + textColor: settings.textColor, + strokeColor: settings.strokeColor, + textAlign: settings.textAlign, + allCaps: settings.allCaps, + }; + + let response: Response; + + if (customFile) { + // Custom image mode: multipart + const formData = new FormData(); + formData.append("file", customFile); + formData.append("settings", JSON.stringify(apiSettings)); + + response = await fetch("/api/v1/tools/meme-generator", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + } else { + // Template mode: JSON + response = await fetch("/api/v1/tools/meme-generator", { + method: "POST", + headers: formatHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(apiSettings), + }); + } + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error( + (body as Record).error || `Generation failed: ${response.status}`, + ); + } + + const result = (await response.json()) as { + jobId: string; + downloadUrl: string; + originalSize: number; + processedSize: number; + }; + + setResultUrl(result.downloadUrl); + setPhase("result"); + } catch (err) { + setError(err instanceof Error ? err.message : "Meme generation failed"); + // Stay on editor phase so user can retry + } + }, + [selectedTemplate, customFile, customLayout], + ); + + const handleEditFromResult = useCallback(() => { + setPhase("editor"); + }, []); + + const handleNewMeme = useCallback(() => { + handleBackToGallery(); + }, [handleBackToGallery]); + + // ── Derived state for editor ────────────────────────────────────── + + const editorImageSrc = selectedTemplate + ? `/api/v1/meme-templates/full/${selectedTemplate.filename}` + : (customImageUrl ?? ""); + + const editorTextBoxes = selectedTemplate + ? selectedTemplate.textBoxes + : ((customLayout && PRESET_LAYOUTS[customLayout]?.boxes) ?? PRESET_LAYOUTS["top-bottom"].boxes); + + // ── Render ──────────────────────────────────────────────────────── + + if (loading) { + return ( +
+ + Loading templates... +
+ ); + } + + if (error && phase === "gallery") { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ + + {error && phase !== "gallery" && ( +
+ {error} +
+ )} + + {phase === "gallery" && ( + + )} + + {phase === "layout-picker" && customImageUrl && ( + + )} + + {phase === "editor" && ( + + )} + + {phase === "result" && resultUrl && ( + + )} +
+ ); +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 5962107d..bcb24d20 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -308,6 +308,11 @@ const TransparencyFixerSettings = lazy(() => default: m.TransparencyFixerSettings, })), ); +const MemeGeneratorSettings = lazy(() => + import("@/components/tools/meme-generator-settings").then((m) => ({ + default: m.MemeGeneratorSettings, + })), +); // ── Color tool wrapper ───────────────────────────────────────────── // Color tools share a single component but differ by toolId. @@ -370,6 +375,7 @@ export const toolRegistry = new Map([ ["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }], ["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }], ["compose", { displayMode: "before-after", Settings: ComposeSettings }], + ["meme-generator", { displayMode: "no-dropzone", Settings: MemeGeneratorSettings }], // Utilities ["info", { displayMode: "before-after", Settings: InfoSettings }],