mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, ToolRegistryEntry>([
|
||||
["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 }],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user