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.
This commit is contained in:
SnapOtter
2026-05-08 17:15:06 +08:00
parent f9e0e90897
commit 68ed34ec29
4 changed files with 1205 additions and 973 deletions
@@ -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 (
<div
key={box.id}
data-testid={`preview-box-${box.id}`}
className="absolute flex items-center overflow-hidden pointer-events-none"
style={{
top: `${box.y}%`,
left: `${box.x}%`,
width: `${box.width}%`,
height: `${box.height}%`,
justifyContent:
textAlign === "left" ? "flex-start" : textAlign === "right" ? "flex-end" : "center",
}}
>
<span
className="w-full leading-tight break-words whitespace-pre-wrap"
style={{
fontFamily: cssFontFamily,
fontSize: appliedSize,
color: textColor,
textAlign: textAlign as "left" | "center" | "right",
WebkitTextStroke: `1.5px ${strokeColor}`,
textShadow: [
`1px 1px 0 ${strokeColor}`,
`-1px -1px 0 ${strokeColor}`,
`1px -1px 0 ${strokeColor}`,
`-1px 1px 0 ${strokeColor}`,
`0 1px 0 ${strokeColor}`,
`0 -1px 0 ${strokeColor}`,
`1px 0 0 ${strokeColor}`,
`-1px 0 0 ${strokeColor}`,
].join(", "),
}}
>
{displayText}
</span>
</div>
);
})}
</>
);
}
// ── 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<HTMLInputElement>(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<string, number> = { 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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setCustomImage(file);
e.target.value = "";
},
[setCustomImage],
);
return (
<div data-testid="meme-gallery" className="h-full overflow-auto p-4">
<div className="max-w-5xl mx-auto space-y-4">
{/* Search */}
<div className="relative max-w-md">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
data-testid="template-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search templates..."
className={cn(INPUT_CLASS, "pl-8")}
/>
</div>
{/* Categories */}
<div className="flex flex-wrap gap-1.5">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
type="button"
data-testid={`category-${cat.id}`}
onClick={() => setActiveCategory(cat.id)}
className={cn(
"px-2.5 py-1 rounded-full text-xs transition-colors",
activeCategory === cat.id
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
>
{cat.label}
<span className="ml-1 opacity-70">({categoryCounts[cat.id] ?? 0})</span>
</button>
))}
</div>
{/* Upload custom */}
<input
ref={fileInputRef}
type="file"
accept="image/*,.heic,.heif,.hif"
onChange={handleFileChange}
className="hidden"
/>
<button
type="button"
data-testid="upload-custom"
onClick={handleUploadCustom}
className="w-full max-w-md py-3 rounded-lg border-2 border-dashed border-border text-sm text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors flex items-center justify-center gap-2"
>
<ImagePlus className="h-4 w-4" />
Upload Custom Image
</button>
{/* Template grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
{filtered.map((t) => (
<button
key={t.id}
type="button"
data-testid={`template-${t.id}`}
onClick={() => selectTemplate(t)}
className="group relative aspect-square rounded-lg overflow-hidden border border-border hover:border-primary/60 transition-all hover:shadow-md"
>
<img
src={`/api/v1/meme-templates/thumbs/${t.filename.replace(/\.[^.]+$/, ".webp")}`}
alt={t.name}
loading="lazy"
className="w-full h-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-1.5 pt-4">
<span className="text-[10px] text-white font-medium leading-tight line-clamp-2">
{t.name}
</span>
</div>
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
<Laugh className="h-8 w-8 mx-auto mb-2 opacity-40" />
No templates match your search
</div>
)}
</div>
</div>
);
}
// ── 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 (
<div
data-testid="layout-picker"
className="h-full overflow-auto p-4 flex items-center justify-center"
>
<div className="max-w-xl w-full space-y-4">
<button
type="button"
onClick={backToGallery}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3 w-3" />
Back to templates
</button>
<p className="text-sm text-foreground font-medium">Choose a text layout</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{(
Object.entries(PRESET_LAYOUTS) as [TextLayout, (typeof PRESET_LAYOUTS)[TextLayout]][]
).map(([key, layout]) => (
<button
key={key}
type="button"
data-testid={`layout-${key}`}
onClick={() => setCustomLayout(key)}
className={cn(
"relative rounded-lg border-2 p-3 transition-all text-left",
selected === key
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40",
)}
>
{/* Mini preview */}
<div className="relative aspect-video rounded bg-muted/50 overflow-hidden mb-2">
<img
src={customImageUrl}
alt=""
className="w-full h-full object-cover opacity-50"
/>
{layout.boxes.map((box) => (
<div
key={box.id}
className="absolute bg-primary/30 border border-primary/50 rounded-sm"
style={{
top: `${box.y}%`,
left: `${box.x}%`,
width: `${box.width}%`,
height: `${box.height}%`,
}}
/>
))}
</div>
<span className="text-xs font-medium text-foreground">{layout.label}</span>
<span className="block text-[10px] text-muted-foreground">{layout.description}</span>
</button>
))}
</div>
</div>
</div>
);
}
// ── 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 (
<div className="h-full flex items-center justify-center p-4 overflow-auto">
<div className="max-w-2xl w-full">
<div
data-testid="meme-editor-preview"
className="relative inline-block w-full rounded-lg overflow-hidden border border-border bg-muted/30"
style={{ containerType: "inline-size" }}
>
<img src={imageSrc} alt="Template preview" className="w-full h-auto block" />
<TextPreviewOverlay
boxes={textBoxes}
textValues={textBoxValues}
fontFamily={fontFamily}
fontSize={fontSize}
textColor={textColor}
strokeColor={strokeColor}
textAlign={textAlign}
allCaps={allCaps}
/>
</div>
</div>
</div>
);
}
// ── 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 (
<div data-testid="meme-result" className="flex flex-col h-full w-full">
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
<img
src={resultUrl}
alt="Generated meme"
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
/>
</div>
<div className="shrink-0 border-t border-border bg-background px-4 py-3 flex items-center justify-center gap-3">
<button
type="button"
data-testid="edit-meme"
onClick={backToEditor}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border text-sm hover:bg-muted transition-colors"
>
<RotateCcw className="h-4 w-4" />
Back to editor
</button>
<a
href={resultUrl}
download
data-testid="download-meme"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Download className="h-4 w-4" />
Download
</a>
<button
type="button"
data-testid="new-meme"
onClick={backToGallery}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border text-sm hover:bg-muted transition-colors"
>
<Sparkles className="h-4 w-4" />
New Meme
</button>
</div>
</div>
);
}
// ── Loading / Error States ──────────────────────────────────────────
function LoadingView() {
return (
<div className="flex items-center justify-center h-full gap-2">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Loading templates...</span>
</div>
);
}
function ErrorView() {
const error = useMemeStore((s) => s.error);
return (
<div className="flex items-center justify-center h-full">
<div className="text-center py-16">
<p className="text-sm text-destructive mb-2">{error}</p>
<button
type="button"
onClick={() => window.location.reload()}
className="text-xs text-primary hover:underline"
>
Retry
</button>
</div>
</div>
);
}
// ── 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 <LoadingView />;
}
if (error && phase === "gallery") {
return <ErrorView />;
}
if (phase === "gallery") {
return <TemplateGallery />;
}
if (phase === "layout-picker") {
return <LayoutPicker />;
}
if (phase === "editor") {
return <EditorPreview />;
}
if (phase === "result") {
return <ResultView />;
}
return null;
}
@@ -4,514 +4,100 @@ import {
AlignRight, AlignRight,
ArrowLeft, ArrowLeft,
Download, Download,
ImagePlus,
Laugh,
Loader2, Loader2,
Search,
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback } from "react";
import { formatHeaders } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
// ── Types ──────────────────────────────────────────────────────────── FONT_OPTIONS,
PRESET_LAYOUTS,
interface TemplateTextBox { type TemplateTextBox,
id: string; useMemeStore,
x: number; } from "@/stores/meme-store";
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<string, string> = {
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 = const INPUT_CLASS =
"w-full px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"; "w-full px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground";
// ── Font loading ───────────────────────────────────────────────────── // ── Gallery Phase Settings ──────────────────────────────────────────
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;
function GallerySettings() {
return ( return (
<> <div className="space-y-3">
{boxes.map((box) => { <div className="text-center py-6">
const value = textValues.find((v) => v.id === box.id); <p className="text-sm text-muted-foreground">
const text = value?.text || box.defaultText || ""; Select a template from the gallery or upload your own image to get started.
const displayText = allCaps ? text.toUpperCase() : text; </p>
// Auto-size: scale from box height, or use explicit fontSize </div>
const autoSize = `clamp(12px, ${box.height * 0.6}cqi, 72px)`; </div>
const appliedSize = fontSize > 0 ? `${fontSize}px` : autoSize; );
}
// ── Layout Picker Phase Settings ────────────────────────────────────
function LayoutPickerSettings() {
return ( return (
<div <div className="space-y-3">
key={box.id} <div className="text-center py-6">
data-testid={`preview-box-${box.id}`} <p className="text-sm text-muted-foreground">Choose a text layout for your custom image.</p>
className="absolute flex items-center overflow-hidden pointer-events-none"
style={{
top: `${box.y}%`,
left: `${box.x}%`,
width: `${box.width}%`,
height: `${box.height}%`,
justifyContent:
textAlign === "left" ? "flex-start" : textAlign === "right" ? "flex-end" : "center",
}}
>
<span
className="w-full leading-tight break-words whitespace-pre-wrap"
style={{
fontFamily: cssFontFamily,
fontSize: appliedSize,
color: textColor,
textAlign: textAlign as "left" | "center" | "right",
WebkitTextStroke: `2px ${strokeColor}`,
textShadow: [
`2px 2px 0 ${strokeColor}`,
`-2px -2px 0 ${strokeColor}`,
`2px -2px 0 ${strokeColor}`,
`-2px 2px 0 ${strokeColor}`,
`0 2px 0 ${strokeColor}`,
`0 -2px 0 ${strokeColor}`,
`2px 0 0 ${strokeColor}`,
`-2px 0 0 ${strokeColor}`,
].join(", "),
}}
>
{displayText}
</span>
</div> </div>
);
})}
</>
);
}
// ── 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<string, number> = { all: templates.length };
for (const t of templates) {
counts[t.category] = (counts[t.category] ?? 0) + 1;
}
return counts;
}, [templates]);
return (
<div data-testid="meme-gallery" className="space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
data-testid="template-search"
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search templates..."
className={cn(INPUT_CLASS, "pl-8")}
/>
</div>
{/* Categories */}
<div className="flex flex-wrap gap-1.5">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
type="button"
data-testid={`category-${cat.id}`}
onClick={() => setCategory(cat.id)}
className={cn(
"px-2.5 py-1 rounded-full text-xs transition-colors",
category === cat.id
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
>
{cat.label}
<span className="ml-1 opacity-70">({categoryCounts[cat.id] ?? 0})</span>
</button>
))}
</div>
{/* Upload custom */}
<button
type="button"
data-testid="upload-custom"
onClick={onUploadCustom}
className="w-full py-3 rounded-lg border-2 border-dashed border-border text-sm text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors flex items-center justify-center gap-2"
>
<ImagePlus className="h-4 w-4" />
Upload Custom Image
</button>
{/* Template grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
{filtered.map((t) => (
<button
key={t.id}
type="button"
data-testid={`template-${t.id}`}
onClick={() => onSelect(t)}
className="group relative aspect-square rounded-lg overflow-hidden border border-border hover:border-primary/60 transition-all hover:shadow-md"
>
<img
src={`/api/v1/meme-templates/thumbs/${t.filename}`}
alt={t.name}
loading="lazy"
className="w-full h-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-1.5 pt-4">
<span className="text-[10px] text-white font-medium leading-tight line-clamp-2">
{t.name}
</span>
</div>
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
<Laugh className="h-8 w-8 mx-auto mb-2 opacity-40" />
No templates match your search
</div>
)}
</div> </div>
); );
} }
// ── Layout Picker (custom image) ───────────────────────────────────── // ── Editor Phase Settings ───────────────────────────────────────────
function LayoutPicker({ function EditorSettings() {
customImageUrl, const selectedTemplate = useMemeStore((s) => s.selectedTemplate);
onSelect, const customLayout = useMemeStore((s) => s.customLayout);
onBack, const textBoxValues = useMemeStore((s) => s.textBoxValues);
}: { const fontFamily = useMemeStore((s) => s.fontFamily);
customImageUrl: string; const fontSize = useMemeStore((s) => s.fontSize);
onSelect: (layout: TextLayout) => void; const textColor = useMemeStore((s) => s.textColor);
onBack: () => void; const strokeColor = useMemeStore((s) => s.strokeColor);
}) { const textAlign = useMemeStore((s) => s.textAlign);
const [selected, setSelected] = useState<TextLayout>("top-bottom"); 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);
return ( const textBoxes: TemplateTextBox[] = selectedTemplate
<div data-testid="layout-picker" className="space-y-4"> ? selectedTemplate.textBoxes
<button : ((customLayout && PRESET_LAYOUTS[customLayout]?.boxes) ?? PRESET_LAYOUTS["top-bottom"].boxes);
type="button"
onClick={onBack}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-3 w-3" />
Back to templates
</button>
<p className="text-sm text-foreground font-medium">Choose a text layout</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{(
Object.entries(PRESET_LAYOUTS) as [TextLayout, (typeof PRESET_LAYOUTS)[TextLayout]][]
).map(([key, layout]) => (
<button
key={key}
type="button"
data-testid={`layout-${key}`}
onClick={() => setSelected(key)}
className={cn(
"relative rounded-lg border-2 p-3 transition-all text-left",
selected === key
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40",
)}
>
{/* Mini preview */}
<div className="relative aspect-video rounded bg-muted/50 overflow-hidden mb-2">
<img src={customImageUrl} alt="" className="w-full h-full object-cover opacity-50" />
{layout.boxes.map((box) => (
<div
key={box.id}
className="absolute bg-primary/30 border border-primary/50 rounded-sm"
style={{
top: `${box.y}%`,
left: `${box.x}%`,
width: `${box.width}%`,
height: `${box.height}%`,
}}
/>
))}
</div>
<span className="text-xs font-medium text-foreground">{layout.label}</span>
<span className="block text-[10px] text-muted-foreground">{layout.description}</span>
</button>
))}
</div>
<button
type="button"
data-testid="confirm-layout"
onClick={() => onSelect(selected)}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm flex items-center justify-center gap-2"
>
Continue
</button>
</div>
);
}
// ── 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<TextBoxValue[]>(
() => 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(() => { const handleGenerate = useCallback(() => {
setGenerating(true); generateMeme();
onGenerate({ textValues, fontFamily, fontSize, textColor, strokeColor, textAlign, allCaps }); }, [generateMeme]);
}, [textValues, fontFamily, fontSize, textColor, strokeColor, textAlign, allCaps, onGenerate]);
return ( return (
<div data-testid="meme-editor" className="space-y-4"> <div className="space-y-3">
{/* Back button */}
<button <button
type="button" type="button"
data-testid="back-to-gallery" data-testid="back-to-gallery"
onClick={onBack} onClick={backToGallery}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
> >
<ArrowLeft className="h-3 w-3" /> <ArrowLeft className="h-3 w-3" />
Back to templates Back to templates
</button> </button>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> {/* Template name */}
{/* Preview */} {selectedTemplate && (
<div <p className="text-xs font-medium text-foreground truncate">{selectedTemplate.name}</p>
className="relative rounded-lg overflow-hidden border border-border bg-muted/30" )}
style={{ containerType: "inline-size" }}
>
<img src={imageSrc} alt="Template preview" className="w-full h-auto block" />
<TextPreviewOverlay
boxes={textBoxes}
textValues={textValues}
fontFamily={fontFamily}
fontSize={fontSize}
textColor={textColor}
strokeColor={strokeColor}
textAlign={textAlign}
allCaps={allCaps}
/>
</div>
{/* Settings */}
<div className="space-y-3">
{/* Text inputs */} {/* Text inputs */}
{textBoxes.map((box) => { {textBoxes.map((box) => {
const val = textValues.find((v) => v.id === box.id); const val = textBoxValues.find((v) => v.id === box.id);
return ( return (
<div key={box.id}> <div key={box.id}>
<label <label
@@ -525,7 +111,7 @@ function MemeEditor({
data-testid={`text-input-${box.id}`} data-testid={`text-input-${box.id}`}
type="text" type="text"
value={val?.text ?? ""} value={val?.text ?? ""}
onChange={(e) => updateText(box.id, e.target.value)} onChange={(e) => updateTextValue(box.id, e.target.value)}
placeholder={box.defaultText || box.id} placeholder={box.defaultText || box.id}
className={INPUT_CLASS} className={INPUT_CLASS}
/> />
@@ -663,6 +249,13 @@ function MemeEditor({
ALL CAPS ALL CAPS
</label> </label>
{/* Error */}
{error && (
<div className="px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-xs">
{error}
</div>
)}
{/* Generate */} {/* Generate */}
<button <button
type="button" type="button"
@@ -684,333 +277,62 @@ function MemeEditor({
)} )}
</button> </button>
</div> </div>
</div>
</div>
); );
} }
// ── Result Phase ───────────────────────────────────────────────────── // ── Result Phase Settings ───────────────────────────────────────────
function ResultSettings() {
const resultUrl = useMemeStore((s) => s.resultUrl);
const backToEditor = useMemeStore((s) => s.backToEditor);
const backToGallery = useMemeStore((s) => s.backToGallery);
function MemeResult({
downloadUrl,
onEdit,
onNew,
}: {
downloadUrl: string;
onEdit: () => void;
onNew: () => void;
}) {
return ( return (
<div data-testid="meme-result" className="space-y-4"> <div className="space-y-3">
<div className="rounded-lg overflow-hidden border border-border bg-muted/30"> <p className="text-xs text-muted-foreground">Your meme is ready.</p>
<img
src={downloadUrl}
alt="Generated meme"
className="w-full h-auto block max-h-[70vh] object-contain mx-auto"
/>
</div>
<div className="flex gap-2"> {resultUrl && (
<a <a
href={downloadUrl} href={resultUrl}
download download
data-testid="download-meme" data-testid="sidebar-download-meme"
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium text-sm flex items-center justify-center gap-2"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
Download Download Meme
</a> </a>
)}
<button <button
type="button" type="button"
data-testid="edit-meme" data-testid="sidebar-edit-meme"
onClick={onEdit} onClick={backToEditor}
className="px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors" className="w-full py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors flex items-center justify-center gap-2"
> >
Edit Edit Again
</button> </button>
<button <button
type="button" type="button"
data-testid="new-meme" data-testid="sidebar-new-meme"
onClick={onNew} onClick={backToGallery}
className="px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors" className="w-full py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors flex items-center justify-center gap-2"
> >
New Meme New Meme
</button> </button>
</div> </div>
</div>
); );
} }
// ── Main Component ─────────────────────────────────────────────────── // ── Main Settings Component ─────────────────────────────────────────
export function MemeGeneratorSettings() { export function MemeGeneratorSettings() {
useFontLoader(); const phase = useMemeStore((s) => s.phase);
const [phase, setPhase] = useState<Phase>("gallery"); if (phase === "gallery") return <GallerySettings />;
const [templates, setTemplates] = useState<MemeTemplate[]>([]); if (phase === "layout-picker") return <LayoutPickerSettings />;
const [loading, setLoading] = useState(true); if (phase === "editor") return <EditorSettings />;
const [error, setError] = useState<string | null>(null); if (phase === "result") return <ResultSettings />;
// Selected template (template mode) return null;
const [selectedTemplate, setSelectedTemplate] = useState<MemeTemplate | null>(null);
// Custom image state
const [customFile, setCustomFile] = useState<File | null>(null);
const [customImageUrl, setCustomImageUrl] = useState<string | null>(null);
const [customLayout, setCustomLayout] = useState<TextLayout | null>(null);
// Editor state (preserved across edit/result transitions)
const [lastEditorSettings, setLastEditorSettings] = useState<EditorSettings | null>(null);
// Result
const [resultUrl, setResultUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<string, string>).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 (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Loading templates...</span>
</div>
);
}
if (error && phase === "gallery") {
return (
<div className="text-center py-16">
<p className="text-sm text-destructive mb-2">{error}</p>
<button
type="button"
onClick={() => window.location.reload()}
className="text-xs text-primary hover:underline"
>
Retry
</button>
</div>
);
}
return (
<div className="space-y-3">
<input
ref={fileInputRef}
type="file"
accept="image/*,.heic,.heif,.hif"
onChange={handleFileChange}
className="hidden"
/>
{error && phase !== "gallery" && (
<div className="px-3 py-2 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{phase === "gallery" && (
<TemplateGallery
templates={templates}
onSelect={handleSelectTemplate}
onUploadCustom={handleUploadCustom}
/>
)}
{phase === "layout-picker" && customImageUrl && (
<LayoutPicker
customImageUrl={customImageUrl}
onSelect={handleLayoutSelect}
onBack={handleBackToGallery}
/>
)}
{phase === "editor" && (
<MemeEditor
key={selectedTemplate?.id ?? customLayout ?? "editor"}
imageSrc={editorImageSrc}
textBoxes={editorTextBoxes}
initialSettings={lastEditorSettings}
onBack={handleBackToGallery}
onGenerate={handleGenerate}
/>
)}
{phase === "result" && resultUrl && (
<MemeResult downloadUrl={resultUrl} onEdit={handleEditFromResult} onNew={handleNewMeme} />
)}
</div>
);
} }
+13 -1
View File
@@ -313,6 +313,11 @@ const MemeGeneratorSettings = lazy(() =>
default: m.MemeGeneratorSettings, default: m.MemeGeneratorSettings,
})), })),
); );
const MemeGeneratorPreview = lazy(() =>
import("@/components/tools/meme-generator-preview").then((m) => ({
default: m.MemeGeneratorPreview,
})),
);
// ── Color tool wrapper ───────────────────────────────────────────── // ── Color tool wrapper ─────────────────────────────────────────────
// Color tools share a single component but differ by toolId. // Color tools share a single component but differ by toolId.
@@ -375,7 +380,14 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }], ["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }],
["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }], ["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }],
["compose", { displayMode: "before-after", Settings: ComposeSettings }], ["compose", { displayMode: "before-after", Settings: ComposeSettings }],
["meme-generator", { displayMode: "no-dropzone", Settings: MemeGeneratorSettings }], [
"meme-generator",
{
displayMode: "no-dropzone",
Settings: MemeGeneratorSettings,
ResultsPanel: MemeGeneratorPreview,
},
],
// Utilities // Utilities
["info", { displayMode: "before-after", Settings: InfoSettings }], ["info", { displayMode: "before-after", Settings: InfoSettings }],
+401
View File
@@ -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<string, string> = {
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<void>;
generateMeme: () => Promise<void>;
backToGallery: () => void;
backToEditor: () => void;
reset: () => void;
}
export const useMemeStore = create<MemeState>((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<string, string>).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,
});
},
}));