Spaces:
Running
Running
File size: 8,799 Bytes
0bc2c6b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | 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,
);
}
|