feat(naming): add Suggest Names button for ideas and tee compositor
This commit is contained in:
1 parent
25d24a53a6
commit
6c6867b49c
6 files changed
+276
-7
No files matched your search
@@ -812,10 +812,96 @@ async function openIdeaDrawer(id, initialTab = "details") {
|
|||||||
function closeIdeaDrawer() {
|
function closeIdeaDrawer() {
|
||||||
state.activeIdeaId = null;
|
state.activeIdeaId = null;
|
||||||
state.activeIdea = null;
|
state.activeIdea = null;
|
||||||
|
document.getElementById("drawer-name-suggestions-panel")?.classList.add("hidden");
|
||||||
document.getElementById("drawer-backdrop").classList.add("hidden");
|
document.getElementById("drawer-backdrop").classList.add("hidden");
|
||||||
document.getElementById("idea-drawer").classList.add("hidden");
|
document.getElementById("idea-drawer").classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestNameSuggestionsForActiveIdea() {
|
||||||
|
if (!state.activeIdeaId || !state.activeIdea) return;
|
||||||
|
const panel = document.getElementById("drawer-name-suggestions-panel");
|
||||||
|
const btn = document.getElementById("btn-suggest-names");
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
panel.classList.remove("hidden");
|
||||||
|
panel.innerHTML = `<div class="muted-label" style="padding:0.4rem;">✨ Brainstorming 6 creative names & taglines for "${escapeHtml(state.activeIdea.title)}"...</div>`;
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/suggest-names`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
const list = res.suggestions || [];
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="name-suggestions-header">
|
||||||
|
<span>✨ <strong>Suggested Names & Taglines</strong> <small class="muted-label">(${escapeHtml(res.generatorUsed || "AI")})</small></span>
|
||||||
|
<div style="display:flex;gap:0.35rem;">
|
||||||
|
<button type="button" id="btn-resuggest-names" class="btn btn-ghost btn-xs">🔄 More</button>
|
||||||
|
<button type="button" id="btn-close-name-suggestions" class="btn btn-ghost btn-xs">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="name-suggestions-grid">
|
||||||
|
${list
|
||||||
|
.map(
|
||||||
|
(s, idx) => `
|
||||||
|
<div class="name-suggestion-card" data-idx="${idx}">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.35rem;">
|
||||||
|
<strong style="font-size:0.86rem;color:var(--text-primary);">${escapeHtml(s.name)}</strong>
|
||||||
|
<span class="tag-chip">${escapeHtml(s.style || "Name")}</span>
|
||||||
|
</div>
|
||||||
|
${s.tagline ? `<small class="muted-label" style="display:block;">${escapeHtml(s.tagline)}</small>` : ""}
|
||||||
|
<div style="display:flex;gap:0.35rem;margin-top:0.25rem;flex-wrap:wrap;">
|
||||||
|
<button type="button" class="btn btn-primary btn-xs btn-use-suggested-title" data-name="${escapeHtml(s.name)}" data-tagline="${escapeHtml(s.tagline || "")}">
|
||||||
|
✓ Use Title
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs btn-use-suggested-tee" data-name="${escapeHtml(s.name)}" data-tagline="${escapeHtml(s.tagline || "")}">
|
||||||
|
👕 Send to Tee Canvas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
panel.querySelector("#btn-resuggest-names")?.addEventListener("click", requestNameSuggestionsForActiveIdea);
|
||||||
|
panel.querySelector("#btn-close-name-suggestions")?.addEventListener("click", () => {
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll(".btn-use-suggested-title").forEach((b) => {
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
const newTitle = b.dataset.name;
|
||||||
|
const tagline = b.dataset.tagline;
|
||||||
|
const patch = { title: newTitle };
|
||||||
|
if (tagline && !state.activeIdea.elevator_pitch) {
|
||||||
|
patch.elevator_pitch = tagline;
|
||||||
|
}
|
||||||
|
document.getElementById("drawer-title-input").value = newTitle;
|
||||||
|
await saveActiveIdeaPatch(patch);
|
||||||
|
showToast(`✨ Renamed Spark to "${newTitle}"!`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll(".btn-use-suggested-tee").forEach((b) => {
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
const topEl = document.getElementById("comp-top-text");
|
||||||
|
const botEl = document.getElementById("comp-bottom-text");
|
||||||
|
if (topEl) topEl.value = b.dataset.name.toUpperCase();
|
||||||
|
if (botEl && b.dataset.tagline) botEl.value = b.dataset.tagline.toUpperCase();
|
||||||
|
switchDrawerTab("mockup");
|
||||||
|
renderCompositorCanvas();
|
||||||
|
showToast("👕 Loaded name & tagline onto the T-Shirt Compositor!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
panel.innerHTML = `<div class="muted-label">⚠️ ${escapeHtml(err.message)}</div>`;
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function switchDrawerTab(tabName) {
|
function switchDrawerTab(tabName) {
|
||||||
state.drawerTab = tabName;
|
state.drawerTab = tabName;
|
||||||
document.querySelectorAll(".drawer-tab").forEach((btn) => {
|
document.querySelectorAll(".drawer-tab").forEach((btn) => {
|
||||||
@@ -1889,6 +1975,8 @@ function bindGlobalEvents() {
|
|||||||
document.getElementById("drawer-title-input")?.addEventListener("change", (e) => {
|
document.getElementById("drawer-title-input")?.addEventListener("change", (e) => {
|
||||||
saveActiveIdeaPatch({ title: e.target.value });
|
saveActiveIdeaPatch({ title: e.target.value });
|
||||||
});
|
});
|
||||||
|
document.getElementById("btn-suggest-names")?.addEventListener("click", requestNameSuggestionsForActiveIdea);
|
||||||
|
document.getElementById("btn-comp-suggest-names")?.addEventListener("click", requestNameSuggestionsForActiveIdea);
|
||||||
document.getElementById("drawer-status-select")?.addEventListener("change", (e) => {
|
document.getElementById("drawer-status-select")?.addEventListener("change", (e) => {
|
||||||
saveActiveIdeaPatch({ status: e.target.value });
|
saveActiveIdeaPatch({ status: e.target.value });
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-2
@@ -347,7 +347,13 @@
|
|||||||
<aside id="idea-drawer" class="idea-drawer hidden" aria-label="Idea Workbench">
|
<aside id="idea-drawer" class="idea-drawer hidden" aria-label="Idea Workbench">
|
||||||
<div class="drawer-header">
|
<div class="drawer-header">
|
||||||
<div class="drawer-title-group">
|
<div class="drawer-title-group">
|
||||||
<input type="text" id="drawer-title-input" class="drawer-title-input" placeholder="Idea Title" />
|
<div class="drawer-title-row">
|
||||||
|
<input type="text" id="drawer-title-input" class="drawer-title-input" placeholder="Idea Title" />
|
||||||
|
<button type="button" id="btn-suggest-names" class="btn btn-secondary btn-xs" title="Brainstorm 6 creative names & taglines for this idea">
|
||||||
|
✨ Suggest Names
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="drawer-name-suggestions-panel" class="name-suggestions-panel hidden"></div>
|
||||||
<div class="drawer-meta-row">
|
<div class="drawer-meta-row">
|
||||||
<select id="drawer-status-select" class="drawer-select">
|
<select id="drawer-status-select" class="drawer-select">
|
||||||
<option value="spark">✨ Spark</option>
|
<option value="spark">✨ Spark</option>
|
||||||
@@ -647,7 +653,10 @@
|
|||||||
|
|
||||||
<div class="form-row-2">
|
<div class="form-row-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="comp-top-text">Top Headline / Club Name</label>
|
<div class="field-header-row">
|
||||||
|
<label for="comp-top-text">Top Headline / Club Name</label>
|
||||||
|
<button type="button" id="btn-comp-suggest-names" class="btn btn-ghost btn-xs">✨ Suggest Names</button>
|
||||||
|
</div>
|
||||||
<input type="text" id="comp-top-text" class="text-input input-sm" placeholder="HIGH ALTITUDE CLUB" />
|
<input type="text" id="comp-top-text" class="text-input input-sm" placeholder="HIGH ALTITUDE CLUB" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -885,6 +885,46 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drawer-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-suggestions-panel {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-suggestions-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-suggestions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-suggestion-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-title-input {
|
.drawer-title-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
|||||||
+101
-4
@@ -168,8 +168,8 @@ async function generateIdeaViaCodex(prompt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = extractJsonObject(lastAgentText);
|
const parsed = extractJsonObject(lastAgentText);
|
||||||
if (!parsed || !parsed.title) {
|
if (!parsed || (!parsed.title && !Array.isArray(parsed.suggestions))) {
|
||||||
throw new Error(`Codex exited (${exitCode}) without returning valid idea JSON.`);
|
throw new Error(`Codex exited (${exitCode}) without returning valid JSON.`);
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -181,8 +181,8 @@ async function generateIdeaViaGemini(apiKey, prompt) {
|
|||||||
input: prompt
|
input: prompt
|
||||||
});
|
});
|
||||||
const parsed = extractJsonObject(interaction.output_text);
|
const parsed = extractJsonObject(interaction.output_text);
|
||||||
if (!parsed || !parsed.title) {
|
if (!parsed || (!parsed.title && !Array.isArray(parsed.suggestions))) {
|
||||||
throw new Error("Gemini completed without returning valid idea JSON.");
|
throw new Error("Gemini completed without returning valid JSON.");
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
@@ -310,3 +310,100 @@ export async function generatePersonalizedRandomIdea({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates 6 creative name / title & slogan suggestions for a given idea
|
||||||
|
* across distinct naming angles (Punchy Brand, Vintage Club/Badge, Clever/Witty,
|
||||||
|
* Minimalist Editorial, Graphic Slogan, Technical/Maker).
|
||||||
|
*/
|
||||||
|
export async function generateNameSuggestions({
|
||||||
|
idea,
|
||||||
|
userBackground = DEFAULT_USER_BACKGROUND,
|
||||||
|
geminiApiKey = ""
|
||||||
|
}) {
|
||||||
|
const prompt = [
|
||||||
|
"You are a sharp creative director, brand namer, and apparel/product copywriter.",
|
||||||
|
`IDEA TO NAME:\n- Current Title: ${idea.title}\n- Elevator Pitch: ${idea.elevator_pitch || ""}\n- Category: ${idea.category}\n- Tags: ${(idea.tags || []).join(", ")}\n- Notes: ${(idea.notes_markdown || "").slice(0, 600)}`,
|
||||||
|
`USER BACKGROUND & TASTES:\n${userBackground || DEFAULT_USER_BACKGROUND}`,
|
||||||
|
"Generate 6 distinct, memorable name/title suggestions for this idea across different naming angles (e.g., Vintage Club/Crest, Punchy Brand Name, Witty/Clever, Minimalist, Screenprint Headline + Sub-tagline, Maker/Project Codename).",
|
||||||
|
"Do NOT run any shell commands or tools. Reply ONLY with a single raw JSON object (no markdown fences) matching this exact schema:",
|
||||||
|
JSON.stringify({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
name: "Suggested Idea Title or Brand Name",
|
||||||
|
tagline: "Short punchy subtitle, est. line, or one-line hook",
|
||||||
|
style: "Angle badge (e.g. Vintage Club, Punchy Brand, Witty, Minimalist, Tee Slogan, Codename)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
|
let resultObj = null;
|
||||||
|
let generatorUsed = "Smart Name Generator";
|
||||||
|
|
||||||
|
if (isCodexConfigured()) {
|
||||||
|
try {
|
||||||
|
resultObj = await generateIdeaViaCodex(prompt);
|
||||||
|
generatorUsed = "OpenAI Codex (gpt-5.6-sol)";
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Codex name suggestion fallback:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resultObj && geminiApiKey) {
|
||||||
|
try {
|
||||||
|
resultObj = await generateIdeaViaGemini(geminiApiKey, prompt);
|
||||||
|
generatorUsed = "Gemini 3.1 Flash-Lite";
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Gemini name suggestion fallback:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resultObj || !Array.isArray(resultObj.suggestions) || resultObj.suggestions.length === 0) {
|
||||||
|
const coreWords = String(idea.title || "Spark")
|
||||||
|
.replace(/—.*$/, "")
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.trim();
|
||||||
|
const shortCore = coreWords.split(/\s+/).slice(0, 3).join(" ") || "Alpine Spark";
|
||||||
|
resultObj = {
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
name: `${shortCore} Society`,
|
||||||
|
tagline: `EST. 1984 • HIGH ALTITUDE DIVISION`,
|
||||||
|
style: "Vintage Club"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `The ${shortCore} Dept.`,
|
||||||
|
tagline: `FIELD TESTED & SELF-HOSTED`,
|
||||||
|
style: "Heritage Badge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${shortCore} — Heavyweight Edition`,
|
||||||
|
tagline: `BUILT FOR LATE NIGHTS & EARLY SUMMITS`,
|
||||||
|
style: "Apparel / Merch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Project ${shortCore.split(" ")[0] || "Pulse"}`,
|
||||||
|
tagline: `ZERO FRICTION • LOCAL FIRST`,
|
||||||
|
style: "Maker Codename"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `${shortCore} Co. Outpost`,
|
||||||
|
tagline: `CRAFTED IN COLORADO • NO SHORTCUTS`,
|
||||||
|
style: "Outfitter Crest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: ` Midnight ${shortCore} Club`,
|
||||||
|
tagline: `IF IT AIN'T BROKE, UPGRADE IT`,
|
||||||
|
style: "Witty / Homelab"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatorUsed,
|
||||||
|
suggestions: resultObj.suggestions.slice(0, 6)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
+22
-1
@@ -19,7 +19,8 @@ import {
|
|||||||
} from "./nano_banana.js";
|
} from "./nano_banana.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_USER_BACKGROUND,
|
DEFAULT_USER_BACKGROUND,
|
||||||
generatePersonalizedRandomIdea
|
generatePersonalizedRandomIdea,
|
||||||
|
generateNameSuggestions
|
||||||
} from "./idea_generator.js";
|
} from "./idea_generator.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -236,6 +237,26 @@ export function createAppServer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3b. POST /api/ideas/:id/suggest-names (AI Name & Tagline Brainstormer)
|
||||||
|
const suggestNamesMatch = pathname.match(/^\/api\/ideas\/(\d+)\/suggest-names$/);
|
||||||
|
if (method === "POST" && suggestNamesMatch) {
|
||||||
|
const id = Number(suggestNamesMatch[1]);
|
||||||
|
const idea = store.getIdea(id);
|
||||||
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
||||||
|
|
||||||
|
const userBackground =
|
||||||
|
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND;
|
||||||
|
const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || "";
|
||||||
|
|
||||||
|
const result = await generateNameSuggestions({
|
||||||
|
idea,
|
||||||
|
userBackground,
|
||||||
|
geminiApiKey: savedKey
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendJson(res, 200, result);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. GET /api/whack-cards
|
// 4. GET /api/whack-cards
|
||||||
if (method === "GET" && pathname === "/api/whack-cards") {
|
if (method === "GET" && pathname === "/api/whack-cards") {
|
||||||
const allIdeas = store.listIdeas({});
|
const allIdeas = store.listIdeas({});
|
||||||
|
|||||||
@@ -233,4 +233,18 @@ test("Personalized Random Idea Generator creates a tailored idea from background
|
|||||||
assert.ok(data.idea.whack_history.length >= 1);
|
assert.ok(data.idea.whack_history.length >= 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Suggest Names endpoint returns 6 creative name and tagline suggestions for an idea", async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/ideas/1/suggest-names`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const data = await res.json();
|
||||||
|
assert.ok(Array.isArray(data.suggestions));
|
||||||
|
assert.equal(data.suggestions.length, 6);
|
||||||
|
assert.ok(data.suggestions[0].name);
|
||||||
|
assert.ok(data.suggestions[0].tagline);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user