import {
ArrowLeft,
Download,
ImagePlus,
Laugh,
Loader2,
RotateCcw,
Search,
Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } 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,
containerWidth,
}: {
boxes: TemplateTextBox[];
textValues: TextBoxValue[];
fontFamily: string;
fontSize: number;
textColor: string;
strokeColor: string;
textAlign: string;
allCaps: boolean;
containerWidth: number;
}) {
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 boxPxW = (box.width / 100) * containerWidth;
const boxPxH = (box.height / 100) * containerWidth;
const autoSize = Math.max(10, Math.min(Math.floor(boxPxW / 8), Math.floor(boxPxH / 2), 48));
const appliedSize = fontSize > 0 ? fontSize : autoSize;
const stroke = Math.max(1, Math.round(appliedSize * 0.04));
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 containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(600);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
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 (
);
}
// ── 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 (
);
}
// ── 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;
}