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,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