mesmer-logo / app.js
mesmertech's picture
Deploy static space build
0bc2c6b verified
Raw
History Blame Contribute Delete
8.8 kB
import { createSpace, el } from "./shared/ui.js";
import { callTrpcMutation } from "./shared/api-client.js";
import { ENDPOINTS, siteUrl } from "./shared/config.js";
const TOOL_KEY = "logo";
const TRPC_PATH = ENDPOINTS[TOOL_KEY].trpcPath; // "/api/trpc/logo.generate"
const FULL_TOOL_URL = siteUrl(ENDPOINTS[TOOL_KEY].fullToolPath); // mesmer.tools/free-tools/ai-logo-maker
// Description length bounds enforced server-side; we mirror them client-side.
const DESC = { min: 3, max: 500 };
const BRAND_MAX = 100;
// Style + colorMood are small, stable enums — defined inline (not fetched).
const STYLES = [
{ value: "minimal", label: "Minimal", desc: "Clean, geometric, lots of whitespace" },
{ value: "bold", label: "Bold", desc: "Heavy, high-contrast, commanding" },
{ value: "playful", label: "Playful", desc: "Friendly, rounded, colorful" },
{ value: "corporate", label: "Corporate", desc: "Professional, refined, trustworthy" },
{ value: "vintage", label: "Vintage", desc: "Nostalgic, badge/emblem, artisanal" },
];
const COLOR_MOODS = [
{ value: "auto", label: "Auto — let the AI choose" },
{ value: "light", label: "Light — bright, airy palette" },
{ value: "dark", label: "Dark — deep, dramatic tones" },
{ value: "vibrant", label: "Vibrant — bold, saturated colors" },
{ value: "muted", label: "Muted — soft, understated hues" },
{ value: "monochrome", label: "Monochrome — single colour / black & white" },
];
const space = createSpace({
toolKey: TOOL_KEY,
emoji: "🎨",
title: "Free AI Logo Maker",
subtitle: "Describe your brand and get an AI-generated logo in seconds. A logo generator from text — pick a style, choose a colour mood, download. Free, no signup.",
intro: "Turn a one-line brief into AI logo design right in your browser. Great for startups, side projects, and quick brand mockups.",
});
/* --- Form ----------------------------------------------------------------- */
const descInput = el("textarea", {
className: "ms-textarea",
id: "description",
name: "description",
required: "",
maxlength: String(DESC.max),
placeholder: "e.g. A cozy neighbourhood coffee shop that roasts its own beans — warm, welcoming, a little artisanal.",
});
const descCount = el("div", { className: "ms-char-count", text: `0 / ${DESC.max}` });
const descHint = el("span", { className: "ms-hint", text: `Describe your brand — what it does and the feel you want (${DESC.min}${DESC.max} characters).` });
const brandInput = el("input", {
className: "ms-input",
type: "text",
id: "brandName",
name: "brandName",
maxlength: String(BRAND_MAX),
placeholder: "Bean There",
autocomplete: "off",
});
// Style chips (single-select; defaults to the first option so the form is always valid).
let selectedStyle = STYLES[0].value;
const styleChips = STYLES.map((s) =>
el("button", {
className: "ms-chip" + (s.value === selectedStyle ? " is-selected" : ""),
type: "button",
role: "radio",
"aria-checked": s.value === selectedStyle ? "true" : "false",
dataset: { value: s.value },
onClick: () => selectStyle(s.value),
},
el("span", { className: "ms-chip-name", text: s.label }),
el("span", { className: "ms-chip-desc", text: s.desc }),
),
);
function selectStyle(value) {
selectedStyle = value;
for (const chip of styleChips) {
const on = chip.dataset.value === value;
chip.classList.toggle("is-selected", on);
chip.setAttribute("aria-checked", on ? "true" : "false");
}
}
const colorSelect = el("select", { className: "ms-select", id: "colorMood", name: "colorMood" },
...COLOR_MOODS.map((m) => el("option", { value: m.value }, m.label)),
);
const siteInput = el("input", {
className: "ms-input",
type: "url",
id: "siteUrl",
name: "siteUrl",
placeholder: "https://beanthere.coffee",
autocomplete: "off",
autocapitalize: "off",
spellcheck: "false",
});
const submitBtn = el("button", { className: "ms-btn", type: "submit" }, "Generate logo");
space.form.append(
el("div", { className: "ms-field" },
el("label", { for: "description", text: "Describe your brand" }),
descInput,
el("div", { className: "ms-row", style: { justifyContent: "space-between", alignItems: "center" } },
descHint,
descCount,
),
),
el("div", { className: "ms-field" },
el("label", { for: "brandName", text: "Brand name" }),
brandInput,
el("span", { className: "ms-hint", text: "Optional — the text that appears in the logo wordmark." }),
),
el("div", { className: "ms-field" },
el("label", { text: "Logo style" }),
el("div", { className: "ms-chips", role: "radiogroup", "aria-label": "Logo style" }, ...styleChips),
),
el("div", { className: "ms-row" },
el("div", { className: "ms-field" },
el("label", { for: "colorMood", text: "Colour mood" }),
colorSelect,
),
el("div", { className: "ms-field" },
el("label", { for: "siteUrl", text: "Website" }),
siteInput,
el("span", { className: "ms-hint", text: "Optional — helps the AI match your brand." }),
),
),
submitBtn,
el("p", { className: "ms-api-note" },
"Free demo: ",
String(ENDPOINTS[TOOL_KEY].freeLimitPerHour),
" logos/hour per visitor. Need more, or vector exports? ",
el("a", { href: FULL_TOOL_URL, target: "_blank", rel: "noopener", text: "Use the full AI Logo Maker on mesmer.tools →" }),
),
);
// Live character count + validity hint for the description.
descInput.addEventListener("input", updateDescCount);
function updateDescCount() {
const len = descInput.value.trim().length;
descCount.textContent = `${descInput.value.length} / ${DESC.max}`;
descCount.style.color = descInput.value.length > DESC.max ? "#fca5a5" : "";
if (len > 0 && len < DESC.min) {
descHint.textContent = `A little more detail, please — at least ${DESC.min} characters.`;
descHint.style.color = "#fca5a5";
} else {
descHint.textContent = `Describe your brand — what it does and the feel you want (${DESC.min}${DESC.max} characters).`;
descHint.style.color = "";
}
}
/* --- Submit --------------------------------------------------------------- */
space.form.addEventListener("submit", async (e) => {
e.preventDefault();
const description = descInput.value.trim();
if (description.length < DESC.min || description.length > DESC.max) {
updateDescCount();
descInput.focus();
return;
}
// Only send brandName / siteUrl when non-empty (both optional server-side).
const input = { description, style: selectedStyle, colorMood: colorSelect.value };
const brandName = brandInput.value.trim();
if (brandName) input.brandName = brandName;
const url = siteInput.value.trim();
if (url) input.siteUrl = url;
space.clearOutput();
space.setLoading(true, "Designing your logo… this takes ~30 seconds");
submitBtn.disabled = true;
try {
// Live wire format confirmed: POST {json: input} → {result:{data:{json:{url, enhancedPrompt}}}}.
// callTrpcMutation already unwraps that envelope, returning {url, enhancedPrompt}.
const data = await callTrpcMutation(TRPC_PATH, input, { timeout: 90000 });
space.clearStatus();
renderResult(data, brandName);
} catch (err) {
space.clearOutput();
space.showError(err); // RateLimitError (429) auto-renders the cross-sell banner.
} finally {
submitBtn.disabled = false;
}
});
/* --- Result --------------------------------------------------------------- */
function slugify(s) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "logo";
}
function renderResult(data, brandName) {
// Success shape: { url, enhancedPrompt }
const imgUrl = data && data.url;
const enhancedPrompt = (data && data.enhancedPrompt) || "";
if (!imgUrl) {
space.showError("The logo could not be generated. Please try again.");
return;
}
const filename = `${slugify(brandName || "logo")}-logo.webp`;
const altText = brandName ? `AI-generated logo for ${brandName}` : "AI-generated logo";
space.output.append(
el("div", { className: "ms-result-frame" },
el("img", { src: imgUrl, alt: altText, loading: "lazy" }),
),
brandName ? el("p", { className: "ms-brand-name-result", text: brandName }) : null,
el("div", { className: "ms-result-actions" },
el("a", { className: "ms-btn-ghost", href: imgUrl, target: "_blank", rel: "noopener", text: "Open ↗" }),
el("a", { className: "ms-btn-ghost", href: imgUrl, download: filename, text: "Download" }),
),
enhancedPrompt
? el("details", { className: "ms-prompt-details" },
el("summary", { text: "Show the AI prompt used" }),
el("p", { className: "ms-meta", text: enhancedPrompt }),
)
: null,
);
}