From 68ed34ec29061a791127518229672de5dc29ac2e Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 17:15:06 +0800 Subject: [PATCH] fix(meme-generator): split into settings + preview panels with shared Zustand store The meme generator had everything in one component crammed into the narrow right sidebar. This splits it into the proper two-panel architecture (like collage/qr-generate): - meme-store.ts: shared Zustand store for all meme state and actions - meme-generator-preview.tsx: ResultsPanel (main area) with gallery, layout picker, editor preview with CSS text overlay, and result view - meme-generator-settings.tsx: Settings sidebar with text inputs, font picker, colors, alignment, and generate button - tool-registry.tsx: adds ResultsPanel to the meme-generator entry Also fixes text overlay sizing (reduced from 0.6cqi to 0.45cqi with smaller stroke width) and moves font injection to a standalone function callable from either component. --- .../tools/meme-generator-preview.tsx | 497 +++++++ .../tools/meme-generator-settings.tsx | 1266 ++++------------- apps/web/src/lib/tool-registry.tsx | 14 +- apps/web/src/stores/meme-store.ts | 401 ++++++ 4 files changed, 1205 insertions(+), 973 deletions(-) create mode 100644 apps/web/src/components/tools/meme-generator-preview.tsx create mode 100644 apps/web/src/stores/meme-store.ts diff --git a/apps/web/src/components/tools/meme-generator-preview.tsx b/apps/web/src/components/tools/meme-generator-preview.tsx new file mode 100644 index 00000000..f21e5bdd --- /dev/null +++ b/apps/web/src/components/tools/meme-generator-preview.tsx @@ -0,0 +1,497 @@ +import { + ArrowLeft, + Download, + ImagePlus, + Laugh, + Loader2, + RotateCcw, + Search, + Sparkles, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { + CATEGORIES, + FONT_FAMILY_MAP, + injectMemeFonts, + PRESET_LAYOUTS, + type TemplateTextBox, + type TextBoxValue, + type TextLayout, + useMemeStore, +} from "@/stores/meme-store"; + +const INPUT_CLASS = + "w-full px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"; + +// ── Text Preview Overlay ──────────────────────────────────────────── + +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; + const autoSize = `clamp(10px, ${box.height * 0.45}cqi, 60px)`; + const appliedSize = fontSize > 0 ? `${fontSize}px` : autoSize; + + return ( +
+ + {displayText} + +
+ ); + })} + + ); +} + +// ── Gallery Phase ─────────────────────────────────────────────────── + +function TemplateGallery() { + const templates = useMemeStore((s) => s.templates); + const searchQuery = useMemeStore((s) => s.searchQuery); + const activeCategory = useMemeStore((s) => s.activeCategory); + const selectTemplate = useMemeStore((s) => s.selectTemplate); + const setSearchQuery = useMemeStore((s) => s.setSearchQuery); + const setActiveCategory = useMemeStore((s) => s.setActiveCategory); + const setCustomImage = useMemeStore((s) => s.setCustomImage); + + const fileInputRef = useRef(null); + + const filtered = useMemo(() => { + let result = templates; + + if (activeCategory !== "all") { + result = result.filter((t) => t.category === activeCategory); + } + + if (searchQuery.trim()) { + const q = searchQuery.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, searchQuery, activeCategory]); + + 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]); + + const handleUploadCustom = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setCustomImage(file); + e.target.value = ""; + }, + [setCustomImage], + ); + + return ( +
+
+ {/* Search */} +
+ + setSearchQuery(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 Phase (custom image) ────────────────────────────── + +function LayoutPicker() { + const customImageUrl = useMemeStore((s) => s.customImageUrl); + const customLayout = useMemeStore((s) => s.customLayout); + const setCustomLayout = useMemeStore((s) => s.setCustomLayout); + const backToGallery = useMemeStore((s) => s.backToGallery); + + const selected = customLayout ?? "top-bottom"; + + if (!customImageUrl) return null; + + return ( +
+
+ + +

Choose a text layout

+ +
+ {( + Object.entries(PRESET_LAYOUTS) as [TextLayout, (typeof PRESET_LAYOUTS)[TextLayout]][] + ).map(([key, layout]) => ( + + ))} +
+
+
+ ); +} + +// ── Editor Phase ──────────────────────────────────────────────────── + +function EditorPreview() { + const selectedTemplate = useMemeStore((s) => s.selectedTemplate); + const customImageUrl = useMemeStore((s) => s.customImageUrl); + const customLayout = useMemeStore((s) => s.customLayout); + const textBoxValues = useMemeStore((s) => s.textBoxValues); + const fontFamily = useMemeStore((s) => s.fontFamily); + const fontSize = useMemeStore((s) => s.fontSize); + const textColor = useMemeStore((s) => s.textColor); + const strokeColor = useMemeStore((s) => s.strokeColor); + const textAlign = useMemeStore((s) => s.textAlign); + const allCaps = useMemeStore((s) => s.allCaps); + + const imageSrc = selectedTemplate + ? `/api/v1/meme-templates/full/${selectedTemplate.filename}` + : (customImageUrl ?? ""); + + const textBoxes = selectedTemplate + ? selectedTemplate.textBoxes + : ((customLayout && PRESET_LAYOUTS[customLayout]?.boxes) ?? PRESET_LAYOUTS["top-bottom"].boxes); + + return ( +
+
+
+ Template preview + +
+
+
+ ); +} + +// ── Result Phase ──────────────────────────────────────────────────── + +function ResultView() { + const resultUrl = useMemeStore((s) => s.resultUrl); + const backToEditor = useMemeStore((s) => s.backToEditor); + const backToGallery = useMemeStore((s) => s.backToGallery); + + if (!resultUrl) return null; + + return ( +
+
+ Generated meme +
+
+ + + + Download + + +
+
+ ); +} + +// ── Loading / Error States ────────────────────────────────────────── + +function LoadingView() { + return ( +
+ + Loading templates... +
+ ); +} + +function ErrorView() { + const error = useMemeStore((s) => s.error); + + return ( +
+
+

{error}

+ +
+
+ ); +} + +// ── Main Preview Component (ResultsPanel) ─────────────────────────── + +export function MemeGeneratorPreview() { + const phase = useMemeStore((s) => s.phase); + const loading = useMemeStore((s) => s.loading); + const error = useMemeStore((s) => s.error); + const templates = useMemeStore((s) => s.templates); + const fetchTemplates = useMemeStore((s) => s.fetchTemplates); + + // Inject fonts on mount + useEffect(() => { + injectMemeFonts(); + }, []); + + // Fetch templates on mount + useEffect(() => { + if (templates.length === 0) { + fetchTemplates(); + } + }, [templates.length, fetchTemplates]); + + if (loading && templates.length === 0) { + return ; + } + + if (error && phase === "gallery") { + return ; + } + + if (phase === "gallery") { + return ; + } + + if (phase === "layout-picker") { + return ; + } + + if (phase === "editor") { + return ; + } + + if (phase === "result") { + return ; + } + + return null; +} diff --git a/apps/web/src/components/tools/meme-generator-settings.tsx b/apps/web/src/components/tools/meme-generator-settings.tsx index 8356758b..eb180483 100644 --- a/apps/web/src/components/tools/meme-generator-settings.tsx +++ b/apps/web/src/components/tools/meme-generator-settings.tsx @@ -4,1013 +4,335 @@ import { 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 { useCallback } from "react"; 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" }, - ], - }, -}; +import { + FONT_OPTIONS, + PRESET_LAYOUTS, + type TemplateTextBox, + useMemeStore, +} from "@/stores/meme-store"; 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; +// ── Gallery Phase Settings ────────────────────────────────────────── +function GallerySettings() { 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 */} - -
+
+
+

+ Select a template from the gallery or upload your own image to get started. +

); } -// ── Result Phase ───────────────────────────────────────────────────── +// ── Layout Picker Phase Settings ──────────────────────────────────── -function MemeResult({ - downloadUrl, - onEdit, - onNew, -}: { - downloadUrl: string; - onEdit: () => void; - onNew: () => void; -}) { +function LayoutPickerSettings() { return ( -
-
- Generated meme -
- -
- - - Download - - - +
+
+

Choose a text layout for your custom image.

); } -// ── Main Component ─────────────────────────────────────────────────── +// ── Editor Phase Settings ─────────────────────────────────────────── -export function MemeGeneratorSettings() { - useFontLoader(); +function EditorSettings() { + const selectedTemplate = useMemeStore((s) => s.selectedTemplate); + const customLayout = useMemeStore((s) => s.customLayout); + const textBoxValues = useMemeStore((s) => s.textBoxValues); + const fontFamily = useMemeStore((s) => s.fontFamily); + const fontSize = useMemeStore((s) => s.fontSize); + const textColor = useMemeStore((s) => s.textColor); + const strokeColor = useMemeStore((s) => s.strokeColor); + const textAlign = useMemeStore((s) => s.textAlign); + const allCaps = useMemeStore((s) => s.allCaps); + const generating = useMemeStore((s) => s.generating); + const error = useMemeStore((s) => s.error); + const updateTextValue = useMemeStore((s) => s.updateTextValue); + const setFontFamily = useMemeStore((s) => s.setFontFamily); + const setFontSize = useMemeStore((s) => s.setFontSize); + const setTextColor = useMemeStore((s) => s.setTextColor); + const setStrokeColor = useMemeStore((s) => s.setStrokeColor); + const setTextAlign = useMemeStore((s) => s.setTextAlign); + const setAllCaps = useMemeStore((s) => s.setAllCaps); + const generateMeme = useMemeStore((s) => s.generateMeme); + const backToGallery = useMemeStore((s) => s.backToGallery); - 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 + const textBoxes: TemplateTextBox[] = 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}

- -
- ); - } + const handleGenerate = useCallback(() => { + generateMeme(); + }, [generateMeme]); return (
- + {/* Back button */} + - {error && phase !== "gallery" && ( -
+ {/* Template name */} + {selectedTemplate && ( +

{selectedTemplate.name}

+ )} + + {/* Text inputs */} + {textBoxes.map((box) => { + const val = textBoxValues.find((v) => v.id === box.id); + return ( +
+ + updateTextValue(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 */} + + + {/* Error */} + {error && ( +
{error}
)} - {phase === "gallery" && ( - - )} - - {phase === "layout-picker" && customImageUrl && ( - - )} - - {phase === "editor" && ( - - )} - - {phase === "result" && resultUrl && ( - - )} + {/* Generate */} +
); } + +// ── Result Phase Settings ─────────────────────────────────────────── + +function ResultSettings() { + const resultUrl = useMemeStore((s) => s.resultUrl); + const backToEditor = useMemeStore((s) => s.backToEditor); + const backToGallery = useMemeStore((s) => s.backToGallery); + + return ( +
+

Your meme is ready.

+ + {resultUrl && ( + + + Download Meme + + )} + + + + +
+ ); +} + +// ── Main Settings Component ───────────────────────────────────────── + +export function MemeGeneratorSettings() { + const phase = useMemeStore((s) => s.phase); + + if (phase === "gallery") return ; + if (phase === "layout-picker") return ; + if (phase === "editor") return ; + if (phase === "result") return ; + + return null; +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index bcb24d20..cc1b1ccd 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -313,6 +313,11 @@ const MemeGeneratorSettings = lazy(() => default: m.MemeGeneratorSettings, })), ); +const MemeGeneratorPreview = lazy(() => + import("@/components/tools/meme-generator-preview").then((m) => ({ + default: m.MemeGeneratorPreview, + })), +); // ── Color tool wrapper ───────────────────────────────────────────── // Color tools share a single component but differ by toolId. @@ -375,7 +380,14 @@ 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 }], + [ + "meme-generator", + { + displayMode: "no-dropzone", + Settings: MemeGeneratorSettings, + ResultsPanel: MemeGeneratorPreview, + }, + ], // Utilities ["info", { displayMode: "before-after", Settings: InfoSettings }], diff --git a/apps/web/src/stores/meme-store.ts b/apps/web/src/stores/meme-store.ts new file mode 100644 index 00000000..2ed24e1d --- /dev/null +++ b/apps/web/src/stores/meme-store.ts @@ -0,0 +1,401 @@ +import { create } from "zustand"; +import { formatHeaders } from "@/lib/api"; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface TemplateTextBox { + id: string; + x: number; + y: number; + width: number; + height: number; + defaultText?: string; +} + +export 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[]; +} + +export type Phase = "gallery" | "layout-picker" | "editor" | "result"; +export type TextLayout = "top-bottom" | "top-only" | "bottom-only" | "center" | "side-by-side"; + +export interface TextBoxValue { + id: string; + text: string; +} + +// ── Constants ──────────────────────────────────────────────────────── + +export 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; + +export 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", +}; + +export 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" }, +]; + +export 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" }, + ], + }, +}; + +// ── 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" }, +]; + +let fontsInjected = false; + +export function injectMemeFonts() { + if (fontsInjected) return; + const id = "meme-generator-fonts"; + if (document.getElementById(id)) { + fontsInjected = true; + 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); + fontsInjected = true; +} + +// ── Store ──────────────────────────────────────────────────────────── + +interface MemeState { + // Phase + phase: Phase; + + // Templates + templates: MemeTemplate[]; + loading: boolean; + searchQuery: string; + activeCategory: string; + + // Selected template + selectedTemplate: MemeTemplate | null; + + // Custom image + customFile: File | null; + customImageUrl: string | null; + customLayout: TextLayout | null; + + // Editor settings + textBoxValues: TextBoxValue[]; + fontFamily: string; + fontSize: number; // 0 = auto + textColor: string; + strokeColor: string; + textAlign: string; + allCaps: boolean; + + // Processing / result + generating: boolean; + resultUrl: string | null; + downloadUrl: string | null; + error: string | null; + + // Actions + setPhase: (phase: Phase) => void; + setSearchQuery: (q: string) => void; + setActiveCategory: (c: string) => void; + selectTemplate: (t: MemeTemplate) => void; + setCustomImage: (file: File) => void; + setCustomLayout: (layout: TextLayout) => void; + updateTextValue: (id: string, text: string) => void; + setFontFamily: (f: string) => void; + setFontSize: (s: number) => void; + setTextColor: (c: string) => void; + setStrokeColor: (c: string) => void; + setTextAlign: (a: string) => void; + setAllCaps: (v: boolean) => void; + fetchTemplates: () => Promise; + generateMeme: () => Promise; + backToGallery: () => void; + backToEditor: () => void; + reset: () => void; +} + +export const useMemeStore = create((set, get) => ({ + phase: "gallery", + templates: [], + loading: true, + searchQuery: "", + activeCategory: "all", + selectedTemplate: null, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: [], + fontFamily: "anton", + fontSize: 0, + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: true, + generating: false, + resultUrl: null, + downloadUrl: null, + error: null, + + setPhase: (phase) => set({ phase }), + setSearchQuery: (q) => set({ searchQuery: q }), + setActiveCategory: (c) => set({ activeCategory: c }), + + selectTemplate: (t) => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + selectedTemplate: t, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: t.textBoxes.map((b) => ({ id: b.id, text: "" })), + resultUrl: null, + downloadUrl: null, + error: null, + generating: false, + phase: "editor", + }); + }, + + setCustomImage: (file) => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + customFile: file, + customImageUrl: URL.createObjectURL(file), + selectedTemplate: null, + resultUrl: null, + downloadUrl: null, + error: null, + generating: false, + phase: "layout-picker", + }); + }, + + setCustomLayout: (layout) => { + const boxes = PRESET_LAYOUTS[layout]?.boxes ?? PRESET_LAYOUTS["top-bottom"].boxes; + set({ + customLayout: layout, + textBoxValues: boxes.map((b) => ({ id: b.id, text: "" })), + phase: "editor", + }); + }, + + updateTextValue: (id, text) => { + const values = get().textBoxValues.map((v) => (v.id === id ? { ...v, text } : v)); + set({ textBoxValues: values }); + }, + + setFontFamily: (f) => set({ fontFamily: f }), + setFontSize: (s) => set({ fontSize: s }), + setTextColor: (c) => set({ textColor: c }), + setStrokeColor: (c) => set({ strokeColor: c }), + setTextAlign: (a) => set({ textAlign: a }), + setAllCaps: (v) => set({ allCaps: v }), + + fetchTemplates: async () => { + set({ loading: true, error: null }); + try { + const res = await fetch("/api/v1/meme-templates", { headers: formatHeaders() }); + if (!res.ok) throw new Error(`Failed to load templates: ${res.status}`); + const data: TemplateManifest = await res.json(); + set({ templates: data.templates, loading: false }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Failed to load templates", + loading: false, + }); + } + }, + + generateMeme: async () => { + const state = get(); + set({ generating: true, error: null }); + + try { + const apiSettings = { + templateId: state.selectedTemplate?.id, + textLayout: state.customLayout ?? "top-bottom", + textBoxes: state.textBoxValues, + fontFamily: state.fontFamily, + fontSize: state.fontSize > 0 ? state.fontSize : undefined, + textColor: state.textColor, + strokeColor: state.strokeColor, + textAlign: state.textAlign, + allCaps: state.allCaps, + }; + + let response: Response; + + if (state.customFile) { + const formData = new FormData(); + formData.append("file", state.customFile); + formData.append("settings", JSON.stringify(apiSettings)); + response = await fetch("/api/v1/tools/meme-generator", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + } else { + 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; + }; + + set({ + resultUrl: result.downloadUrl, + downloadUrl: result.downloadUrl, + phase: "result", + generating: false, + }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Meme generation failed", + generating: false, + }); + } + }, + + backToGallery: () => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + phase: "gallery", + selectedTemplate: null, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: [], + resultUrl: null, + downloadUrl: null, + error: null, + generating: false, + }); + }, + + backToEditor: () => set({ phase: "editor", resultUrl: null, downloadUrl: null }), + + reset: () => { + const oldUrl = get().customImageUrl; + if (oldUrl) URL.revokeObjectURL(oldUrl); + set({ + phase: "gallery", + templates: [], + loading: true, + searchQuery: "", + activeCategory: "all", + selectedTemplate: null, + customFile: null, + customImageUrl: null, + customLayout: null, + textBoxValues: [], + fontFamily: "anton", + fontSize: 0, + textColor: "#ffffff", + strokeColor: "#000000", + textAlign: "center", + allCaps: true, + generating: false, + resultUrl: null, + downloadUrl: null, + error: null, + }); + }, +}));