Files
sparkvault/public/app.js
T

1926 lines
71 KiB
JavaScript

// SparkVault — Frontend Application Logic
// Pipeline Board, Rich Workbench, 64-Card Creative Whack Pack & Nano Banana 2.1 Mockup Studio
const state = {
ideas: [],
stats: { total: 0, statusCounts: {}, totalMockups: 0, totalWhacks: 0, tags: [] },
whackCards: [],
whackSuits: {},
currentView: "board",
gridStatusFilter: "all",
filters: {
search: "",
category: "all",
tag: "all",
hasMockup: false,
sort: "score"
},
// Whack Studio state
whackSubmode: "collider",
colliderCard: null,
colliderIdea: null,
spreadCards: [],
deckSuitFilter: "all",
deckSearch: "",
// Quick Banner Whack Card
quickBannerCard: null,
// Review & Arena state
reviewMode: "arena",
triageQueue: [],
triageIndex: 0,
arenaPair: [],
// Active Drawer Idea
activeIdeaId: null,
activeIdea: null,
drawerTab: "details",
drawerWhackCard: null,
// Nano Banana Studio state
nbGarmentColor: "#F5E8D3",
nbGarmentColorName: "Vintage Cream",
nbEditParentMockup: null,
uploadedOverlayImage: null,
settings: {
hasApiKey: false,
apiKeySource: "none",
maskedKey: "",
nanoBananaModel: "gemini-nano-banana-2.1"
}
};
const CATEGORY_LABELS = {
creative: "🎨 Creative / Merch",
project: "🛠️ Project / App",
spark: "⚡ Quick Spark",
research: "🔬 Research"
};
const NEXT_STAGE_MAP = {
spark: "exploring",
exploring: "building",
building: "shipped",
shipped: "shipped",
archived: "spark"
};
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderSimpleMarkdown(md) {
if (!md) return '<p class="muted-label">No notes yet.</p>';
const lines = String(md).split("\n");
const html = lines
.map((line) => {
const trimmed = line.trim();
if (trimmed.startsWith("### ")) return `<h4>${escapeHtml(trimmed.slice(4))}</h4>`;
if (trimmed.startsWith("## ")) return `<h3>${escapeHtml(trimmed.slice(3))}</h3>`;
if (trimmed.startsWith("# ")) return `<h2>${escapeHtml(trimmed.slice(2))}</h2>`;
if (trimmed.startsWith("- ")) return `<li>${formatInlineMd(trimmed.slice(2))}</li>`;
if (!trimmed) return "<br/>";
return `<p>${formatInlineMd(trimmed)}</p>`;
})
.join("");
return html;
}
function formatInlineMd(text) {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`(.+?)`/g, "<code>$1</code>");
}
function showToast(message) {
const container = document.getElementById("toast-container");
const el = document.createElement("div");
el.className = "toast";
el.textContent = message;
container.appendChild(el);
setTimeout(() => el.remove(), 3400);
}
async function apiFetch(url, options = {}) {
const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.error || `Request failed (${res.status})`);
err.code = data.code;
throw err;
}
return data;
}
// --- INITIALIZATION ---
async function initApp() {
bindGlobalEvents();
await Promise.all([loadSettings(), loadWhackCatalog(), loadIdeas()]);
renderCompositorCanvas();
}
async function loadSettings() {
try {
const data = await apiFetch("/api/settings");
state.settings = data;
const dot = document.getElementById("api-key-status-dot");
if (dot) {
dot.classList.toggle("online", Boolean(data.hasApiKey));
}
const keyStatus = document.getElementById("settings-key-status");
if (keyStatus) {
keyStatus.textContent = data.hasApiKey
? `Active key configured (${data.apiKeySource}: ${data.maskedKey})`
: "No key stored yet — paste your Gemini API key to enable live Nano Banana 2.1 generation.";
}
const modelSel = document.getElementById("settings-model-select");
if (modelSel && data.nanoBananaModel) {
modelSel.value = data.nanoBananaModel;
}
} catch (err) {
console.warn("Failed to load settings:", err);
}
}
async function loadWhackCatalog() {
const data = await apiFetch("/api/whack-cards");
state.whackCards = data.cards || [];
state.whackSuits = data.suits || {};
populateWhackTwistSelect();
renderDeckBrowser();
}
function populateWhackTwistSelect() {
const sel = document.getElementById("nb-whack-twist-select");
if (!sel) return;
sel.innerHTML = `<option value="">None (Direct Concept)</option>` +
state.whackCards
.map((c) => `<option value="${c.number}">#${c.number} [${c.suitLabel}] ${escapeHtml(c.title)}</option>`)
.join("");
}
async function loadIdeas() {
const params = new URLSearchParams();
if (state.filters.search) params.set("search", state.filters.search);
if (state.filters.category !== "all") params.set("category", state.filters.category);
if (state.filters.tag !== "all") params.set("tag", state.filters.tag);
if (state.filters.hasMockup) params.set("hasMockup", "true");
params.set("sort", state.filters.sort);
const data = await apiFetch(`/api/ideas?${params.toString()}`);
state.ideas = data.ideas || [];
state.stats = data.stats || state.stats;
updateFilterTagOptions();
document.getElementById("stat-mockups-count").textContent = state.stats.totalMockups || 0;
renderKanbanBoard();
renderExplorerGrid();
populateColliderIdeaSelect();
if (state.activeIdeaId) {
const refreshed = state.ideas.find((i) => i.id === state.activeIdeaId);
if (refreshed) {
state.activeIdea = refreshed;
renderDrawerContent();
}
}
}
function updateFilterTagOptions() {
const sel = document.getElementById("filter-tag");
if (!sel) return;
const current = state.filters.tag;
const optionsHtml =
`<option value="all">All #Tags</option>` +
(state.stats.tags || [])
.map((t) => `<option value="${escapeHtml(t.name)}">#${escapeHtml(t.name)} (${t.count})</option>`)
.join("");
sel.innerHTML = optionsHtml;
if ([...sel.options].some((o) => o.value === current)) {
sel.value = current;
}
}
// --- RENDER KANBAN BOARD ---
function renderKanbanBoard() {
const statuses = ["spark", "exploring", "building", "shipped", "archived"];
for (const st of statuses) {
const colEl = document.getElementById(`col-${st}`);
const countEl = document.getElementById(`count-${st}`);
const list = state.ideas.filter((i) => i.status === st);
if (countEl) countEl.textContent = list.length;
if (!colEl) continue;
if (list.length === 0) {
colEl.innerHTML = `<div class="muted-label" style="padding:0.75rem;text-align:center;">Drop ideas here</div>`;
continue;
}
colEl.innerHTML = list.map((idea) => renderIdeaCardHtml(idea)).join("");
bindIdeaCardEvents(colEl);
}
}
function renderIdeaCardHtml(idea) {
const checklistTotal = idea.checklist?.length || 0;
const checklistDone = (idea.checklist || []).filter((c) => c.done).length;
const whackCount = idea.whack_history?.length || 0;
const nextStage = NEXT_STAGE_MAP[idea.status];
return `
<article
class="idea-card ${idea.pinned ? "pinned" : ""}"
draggable="true"
data-idea-id="${idea.id}"
>
${
idea.cover_mockup_url
? `<img src="${escapeHtml(idea.cover_mockup_url)}" alt="${escapeHtml(idea.title)}" class="idea-card-mockup-thumb" loading="lazy" />`
: ""
}
<div class="idea-card-top">
<span class="cat-badge">${CATEGORY_LABELS[idea.category] || idea.category}</span>
<span class="score-pill" title="Composite Priority Score">⚡ ${idea.composite_score}</span>
</div>
<h3 class="idea-card-title">${idea.pinned ? "📌 " : ""}${escapeHtml(idea.title)}</h3>
${idea.elevator_pitch ? `<p class="idea-card-pitch">${escapeHtml(idea.elevator_pitch)}</p>` : ""}
${
idea.tags.length > 0
? `<div class="idea-card-tags">${idea.tags.map((t) => `<span class="tag-chip">#${escapeHtml(t)}</span>`).join("")}</div>`
: ""
}
<div class="idea-card-footer">
<div>
<span title="Excitement">🔥${idea.rating_excitement}</span>
${checklistTotal > 0 ? `<span> • ☑️ ${checklistDone}/${checklistTotal}</span>` : ""}
${whackCount > 0 ? `<span title="Whack Pack reflections"> • 🃏${whackCount}</span>` : ""}
${idea.mockups.length > 0 ? `<span title="Visual Mockups"> • 🍌${idea.mockups.length}</span>` : ""}
</div>
<div class="card-quick-actions">
<button type="button" class="icon-btn btn-card-mockup" data-idea-id="${idea.id}" title="Open in Nano Banana Mockup Studio">🍌</button>
<button type="button" class="icon-btn btn-card-whack" data-idea-id="${idea.id}" title="Draw a Whack Card for this Idea">🃏</button>
${
idea.status !== "shipped"
? `<button type="button" class="icon-btn btn-card-advance" data-idea-id="${idea.id}" data-next="${nextStage}" title="Advance to ${nextStage}">→</button>`
: ""
}
</div>
</div>
</article>
`;
}
function bindIdeaCardEvents(container) {
container.querySelectorAll(".idea-card").forEach((cardEl) => {
const id = Number(cardEl.dataset.ideaId);
cardEl.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
openIdeaDrawer(id, "details");
});
cardEl.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", String(id));
});
});
container.querySelectorAll(".btn-card-mockup").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openIdeaDrawer(Number(btn.dataset.ideaId), "mockup");
});
});
container.querySelectorAll(".btn-card-whack").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openIdeaDrawer(Number(btn.dataset.ideaId), "whack");
});
});
container.querySelectorAll(".btn-card-advance").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const id = Number(btn.dataset.ideaId);
const next = btn.dataset.next;
await apiFetch(`/api/ideas/${id}`, {
method: "PATCH",
body: JSON.stringify({ status: next })
});
showToast(`Moved idea to ${next.toUpperCase()}`);
await loadIdeas();
});
});
}
// --- RENDER EXPLORER GRID ---
function renderExplorerGrid() {
const container = document.getElementById("explorer-grid");
if (!container) return;
let items = state.ideas;
if (state.gridStatusFilter !== "all") {
items = items.filter((i) => i.status === state.gridStatusFilter);
}
if (items.length === 0) {
container.innerHTML = `<div class="muted-label" style="padding:2rem;">No matching ideas found. Capture a spark above!</div>`;
return;
}
container.innerHTML = items.map((idea) => renderIdeaCardHtml(idea)).join("");
bindIdeaCardEvents(container);
}
// --- CREATIVE WHACK PACK STUDIO ---
function populateColliderIdeaSelect() {
const sel = document.getElementById("collider-idea-select");
if (!sel) return;
const active = state.ideas.filter((i) => i.status !== "archived");
const currentVal = sel.value;
sel.innerHTML =
`<option value="">🎲 Random Active Idea</option>` +
active.map((i) => `<option value="${i.id}">[${i.status.toUpperCase()}] ${escapeHtml(i.title)}</option>`).join("");
if (currentVal && [...sel.options].some((o) => o.value === currentVal)) {
sel.value = currentVal;
}
}
async function dealIdeaCollider({ forceRandomIdea = false } = {}) {
const suit = document.getElementById("collider-suit-select")?.value || "";
const selectedIdeaId = forceRandomIdea ? "" : document.getElementById("collider-idea-select")?.value || "";
const params = new URLSearchParams({ mode: "collider" });
if (suit) params.set("suit", suit);
if (selectedIdeaId) params.set("ideaId", selectedIdeaId);
const data = await apiFetch(`/api/whack-cards/draw?${params.toString()}`);
state.colliderCard = data.card;
state.colliderIdea = data.idea;
renderColliderStage();
}
function renderColliderStage() {
const stage = document.getElementById("collider-stage");
if (!stage || !state.colliderCard) return;
const card = state.colliderCard;
const idea = state.colliderIdea;
stage.innerHTML = `
<div class="whack-card-showcase">
<div style="margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:center;">
<span class="suit-pill suit-${card.suit}">${card.suitLabel} • Card #${card.number}</span>
<button type="button" class="btn btn-ghost btn-xs" id="btn-zoom-collider-card">🔍 Zoom Card</button>
</div>
<img
src="${escapeHtml(card.imageUrl)}"
alt="${escapeHtml(card.title)}"
class="whack-card-scan"
id="img-collider-card"
/>
<h3 style="margin-top:0.65rem;font-size:1rem;">${escapeHtml(card.title)}</h3>
<p class="muted-label" style="margin-top:0.2rem;">${escapeHtml(card.roleDescription)}</p>
</div>
<div class="collider-workbench">
<div>
<span class="muted-label">COLLIDING WITH YOUR IDEA:</span>
${
idea
? `<h3 style="font-size:1.2rem;margin-top:0.2rem;">${escapeHtml(idea.title)} <span class="score-pill">Stage: ${idea.status.toUpperCase()}</span></h3>
<p class="muted-label" style="margin-top:0.2rem;">${escapeHtml(idea.elevator_pitch || "No elevator pitch yet.")}</p>`
: `<h3 style="font-size:1.1rem;">Freeform Brainstorm (No active idea selected)</h3>`
}
</div>
<div class="prompt-callout">
<div class="muted-label">GUIDING WHACK QUESTION (CARD #${card.number}):</div>
<strong style="font-size:1.05rem;color:var(--accent-primary);">${escapeHtml(card.promptQuestion)}</strong>
</div>
<div class="field-group">
<label for="collider-reflection-input">How does "${escapeHtml(card.title)}" transform or advance this idea?</label>
<textarea
id="collider-reflection-input"
rows="4"
class="textarea-input"
placeholder="Write your breakthrough angle, visual twist, feature reversal, or next step..."
></textarea>
</div>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:center;">
${
idea
? `<label class="filter-checkbox">
<input type="checkbox" id="collider-add-checklist" checked />
<span>Also add as Action Checklist step</span>
</label>
<button type="button" id="btn-save-collider-whack" class="btn btn-primary">
💾 Log Breakthrough to Idea
</button>
<button type="button" id="btn-collider-to-mockup" class="btn btn-secondary">
🍌 Visualize This Twist in Nano Banana Studio
</button>`
: ""
}
<button type="button" id="btn-spawn-from-collider" class="btn btn-ghost">
✨ Spawn Brand-New Idea from Card #${card.number}
</button>
</div>
</div>
`;
document.getElementById("img-collider-card")?.addEventListener("click", () => openCardLightbox(card));
document.getElementById("btn-zoom-collider-card")?.addEventListener("click", () => openCardLightbox(card));
document.getElementById("btn-save-collider-whack")?.addEventListener("click", async () => {
const reflection = document.getElementById("collider-reflection-input")?.value.trim();
if (!reflection) {
showToast("Write a quick reflection or breakthrough first!");
return;
}
const addChecklistItem = document.getElementById("collider-add-checklist")?.checked;
await apiFetch(`/api/ideas/${idea.id}/whack`, {
method: "POST",
body: JSON.stringify({
cardNumber: card.number,
reflection,
addChecklistItem
})
});
showToast(`Logged Card #${card.number} breakthrough onto "${idea.title}"!`);
document.getElementById("collider-reflection-input").value = "";
await Promise.all([loadIdeas(), loadWhackCatalog()]);
});
document.getElementById("btn-collider-to-mockup")?.addEventListener("click", async () => {
const reflection = document.getElementById("collider-reflection-input")?.value.trim();
await openIdeaDrawer(idea.id, "mockup");
const twistSel = document.getElementById("nb-whack-twist-select");
if (twistSel) twistSel.value = String(card.number);
const promptEl = document.getElementById("nb-prompt-input");
if (promptEl) {
promptEl.value = reflection
? `${idea.title} — ${reflection} (Inspired by Card #${card.number}: ${card.title})`
: `${idea.title} — reimagined using "${card.title}": ${card.promptQuestion}`;
}
});
document.getElementById("btn-spawn-from-collider")?.addEventListener("click", async () => {
const reflection = document.getElementById("collider-reflection-input")?.value.trim();
const title = reflection
? reflection.split("\n")[0].slice(0, 90)
: `Spark from Card #${card.number}: ${card.title}`;
const created = await apiFetch("/api/ideas", {
method: "POST",
body: JSON.stringify({
title,
elevator_pitch: `Inspired by Creative Whack Pack Card #${card.number} (${card.title}): ${card.promptQuestion}`,
inspired_by_card: card.number,
tags: ["whack-spark", card.suit]
})
});
showToast(`Created new Spark: "${created.idea.title}"`);
await Promise.all([loadIdeas(), loadWhackCatalog()]);
openIdeaDrawer(created.idea.id, "details");
});
}
async function dealFourRoleSpread() {
const data = await apiFetch("/api/whack-cards/draw?mode=spread");
state.spreadCards = data.cards || [];
const grid = document.getElementById("spread-grid");
if (!grid) return;
grid.innerHTML = state.spreadCards
.map(
(card) => `
<div class="whack-card-showcase">
<div style="margin-bottom:0.45rem;">
<span class="suit-pill suit-${card.suit}">${card.suitLabel} (#${card.number})</span>
</div>
<img src="${escapeHtml(card.imageUrl)}" alt="${escapeHtml(card.title)}" class="whack-card-scan" data-card-num="${card.number}" />
<h4 style="margin-top:0.55rem;">${escapeHtml(card.title)}</h4>
<p style="font-size:0.82rem;color:var(--accent-primary);margin-top:0.3rem;font-weight:600;">${escapeHtml(card.promptQuestion)}</p>
<div style="margin-top:0.65rem;display:flex;gap:0.4rem;justify-content:center;">
<button type="button" class="btn btn-ghost btn-xs btn-spread-collide" data-card-num="${card.number}">⚡ Use in Collider</button>
<button type="button" class="btn btn-primary btn-xs btn-spread-spawn" data-card-num="${card.number}">✨ Spawn Idea</button>
</div>
</div>
`
)
.join("");
grid.querySelectorAll("img[data-card-num]").forEach((img) => {
img.addEventListener("click", () => {
const c = state.whackCards.find((x) => x.number === Number(img.dataset.cardNum));
if (c) openCardLightbox(c);
});
});
grid.querySelectorAll(".btn-spread-collide").forEach((btn) => {
btn.addEventListener("click", () => {
const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum));
if (!c) return;
state.colliderCard = c;
if (!state.colliderIdea && state.ideas.length > 0) {
state.colliderIdea = state.ideas[0];
}
switchWhackSubmode("collider");
renderColliderStage();
});
});
grid.querySelectorAll(".btn-spread-spawn").forEach((btn) => {
btn.addEventListener("click", () => {
const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum));
if (c) showQuickWhackBanner(c);
});
});
}
function renderDeckBrowser() {
const grid = document.getElementById("deck-cards-grid");
if (!grid) return;
let list = state.whackCards;
if (state.deckSuitFilter !== "all") {
list = list.filter((c) => c.suit === state.deckSuitFilter);
}
if (state.deckSearch.trim()) {
const q = state.deckSearch.trim().toLowerCase();
list = list.filter(
(c) =>
c.title.toLowerCase().includes(q) ||
c.promptQuestion.toLowerCase().includes(q) ||
String(c.number) === q
);
}
grid.innerHTML = list
.map(
(card) => `
<div class="deck-card-item">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="suit-pill suit-${card.suit}">#${card.number} ${card.suitLabel}</span>
${card.usageCount > 0 ? `<span class="tag-chip">Used ${card.usageCount}x</span>` : ""}
</div>
<img src="${escapeHtml(card.imageUrl)}" alt="${escapeHtml(card.title)}" loading="lazy" data-card-num="${card.number}" />
<strong style="font-size:0.86rem;">${escapeHtml(card.title)}</strong>
<p class="muted-label" style="font-size:0.75rem;flex:1;">${escapeHtml(card.promptQuestion)}</p>
<div style="display:flex;gap:0.35rem;">
<button type="button" class="btn btn-ghost btn-xs btn-deck-collide" data-card-num="${card.number}" style="flex:1;">⚡ Collide</button>
<button type="button" class="btn btn-primary btn-xs btn-deck-spark" data-card-num="${card.number}" style="flex:1;">✨ Spark</button>
</div>
</div>
`
)
.join("");
grid.querySelectorAll("img[data-card-num]").forEach((img) => {
img.addEventListener("click", () => {
const c = state.whackCards.find((x) => x.number === Number(img.dataset.cardNum));
if (c) openCardLightbox(c);
});
});
grid.querySelectorAll(".btn-deck-collide").forEach((btn) => {
btn.addEventListener("click", () => {
const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum));
if (!c) return;
state.colliderCard = c;
if (!state.colliderIdea && state.ideas.length > 0) {
state.colliderIdea = state.ideas[0];
}
switchWhackSubmode("collider");
renderColliderStage();
});
});
grid.querySelectorAll(".btn-deck-spark").forEach((btn) => {
btn.addEventListener("click", () => {
const c = state.whackCards.find((x) => x.number === Number(btn.dataset.cardNum));
if (c) showQuickWhackBanner(c);
});
});
}
// --- REVIEW & HEAD-TO-HEAD ARENA ---
async function loadReviewSession() {
const data = await apiFetch("/api/review/next");
state.triageQueue = data.triageQueue || [];
state.triageIndex = 0;
state.arenaPair = data.arenaPair || [];
renderArenaStage();
renderTriageStage();
}
function renderArenaStage() {
const container = document.getElementById("arena-stage");
if (!container) return;
if (state.arenaPair.length < 2) {
container.innerHTML = `<div class="muted-label" style="padding:2rem;">Need at least 2 active ideas for Head-to-Head Arena. Capture more sparks above!</div>`;
return;
}
const [left, right] = state.arenaPair;
container.innerHTML = `
${renderArenaFighterHtml(left, right.id, "left")}
<div class="arena-vs-badge">VS</div>
${renderArenaFighterHtml(right, left.id, "right")}
`;
container.querySelectorAll(".btn-arena-win").forEach((btn) => {
btn.addEventListener("click", async () => {
const winnerId = Number(btn.dataset.winner);
const loserId = Number(btn.dataset.loser);
const res = await apiFetch("/api/review/arena", {
method: "POST",
body: JSON.stringify({ winnerId, loserId })
});
showToast(`🏆 "${res.winner.title}" wins the round! (Score: ${res.winner.composite_score})`);
await Promise.all([loadIdeas(), loadReviewSession()]);
});
});
container.querySelectorAll(".btn-arena-open").forEach((btn) => {
btn.addEventListener("click", () => openIdeaDrawer(Number(btn.dataset.id), "details"));
});
}
function renderArenaFighterHtml(idea, opponentId) {
return `
<div class="arena-fighter">
<div>
<div class="idea-card-top" style="margin-bottom:0.5rem;">
<span class="cat-badge">${CATEGORY_LABELS[idea.category] || idea.category}</span>
<span class="score-pill">⚡ Score ${idea.composite_score} • 🏆 ${idea.arena_wins}W</span>
</div>
${
idea.cover_mockup_url
? `<img src="${escapeHtml(idea.cover_mockup_url)}" alt="${escapeHtml(idea.title)}" class="idea-card-mockup-thumb" style="height:210px;margin-bottom:0.75rem;" />`
: ""
}
<h3 style="font-size:1.2rem;margin-bottom:0.35rem;">${escapeHtml(idea.title)}</h3>
<p style="color:var(--text-secondary);font-size:0.9rem;margin-bottom:0.65rem;">
${escapeHtml(idea.elevator_pitch || idea.notes_markdown.slice(0, 140) || "No description yet.")}
</p>
<div class="idea-card-tags">
${idea.tags.map((t) => `<span class="tag-chip">#${escapeHtml(t)}</span>`).join("")}
</div>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button type="button" class="btn btn-primary btn-arena-win" style="flex:1;" data-winner="${idea.id}" data-loser="${opponentId}">
🔥 This Excites Me More
</button>
<button type="button" class="btn btn-ghost btn-sm btn-arena-open" data-id="${idea.id}">
Inspect
</button>
</div>
</div>
`;
}
function renderTriageStage() {
const container = document.getElementById("triage-stage");
if (!container) return;
const item = state.triageQueue[state.triageIndex];
if (!item) {
container.innerHTML = `<div class="triage-card"><h3>🎉 All Caught Up!</h3><p class="muted-label">You've reviewed every active idea in the triage queue.</p></div>`;
return;
}
const nextStage = NEXT_STAGE_MAP[item.status];
container.innerHTML = `
<div class="triage-card">
<div class="idea-card-top">
<span class="cat-badge">Stage: ${item.status.toUpperCase()} • ${CATEGORY_LABELS[item.category] || item.category}</span>
<span class="muted-label">Card ${state.triageIndex + 1} of ${state.triageQueue.length}</span>
</div>
${
item.cover_mockup_url
? `<img src="${escapeHtml(item.cover_mockup_url)}" alt="${escapeHtml(item.title)}" class="idea-card-mockup-thumb" style="height:220px;" />`
: ""
}
<h3 style="font-size:1.35rem;">${escapeHtml(item.title)}</h3>
<p style="color:var(--text-secondary);">${escapeHtml(item.elevator_pitch || "No elevator pitch yet — open Workbench or deal a Whack card to flesh it out.")}</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;padding-top:0.5rem;">
<button type="button" class="btn btn-primary" id="btn-triage-promote">🚀 Promote to ${nextStage.toUpperCase()}</button>
<button type="button" class="btn btn-secondary" id="btn-triage-mockup">🍌 Mock Up Image</button>
<button type="button" class="btn btn-ghost" id="btn-triage-whack">🃏 Whack It</button>
<button type="button" class="btn btn-ghost" id="btn-triage-keep">✓ Keep & Next</button>
<button type="button" class="btn btn-danger-ghost" id="btn-triage-archive">📦 Archive</button>
</div>
</div>
`;
document.getElementById("btn-triage-promote")?.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${item.id}`, {
method: "PATCH",
body: JSON.stringify({ status: nextStage, last_reviewed_at: new Date().toISOString() })
});
showToast(`Promoted "${item.title}" to ${nextStage.toUpperCase()}`);
state.triageIndex++;
await loadIdeas();
renderTriageStage();
});
document.getElementById("btn-triage-mockup")?.addEventListener("click", () => {
openIdeaDrawer(item.id, "mockup");
});
document.getElementById("btn-triage-whack")?.addEventListener("click", () => {
openIdeaDrawer(item.id, "whack");
});
document.getElementById("btn-triage-keep")?.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${item.id}`, {
method: "PATCH",
body: JSON.stringify({ last_reviewed_at: new Date().toISOString() })
});
state.triageIndex++;
renderTriageStage();
});
document.getElementById("btn-triage-archive")?.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${item.id}`, {
method: "PATCH",
body: JSON.stringify({ status: "archived", last_reviewed_at: new Date().toISOString() })
});
showToast(`Archived "${item.title}"`);
state.triageIndex++;
await loadIdeas();
renderTriageStage();
});
}
// --- SLIDE-OVER RICH WORKBENCH & NANO BANANA STUDIO ---
async function openIdeaDrawer(id, initialTab = "details") {
const data = await apiFetch(`/api/ideas/${id}`);
state.activeIdeaId = id;
state.activeIdea = data.idea;
state.nbEditParentMockup = null;
document.getElementById("drawer-backdrop").classList.remove("hidden");
document.getElementById("idea-drawer").classList.remove("hidden");
switchDrawerTab(initialTab);
renderDrawerContent();
// Pre-populate compositor text from idea title
const topInput = document.getElementById("comp-top-text");
const bottomInput = document.getElementById("comp-bottom-text");
if (topInput && !topInput.value) {
topInput.value = state.activeIdea.title.slice(0, 26).toUpperCase();
}
if (bottomInput && !bottomInput.value) {
bottomInput.value = state.activeIdea.tags[0] ? `#${state.activeIdea.tags[0].toUpperCase()}` : "SPARKVAULT STUDIO";
}
renderCompositorCanvas();
if (initialTab === "whack" && !state.drawerWhackCard) {
await drawCardForActiveIdea();
}
}
function closeIdeaDrawer() {
state.activeIdeaId = null;
state.activeIdea = null;
document.getElementById("drawer-backdrop").classList.add("hidden");
document.getElementById("idea-drawer").classList.add("hidden");
}
function switchDrawerTab(tabName) {
state.drawerTab = tabName;
document.querySelectorAll(".drawer-tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.drawerTab === tabName);
});
["details", "mockup", "whack"].forEach((t) => {
const panel = document.getElementById(`drawer-tab-${t}`);
if (panel) panel.classList.toggle("hidden", t !== tabName);
});
if (tabName === "mockup") {
renderCompositorCanvas();
}
}
function renderDrawerContent() {
const idea = state.activeIdea;
if (!idea) return;
document.getElementById("drawer-title-input").value = idea.title;
document.getElementById("drawer-status-select").value = idea.status;
document.getElementById("drawer-category-select").value = idea.category;
document.getElementById("drawer-pin-btn").textContent = idea.pinned ? "📌 Pinned" : "📌 Pin";
document.getElementById("drawer-score-badge").textContent = `⚡ Score: ${idea.composite_score}`;
document.getElementById("drawer-pitch-input").value = idea.elevator_pitch || "";
document.getElementById("drawer-notes-input").value = idea.notes_markdown || "";
document.getElementById("drawer-notes-preview").innerHTML = renderSimpleMarkdown(idea.notes_markdown);
// Sliders
document.getElementById("slider-excitement").value = idea.rating_excitement;
document.getElementById("val-excitement").textContent = idea.rating_excitement;
document.getElementById("slider-impact").value = idea.rating_impact;
document.getElementById("val-impact").textContent = idea.rating_impact;
document.getElementById("slider-ease").value = idea.rating_ease;
document.getElementById("val-ease").textContent = idea.rating_ease;
// Counts
document.getElementById("drawer-mockup-count").textContent = idea.mockups.length;
document.getElementById("drawer-whack-count").textContent = idea.whack_history.length;
// Tags
const tagsEl = document.getElementById("drawer-tags-list");
tagsEl.innerHTML = idea.tags
.map(
(t) =>
`<span class="tag-removable">#${escapeHtml(t)} <button type="button" data-remove-tag="${escapeHtml(t)}">✕</button></span>`
)
.join("");
tagsEl.querySelectorAll("button[data-remove-tag]").forEach((btn) => {
btn.addEventListener("click", async () => {
const nextTags = idea.tags.filter((x) => x !== btn.dataset.removeTag);
await saveActiveIdeaPatch({ tags: nextTags });
});
});
// Checklist
const chkContainer = document.getElementById("drawer-checklist-items");
const doneCount = idea.checklist.filter((c) => c.done).length;
document.getElementById("drawer-checklist-progress").textContent = `${doneCount}/${idea.checklist.length} completed`;
chkContainer.innerHTML = idea.checklist
.map(
(item) => `
<div class="checklist-row ${item.done ? "done" : ""}">
<label style="display:flex;align-items:center;gap:0.5rem;flex:1;cursor:pointer;">
<input type="checkbox" data-chk-id="${escapeHtml(item.id)}" ${item.done ? "checked" : ""} />
<span>${escapeHtml(item.text)}</span>
</label>
<button type="button" class="btn btn-ghost btn-xs" data-del-chk="${escapeHtml(item.id)}">✕</button>
</div>
`
)
.join("");
chkContainer.querySelectorAll("input[data-chk-id]").forEach((cb) => {
cb.addEventListener("change", async () => {
const next = idea.checklist.map((c) => (c.id === cb.dataset.chkId ? { ...c, done: cb.checked } : c));
await saveActiveIdeaPatch({ checklist: next });
});
});
chkContainer.querySelectorAll("button[data-del-chk]").forEach((btn) => {
btn.addEventListener("click", async () => {
const next = idea.checklist.filter((c) => c.id !== btn.dataset.delChk);
await saveActiveIdeaPatch({ checklist: next });
});
});
// Pros & Cons
renderBulletList("drawer-pros-list", idea.pros, "pros");
renderBulletList("drawer-cons-list", idea.cons, "cons");
// Links
const linksEl = document.getElementById("drawer-links-list");
linksEl.innerHTML = idea.links
.map(
(l) => `
<div class="checklist-row">
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent-primary);">${escapeHtml(l.title || l.url)}</a>
<button type="button" class="btn btn-ghost btn-xs" data-del-link="${escapeHtml(l.id)}">✕</button>
</div>
`
)
.join("");
linksEl.querySelectorAll("button[data-del-link]").forEach((btn) => {
btn.addEventListener("click", async () => {
const next = idea.links.filter((l) => l.id !== btn.dataset.delLink);
await saveActiveIdeaPatch({ links: next });
});
});
// Mockup Gallery
renderDrawerMockupGallery(idea);
// Whack History
renderDrawerWhackHistory(idea);
}
function renderBulletList(elementId, items, fieldName) {
const ul = document.getElementById(elementId);
if (!ul) return;
ul.innerHTML = items
.map(
(txt, idx) => `
<li>
<span>${escapeHtml(txt)}</span>
<button type="button" class="btn btn-ghost btn-xs" data-del-idx="${idx}">✕</button>
</li>
`
)
.join("");
ul.querySelectorAll("button[data-del-idx]").forEach((btn) => {
btn.addEventListener("click", async () => {
const idx = Number(btn.dataset.delIdx);
const next = items.filter((_, i) => i !== idx);
await saveActiveIdeaPatch({ [fieldName]: next });
});
});
}
async function saveActiveIdeaPatch(patch) {
if (!state.activeIdeaId) return;
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}`, {
method: "PATCH",
body: JSON.stringify(patch)
});
state.activeIdea = res.idea;
await loadIdeas();
}
// --- NANO BANANA MOCKUP GALLERY & ITERATIVE EDITING ---
function renderDrawerMockupGallery(idea) {
const container = document.getElementById("drawer-mockup-gallery");
if (!container) return;
if (!idea.mockups || idea.mockups.length === 0) {
container.innerHTML = `<p class="muted-label">No mockups saved for this idea yet. Generate one with 🍌 Nano Banana 2.1 or save a layout from the T-Shirt Compositor!</p>`;
return;
}
container.innerHTML = idea.mockups
.map((m) => {
const isCover = idea.cover_mockup_url === m.imageUrl;
return `
<div class="mockup-gallery-card ${isCover ? "is-cover" : ""}">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:0.82rem;">${escapeHtml(m.title)}</strong>
<span class="tag-chip">${escapeHtml(m.modelUsed || "mockup")}</span>
</div>
<img src="${escapeHtml(m.imageUrl)}" alt="${escapeHtml(m.title)}" data-zoom-url="${escapeHtml(m.imageUrl)}" />
${m.editInstruction ? `<small class="muted-label">Edited: "${escapeHtml(m.editInstruction)}"</small>` : ""}
<div class="mockup-gallery-actions">
<button type="button" class="btn btn-primary btn-xs btn-mk-refine" data-mk-id="${escapeHtml(m.id)}">
🍌 Refine with Nano Banana
</button>
<button type="button" class="btn btn-ghost btn-xs btn-mk-load-canvas" data-mk-url="${escapeHtml(m.imageUrl)}">
👕 Place on Tee Canvas
</button>
${
!isCover
? `<button type="button" class="btn btn-ghost btn-xs btn-mk-set-cover" data-mk-id="${escapeHtml(m.id)}">⭐ Set Cover</button>`
: `<span class="tag-chip" style="color:var(--accent-primary);">★ Active Cover</span>`
}
<a href="${escapeHtml(m.imageUrl)}" download class="btn btn-ghost btn-xs">⬇️</a>
<button type="button" class="btn btn-danger-ghost btn-xs btn-mk-delete" data-mk-id="${escapeHtml(m.id)}">🗑️</button>
</div>
</div>
`;
})
.join("");
container.querySelectorAll("img[data-zoom-url]").forEach((img) => {
img.addEventListener("click", () => {
openImageLightbox(img.dataset.zoomUrl, img.alt);
});
});
container.querySelectorAll(".btn-mk-refine").forEach((btn) => {
btn.addEventListener("click", () => {
const mk = idea.mockups.find((x) => x.id === btn.dataset.mkId);
if (!mk) return;
state.nbEditParentMockup = mk;
document.getElementById("nb-edit-mode-box").classList.remove("hidden");
document.getElementById("nb-edit-target-label").textContent = mk.title;
document.getElementById("nb-edit-instruction-input").focus();
showToast(`Iterative Edit Mode enabled for "${mk.title}"`);
});
});
container.querySelectorAll(".btn-mk-load-canvas").forEach((btn) => {
btn.addEventListener("click", () => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
state.uploadedOverlayImage = img;
renderCompositorCanvas();
showToast("Loaded artwork onto the Interactive T-Shirt Canvas!");
};
img.src = btn.dataset.mkUrl;
});
});
container.querySelectorAll(".btn-mk-set-cover").forEach((btn) => {
btn.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${idea.id}/mockups/${btn.dataset.mkId}/cover`, { method: "PATCH" });
showToast("Updated card cover thumbnail!");
await loadIdeas();
});
});
container.querySelectorAll(".btn-mk-delete").forEach((btn) => {
btn.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${idea.id}/mockups/${btn.dataset.mkId}`, { method: "DELETE" });
showToast("Deleted mockup variation");
await loadIdeas();
});
});
}
// --- INTERACTIVE HTML5 CANVAS T-SHIRT & PRODUCT COMPOSITOR ---
function renderCompositorCanvas() {
const canvas = document.getElementById("mockup-compositor-canvas");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const W = canvas.width;
const H = canvas.height;
const surface = document.getElementById("comp-surface-select")?.value || "tshirt";
const placement = document.getElementById("comp-placement-select")?.value || "center-chest";
const emblem = document.getElementById("comp-emblem-select")?.value || "mountains";
const topText = (document.getElementById("comp-top-text")?.value || "HIGH ALTITUDE CLUB").toUpperCase();
const bottomText = (document.getElementById("comp-bottom-text")?.value || "EST. 1984 • COLORADO").toUpperCase();
const inkColor = document.getElementById("comp-ink-color")?.value || "#BC4921";
const garmentHex = state.nbGarmentColor || "#F5E8D3";
// Studio background
const bgGrad = ctx.createRadialGradient(W / 2, H / 2, 60, W / 2, H / 2, W * 0.65);
bgGrad.addColorStop(0, "#232938");
bgGrad.addColorStop(1, "#11141c");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, W, H);
if (surface === "tshirt") {
drawTShirtSilhouette(ctx, W, H, garmentHex);
} else if (surface === "sticker") {
drawStickerSurface(ctx, W, H, garmentHex);
} else {
drawPosterSurface(ctx, W, H, garmentHex);
}
// Determine artwork bounding zone based on placement
let artX = W / 2;
let artY = H * 0.44;
let artScale = 1.0;
if (surface === "tshirt") {
if (placement === "left-chest") {
artX = W * 0.61; // Viewer's right = wearer's left chest pocket zone
artY = H * 0.36;
artScale = 0.46;
} else if (placement === "oversized") {
artX = W / 2;
artY = H * 0.46;
artScale = 1.22;
}
}
ctx.save();
ctx.translate(artX, artY);
ctx.scale(artScale, artScale);
// Draw optional uploaded or selected image
if (state.uploadedOverlayImage) {
const img = state.uploadedOverlayImage;
const maxDim = 210;
const ratio = Math.min(maxDim / img.width, maxDim / img.height);
const dw = img.width * ratio;
const dh = img.height * ratio;
ctx.drawImage(img, -dw / 2, -dh / 2 - 10, dw, dh);
} else if (emblem !== "none") {
drawVectorEmblem(ctx, emblem, inkColor);
}
// Draw Screenprint Typography
ctx.fillStyle = inkColor;
ctx.textAlign = "center";
if (topText) {
ctx.font = "900 26px Georgia, serif";
ctx.fillText(topText, 0, -96);
}
if (bottomText) {
ctx.font = "700 16px 'Courier New', monospace";
ctx.fillText(bottomText, 0, 108);
}
// Decorative vintage rule lines
ctx.strokeStyle = inkColor;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(-110, 82);
ctx.lineTo(110, 82);
ctx.stroke();
ctx.restore();
}
function drawTShirtSilhouette(ctx, W, H, garmentHex) {
ctx.save();
ctx.shadowColor = "rgba(0, 0, 0, 0.55)";
ctx.shadowBlur = 32;
ctx.shadowOffsetY = 14;
// Classic Crewneck Tee Vector Path
ctx.beginPath();
ctx.moveTo(225, 95); // Left shoulder neck
ctx.quadraticCurveTo(340, 132, 455, 95); // Collar front curve
ctx.lineTo(545, 130); // Right shoulder
ctx.lineTo(625, 255); // Right outer sleeve
ctx.lineTo(545, 305); // Right sleeve hem
ctx.lineTo(500, 235); // Right armpit
ctx.lineTo(500, 595); // Right torso hem
ctx.lineTo(180, 595); // Bottom hem
ctx.lineTo(180, 235); // Left armpit
ctx.lineTo(135, 305); // Left sleeve hem
ctx.lineTo(55, 255); // Left outer sleeve
ctx.lineTo(135, 130); // Left shoulder
ctx.closePath();
ctx.fillStyle = garmentHex;
ctx.fill();
// Collar rib & subtle fabric shading
ctx.shadowColor = "transparent";
ctx.strokeStyle = "rgba(0,0,0,0.18)";
ctx.lineWidth = 3;
ctx.stroke();
// Back neck interior shadow
ctx.beginPath();
ctx.moveTo(228, 96);
ctx.quadraticCurveTo(340, 114, 452, 96);
ctx.quadraticCurveTo(340, 136, 228, 96);
ctx.fillStyle = "rgba(0,0,0,0.14)";
ctx.fill();
// Ribbed collar seam
ctx.beginPath();
ctx.moveTo(215, 102);
ctx.quadraticCurveTo(340, 148, 465, 102);
ctx.strokeStyle = "rgba(0,0,0,0.15)";
ctx.lineWidth = 4;
ctx.stroke();
ctx.restore();
}
function drawStickerSurface(ctx, W, H, fillHex) {
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.shadowBlur = 28;
// White die-cut vinyl border
ctx.beginPath();
ctx.arc(W / 2, H * 0.45, 215, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.shadowColor = "transparent";
ctx.beginPath();
ctx.arc(W / 2, H * 0.45, 198, 0, Math.PI * 2);
ctx.fillStyle = fillHex;
ctx.fill();
ctx.lineWidth = 4;
ctx.strokeStyle = "#222";
ctx.stroke();
ctx.restore();
}
function drawPosterSurface(ctx, W, H, fillHex) {
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.shadowBlur = 30;
ctx.fillStyle = "#1c1917";
ctx.fillRect(145, 65, 390, 540);
ctx.shadowColor = "transparent";
ctx.fillStyle = fillHex;
ctx.fillRect(165, 85, 350, 500);
ctx.restore();
}
function drawVectorEmblem(ctx, emblem, inkColor) {
ctx.save();
ctx.strokeStyle = inkColor;
ctx.fillStyle = inkColor;
ctx.lineWidth = 4;
// Outer badge ring
ctx.beginPath();
ctx.arc(0, -8, 72, 0, Math.PI * 2);
ctx.stroke();
if (emblem === "mountains") {
// Sun
ctx.beginPath();
ctx.arc(22, -36, 14, 0, Math.PI * 2);
ctx.fillStyle = "#EFA51C";
ctx.fill();
// Peaks
ctx.fillStyle = inkColor;
ctx.beginPath();
ctx.moveTo(-56, 36);
ctx.lineTo(-12, -32);
ctx.lineTo(18, 12);
ctx.lineTo(36, -14);
ctx.lineTo(58, 36);
ctx.closePath();
ctx.fill();
} else if (emblem === "espresso") {
// Cup body
ctx.beginPath();
ctx.roundRect(-34, -18, 62, 44, [4, 4, 24, 24]);
ctx.fill();
// Handle
ctx.beginPath();
ctx.arc(30, 2, 12, -Math.PI / 2, Math.PI / 2);
ctx.stroke();
// Steam waves
ctx.beginPath();
ctx.moveTo(-14, -28);
ctx.quadraticCurveTo(-20, -42, -10, -52);
ctx.moveTo(6, -28);
ctx.quadraticCurveTo(0, -42, 10, -52);
ctx.stroke();
} else if (emblem === "lightbulb") {
ctx.beginPath();
ctx.arc(0, -22, 28, 0, Math.PI * 2);
ctx.fillStyle = "#EFA51C";
ctx.fill();
ctx.stroke();
ctx.fillStyle = inkColor;
ctx.fillRect(-14, 6, 28, 16);
} else if (emblem === "compass") {
ctx.beginPath();
ctx.moveTo(0, -54);
ctx.lineTo(14, -14);
ctx.lineTo(54, 0);
ctx.lineTo(14, 14);
ctx.lineTo(0, 54);
ctx.lineTo(-14, 14);
ctx.lineTo(-54, 0);
ctx.lineTo(-14, -14);
ctx.closePath();
ctx.stroke();
} else if (emblem === "lightning") {
ctx.beginPath();
ctx.moveTo(8, -52);
ctx.lineTo(-24, -4);
ctx.lineTo(4, -4);
ctx.lineTo(-12, 44);
ctx.lineTo(28, -12);
ctx.lineTo(0, -12);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
// --- WHACK THIS IDEA DRAWER TAB ---
async function drawCardForActiveIdea() {
if (!state.activeIdea) return;
const suit = document.getElementById("drawer-whack-suit-select")?.value || "";
const params = new URLSearchParams();
if (suit) params.set("suit", suit);
else params.set("status", state.activeIdea.status);
const data = await apiFetch(`/api/whack-cards/draw?${params.toString()}`);
state.drawerWhackCard = data.card;
renderDrawerActiveWhackStage();
}
function renderDrawerActiveWhackStage() {
const container = document.getElementById("drawer-active-whack-stage");
const card = state.drawerWhackCard;
const idea = state.activeIdea;
if (!container || !card || !idea) return;
container.innerHTML = `
<div class="collider-stage" style="margin-top:0.5rem;">
<div class="whack-card-showcase">
<span class="suit-pill suit-${card.suit}">#${card.number} • ${card.suitLabel}</span>
<img src="${escapeHtml(card.imageUrl)}" alt="${escapeHtml(card.title)}" class="whack-card-scan" style="margin-top:0.5rem;" id="drawer-whack-card-img" />
</div>
<div class="collider-workbench">
<h4>Card #${card.number}: ${escapeHtml(card.title)}</h4>
<div class="prompt-callout">
<strong>${escapeHtml(card.promptQuestion)}</strong>
</div>
<div class="field-group">
<label for="drawer-whack-reflection">Your Breakthrough / Answer for "${escapeHtml(idea.title)}"</label>
<textarea id="drawer-whack-reflection" rows="4" class="textarea-input" placeholder="How can you apply this card to advance your idea?"></textarea>
</div>
<label class="filter-checkbox">
<input type="checkbox" id="drawer-whack-add-chk" checked />
<span>Also add to Action Checklist</span>
</label>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<button type="button" id="btn-drawer-save-whack" class="btn btn-primary btn-sm">💾 Save Breakthrough</button>
<button type="button" id="btn-drawer-whack-to-nb" class="btn btn-secondary btn-sm">🍌 Send Twist to Nano Banana Mockup Studio</button>
</div>
</div>
</div>
`;
document.getElementById("drawer-whack-card-img")?.addEventListener("click", () => openCardLightbox(card));
document.getElementById("btn-drawer-save-whack")?.addEventListener("click", async () => {
const reflection = document.getElementById("drawer-whack-reflection")?.value.trim();
if (!reflection) {
showToast("Enter a reflection first!");
return;
}
const addChecklistItem = document.getElementById("drawer-whack-add-chk")?.checked;
const res = await apiFetch(`/api/ideas/${idea.id}/whack`, {
method: "POST",
body: JSON.stringify({
cardNumber: card.number,
reflection,
addChecklistItem
})
});
state.activeIdea = res.idea;
showToast(`Saved Card #${card.number} breakthrough!`);
await Promise.all([loadIdeas(), loadWhackCatalog()]);
});
document.getElementById("btn-drawer-whack-to-nb")?.addEventListener("click", () => {
const reflection = document.getElementById("drawer-whack-reflection")?.value.trim();
switchDrawerTab("mockup");
const twistSel = document.getElementById("nb-whack-twist-select");
if (twistSel) twistSel.value = String(card.number);
const promptEl = document.getElementById("nb-prompt-input");
if (promptEl) {
promptEl.value = reflection
? `${idea.title} — ${reflection}`
: `${idea.title} (${idea.elevator_pitch || ""}) with twist from "${card.title}"`;
}
});
}
function renderDrawerWhackHistory(idea) {
const listEl = document.getElementById("drawer-whack-history-list");
if (!listEl) return;
if (!idea.whack_history || idea.whack_history.length === 0) {
listEl.innerHTML = `<p class="muted-label">No Whack Pack cards applied to this idea yet.</p>`;
return;
}
listEl.innerHTML = idea.whack_history
.map(
(w) => `
<div class="whack-log-item">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem;">
<span class="suit-pill suit-${w.suit || "explorer"}">Card #${w.cardNumber}: ${escapeHtml(w.cardTitle)}</span>
<span class="muted-label">${new Date(w.createdAt).toLocaleDateString()}</span>
</div>
<p style="font-size:0.88rem;">${escapeHtml(w.reflection)}</p>
</div>
`
)
.join("");
}
// --- QUICK WHACK BANNER & LIGHTBOX ---
function showQuickWhackBanner(card) {
state.quickBannerCard = card;
const banner = document.getElementById("quick-whack-banner");
banner.classList.remove("hidden");
document.getElementById("qwb-card-img").src = card.imageUrl;
const badge = document.getElementById("qwb-suit-badge");
badge.textContent = `${card.suitLabel} #${card.number}`;
badge.className = `suit-pill suit-${card.suit}`;
document.getElementById("qwb-card-title").textContent = card.title;
document.getElementById("qwb-card-question").textContent = card.promptQuestion;
}
function openCardLightbox(card) {
const modal = document.getElementById("lightbox-modal");
modal.classList.remove("hidden");
document.getElementById("lightbox-title").textContent = `Card #${card.number}: ${card.title} (${card.suitLabel})`;
document.getElementById("lightbox-img").src = card.imageUrl;
document.getElementById("lightbox-meta").innerHTML = `
<div class="prompt-callout">
<strong>${escapeHtml(card.promptQuestion)}</strong>
</div>
`;
}
function openImageLightbox(url, title = "Mockup Preview") {
const modal = document.getElementById("lightbox-modal");
modal.classList.remove("hidden");
document.getElementById("lightbox-title").textContent = title;
document.getElementById("lightbox-img").src = url;
document.getElementById("lightbox-meta").innerHTML = "";
}
function switchWhackSubmode(mode) {
state.whackSubmode = mode;
document.querySelectorAll("[data-whack-mode]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.whackMode === mode);
});
["collider", "spread", "browser"].forEach((m) => {
document.getElementById(`whack-submode-${m}`)?.classList.toggle("hidden", m !== mode);
});
}
// --- GLOBAL EVENT BINDINGS ---
function bindGlobalEvents() {
// Keyboard shortcuts: Ctrl+K for Quick Capture, Esc to close modals/drawer
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
document.getElementById("quick-capture-input")?.focus();
} else if (e.key === "Escape") {
document.getElementById("lightbox-modal")?.classList.add("hidden");
document.getElementById("settings-modal")?.classList.add("hidden");
closeIdeaDrawer();
}
});
// Quick Capture Form
document.getElementById("quick-capture-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const input = document.getElementById("quick-capture-input");
const category = document.getElementById("quick-capture-category").value;
const raw = input.value.trim();
if (!raw) return;
const payload = { title: raw, category };
if (state.quickBannerCard) {
payload.inspired_by_card = state.quickBannerCard.number;
payload.elevator_pitch = `Sparked by Card #${state.quickBannerCard.number} (${state.quickBannerCard.title})`;
}
const res = await apiFetch("/api/ideas", {
method: "POST",
body: JSON.stringify(payload)
});
input.value = "";
showToast(`✨ Captured "${res.idea.title}" in Spark!`);
await Promise.all([loadIdeas(), loadWhackCatalog()]);
});
// Quick Whack Prompt button
document.getElementById("btn-quick-whack-prompt")?.addEventListener("click", async () => {
const data = await apiFetch("/api/whack-cards/draw");
if (data.card) showQuickWhackBanner(data.card);
});
document.getElementById("btn-qwb-redraw")?.addEventListener("click", async () => {
const data = await apiFetch("/api/whack-cards/draw");
if (data.card) showQuickWhackBanner(data.card);
});
document.getElementById("btn-qwb-close")?.addEventListener("click", () => {
state.quickBannerCard = null;
document.getElementById("quick-whack-banner").classList.add("hidden");
});
document.getElementById("btn-qwb-use")?.addEventListener("click", () => {
const input = document.getElementById("quick-capture-input");
if (input && state.quickBannerCard) {
input.placeholder = `Idea inspired by #${state.quickBannerCard.number} (${state.quickBannerCard.title})...`;
input.focus();
}
});
document.getElementById("qwb-card-img")?.addEventListener("click", () => {
if (state.quickBannerCard) openCardLightbox(state.quickBannerCard);
});
// Theme Toggle
document.getElementById("btn-theme-toggle")?.addEventListener("click", () => {
const html = document.documentElement;
const next = html.getAttribute("data-theme") === "dark" ? "paper" : "dark";
html.setAttribute("data-theme", next);
document.getElementById("btn-theme-toggle").textContent = next === "dark" ? "☀️ Theme" : "🌙 Theme";
});
// Export Dropdown
document.getElementById("btn-export-menu")?.addEventListener("click", () => {
document.getElementById("export-dropdown-menu")?.classList.toggle("hidden");
});
// Main View Tabs
document.querySelectorAll(".view-tab").forEach((tab) => {
tab.addEventListener("click", async () => {
const view = tab.dataset.view;
state.currentView = view;
document.querySelectorAll(".view-tab").forEach((t) => t.classList.toggle("active", t === tab));
["board", "grid", "whack", "review"].forEach((v) => {
document.getElementById(`view-${v}`)?.classList.toggle("hidden", v !== view);
});
if (view === "whack" && !state.colliderCard) {
await dealIdeaCollider();
}
if (view === "review") {
await loadReviewSession();
}
});
});
// Global Filters
document.getElementById("filter-search")?.addEventListener("input", (e) => {
state.filters.search = e.target.value;
loadIdeas();
});
document.getElementById("filter-category")?.addEventListener("change", (e) => {
state.filters.category = e.target.value;
loadIdeas();
});
document.getElementById("filter-tag")?.addEventListener("change", (e) => {
state.filters.tag = e.target.value;
loadIdeas();
});
document.getElementById("filter-has-mockup")?.addEventListener("change", (e) => {
state.filters.hasMockup = e.target.checked;
loadIdeas();
});
document.getElementById("filter-sort")?.addEventListener("change", (e) => {
state.filters.sort = e.target.value;
loadIdeas();
});
// Kanban Drag & Drop
document.querySelectorAll(".kanban-dropzone").forEach((zone) => {
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("drag-over");
});
zone.addEventListener("dragleave", () => zone.classList.remove("drag-over"));
zone.addEventListener("drop", async (e) => {
e.preventDefault();
zone.classList.remove("drag-over");
const ideaId = Number(e.dataTransfer.getData("text/plain"));
const targetStatus = zone.dataset.status;
if (!ideaId || !targetStatus) return;
await apiFetch(`/api/ideas/${ideaId}`, {
method: "PATCH",
body: JSON.stringify({ status: targetStatus })
});
showToast(`Moved idea to ${targetStatus.toUpperCase()}`);
await loadIdeas();
});
});
// Explorer Grid Status Pills
document.querySelectorAll("#grid-status-pills .pill-btn").forEach((btn) => {
btn.addEventListener("click", () => {
state.gridStatusFilter = btn.dataset.status;
document.querySelectorAll("#grid-status-pills .pill-btn").forEach((b) => b.classList.toggle("active", b === btn));
renderExplorerGrid();
});
});
document.getElementById("btn-new-idea-modal")?.addEventListener("click", async () => {
const res = await apiFetch("/api/ideas", {
method: "POST",
body: JSON.stringify({ title: "New Concept Spark", category: "creative" })
});
await loadIdeas();
openIdeaDrawer(res.idea.id, "details");
});
// Whack Studio Sub-mode Tabs
document.querySelectorAll("[data-whack-mode]").forEach((btn) => {
btn.addEventListener("click", async () => {
const mode = btn.dataset.whackMode;
switchWhackSubmode(mode);
if (mode === "spread" && state.spreadCards.length === 0) {
await dealFourRoleSpread();
}
});
});
document.getElementById("btn-deal-collider")?.addEventListener("click", () => dealIdeaCollider({ forceRandomIdea: false }));
document.getElementById("btn-collider-random-both")?.addEventListener("click", () => dealIdeaCollider({ forceRandomIdea: true }));
document.getElementById("btn-deal-spread")?.addEventListener("click", () => dealFourRoleSpread());
document.querySelectorAll("#deck-suit-filters .pill-btn").forEach((btn) => {
btn.addEventListener("click", () => {
state.deckSuitFilter = btn.dataset.suit;
document.querySelectorAll("#deck-suit-filters .pill-btn").forEach((b) => b.classList.toggle("active", b === btn));
renderDeckBrowser();
});
});
document.getElementById("deck-search-input")?.addEventListener("input", (e) => {
state.deckSearch = e.target.value;
renderDeckBrowser();
});
// Review Mode Tabs
document.querySelectorAll("[data-review-mode]").forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.reviewMode;
state.reviewMode = mode;
document.querySelectorAll("[data-review-mode]").forEach((b) => b.classList.toggle("active", b === btn));
document.getElementById("review-submode-arena")?.classList.toggle("hidden", mode !== "arena");
document.getElementById("review-submode-triage")?.classList.toggle("hidden", mode !== "triage");
});
});
document.getElementById("btn-skip-arena")?.addEventListener("click", () => loadReviewSession());
// Drawer Events
document.getElementById("drawer-backdrop")?.addEventListener("click", closeIdeaDrawer);
document.getElementById("drawer-close-btn")?.addEventListener("click", closeIdeaDrawer);
document.querySelectorAll(".drawer-tab").forEach((btn) => {
btn.addEventListener("click", async () => {
const tab = btn.dataset.drawerTab;
switchDrawerTab(tab);
if (tab === "whack" && !state.drawerWhackCard) {
await drawCardForActiveIdea();
}
});
});
document.getElementById("drawer-title-input")?.addEventListener("change", (e) => {
saveActiveIdeaPatch({ title: e.target.value });
});
document.getElementById("drawer-status-select")?.addEventListener("change", (e) => {
saveActiveIdeaPatch({ status: e.target.value });
});
document.getElementById("drawer-category-select")?.addEventListener("change", (e) => {
saveActiveIdeaPatch({ category: e.target.value });
});
document.getElementById("drawer-pin-btn")?.addEventListener("click", () => {
if (!state.activeIdea) return;
saveActiveIdeaPatch({ pinned: !state.activeIdea.pinned });
});
document.getElementById("drawer-delete-btn")?.addEventListener("click", async () => {
if (!state.activeIdeaId) return;
await apiFetch(`/api/ideas/${state.activeIdeaId}`, { method: "DELETE" });
showToast("Idea deleted");
closeIdeaDrawer();
await loadIdeas();
});
document.getElementById("drawer-pitch-input")?.addEventListener("change", (e) => {
saveActiveIdeaPatch({ elevator_pitch: e.target.value });
});
document.getElementById("drawer-notes-input")?.addEventListener("change", (e) => {
saveActiveIdeaPatch({ notes_markdown: e.target.value });
});
document.getElementById("btn-toggle-md-preview")?.addEventListener("click", () => {
const textarea = document.getElementById("drawer-notes-input");
const preview = document.getElementById("drawer-notes-preview");
preview.innerHTML = renderSimpleMarkdown(textarea.value);
textarea.classList.toggle("hidden");
preview.classList.toggle("hidden");
});
["excitement", "impact", "ease"].forEach((metric) => {
const slider = document.getElementById(`slider-${metric}`);
slider?.addEventListener("input", (e) => {
document.getElementById(`val-${metric}`).textContent = e.target.value;
});
slider?.addEventListener("change", (e) => {
saveActiveIdeaPatch({ [`rating_${metric}`]: Number(e.target.value) });
});
});
document.getElementById("drawer-add-tag-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
if (!state.activeIdea) return;
const input = document.getElementById("drawer-new-tag-input");
const val = input.value.replace(/^#/, "").trim().toLowerCase();
if (!val) return;
input.value = "";
const next = Array.from(new Set([...state.activeIdea.tags, val]));
await saveActiveIdeaPatch({ tags: next });
});
document.getElementById("drawer-add-checklist-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
if (!state.activeIdea) return;
const input = document.getElementById("drawer-new-checklist-input");
const text = input.value.trim();
if (!text) return;
input.value = "";
const next = [...state.activeIdea.checklist, { id: `chk-${Date.now()}`, text, done: false }];
await saveActiveIdeaPatch({ checklist: next });
});
document.getElementById("drawer-add-pro-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
if (!state.activeIdea) return;
const input = document.getElementById("drawer-new-pro-input");
const text = input.value.trim();
if (!text) return;
input.value = "";
await saveActiveIdeaPatch({ pros: [...state.activeIdea.pros, text] });
});
document.getElementById("drawer-add-con-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
if (!state.activeIdea) return;
const input = document.getElementById("drawer-new-con-input");
const text = input.value.trim();
if (!text) return;
input.value = "";
await saveActiveIdeaPatch({ cons: [...state.activeIdea.cons, text] });
});
document.getElementById("drawer-add-link-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
if (!state.activeIdea) return;
const titleEl = document.getElementById("drawer-link-title");
const urlEl = document.getElementById("drawer-link-url");
if (!urlEl.value.trim()) return;
const next = [
...state.activeIdea.links,
{ id: `lnk-${Date.now()}`, title: titleEl.value.trim() || urlEl.value.trim(), url: urlEl.value.trim() }
];
titleEl.value = "";
urlEl.value = "";
await saveActiveIdeaPatch({ links: next });
});
// --- NANO BANANA MOCKUP STUDIO EVENTS ---
document.querySelectorAll("#nb-color-swatches .color-swatch").forEach((sw) => {
sw.addEventListener("click", () => {
document.querySelectorAll("#nb-color-swatches .color-swatch").forEach((s) => s.classList.remove("active"));
sw.classList.add("active");
state.nbGarmentColor = sw.dataset.color;
state.nbGarmentColorName = sw.dataset.name;
document.getElementById("nb-selected-color-label").textContent = `${sw.dataset.name} (${sw.dataset.color})`;
renderCompositorCanvas();
});
});
document.getElementById("btn-studio-auto-prompt")?.addEventListener("click", () => {
if (!state.activeIdea) return;
const idea = state.activeIdea;
const twistNum = document.getElementById("nb-whack-twist-select")?.value;
const twistCard = twistNum ? state.whackCards.find((c) => c.number === Number(twistNum)) : null;
let built = `${idea.title}`;
if (idea.elevator_pitch) built += ` — ${idea.elevator_pitch}`;
if (twistCard) {
built += `. Creative twist from Card #${twistCard.number} (${twistCard.title}): ${twistCard.promptQuestion}`;
}
document.getElementById("nb-prompt-input").value = built;
});
document.getElementById("btn-nb-clear-edit")?.addEventListener("click", () => {
state.nbEditParentMockup = null;
document.getElementById("nb-edit-mode-box").classList.add("hidden");
document.getElementById("nb-edit-instruction-input").value = "";
});
document.getElementById("btn-generate-nano-banana")?.addEventListener("click", async () => {
if (!state.activeIdeaId) return;
const btn = document.getElementById("btn-generate-nano-banana");
const statusLabel = document.getElementById("nb-generation-status");
const template = document.getElementById("nb-template-select").value;
const whackCardNumber = document.getElementById("nb-whack-twist-select").value;
const prompt = document.getElementById("nb-prompt-input").value.trim();
const editInstruction = document.getElementById("nb-edit-instruction-input").value.trim();
btn.disabled = true;
statusLabel.textContent = "🍌 Calling Nano Banana 2.1 (gemini-nano-banana-2.1)...";
try {
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/mockups/nano-banana`, {
method: "POST",
body: JSON.stringify({
template,
garmentColor: state.nbGarmentColor,
garmentColorName: state.nbGarmentColorName,
whackCardNumber: whackCardNumber ? Number(whackCardNumber) : null,
prompt,
editInstruction: state.nbEditParentMockup ? editInstruction : "",
parentMockupId: state.nbEditParentMockup ? state.nbEditParentMockup.id : null
})
});
state.activeIdea = res.idea;
state.nbEditParentMockup = null;
document.getElementById("nb-edit-mode-box").classList.add("hidden");
document.getElementById("nb-edit-instruction-input").value = "";
statusLabel.textContent = "✓ Saved new Nano Banana mockup to gallery!";
showToast("🍌 Nano Banana 2.1 generated & saved your mockup!");
await loadIdeas();
} catch (err) {
statusLabel.textContent = `⚠️ ${err.message}`;
if (err.code === "MISSING_API_KEY") {
document.getElementById("settings-modal")?.classList.remove("hidden");
} else {
showToast(err.message);
}
} finally {
btn.disabled = false;
}
});
// Compositor controls
["comp-surface-select", "comp-placement-select", "comp-emblem-select", "comp-top-text", "comp-bottom-text", "comp-ink-color"].forEach((id) => {
const el = document.getElementById(id);
el?.addEventListener("input", renderCompositorCanvas);
el?.addEventListener("change", renderCompositorCanvas);
});
document.getElementById("comp-upload-art")?.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
state.uploadedOverlayImage = img;
renderCompositorCanvas();
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
document.getElementById("btn-clear-uploaded-art")?.addEventListener("click", () => {
state.uploadedOverlayImage = null;
const fileEl = document.getElementById("comp-upload-art");
if (fileEl) fileEl.value = "";
renderCompositorCanvas();
});
document.getElementById("btn-save-canvas-mockup")?.addEventListener("click", async () => {
if (!state.activeIdeaId) return;
const canvas = document.getElementById("mockup-compositor-canvas");
const imageDataUrl = canvas.toDataURL("image/png");
const surface = document.getElementById("comp-surface-select").value;
const placement = document.getElementById("comp-placement-select").value;
const topText = document.getElementById("comp-top-text").value;
const bottomText = document.getElementById("comp-bottom-text").value;
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/mockups/canvas`, {
method: "POST",
body: JSON.stringify({
title: `${state.nbGarmentColorName} ${surface === "tshirt" ? "Tee" : surface} (${placement})`,
template: surface === "tshirt" ? (placement === "left-chest" ? "tshirt-left-chest" : "tshirt-front") : surface,
garmentColor: state.nbGarmentColor,
garmentColorName: state.nbGarmentColorName,
placement,
topText,
bottomText,
imageDataUrl
})
});
state.activeIdea = res.idea;
showToast("📸 Saved T-Shirt / Product Mockup to Idea Gallery!");
await loadIdeas();
});
document.getElementById("btn-download-canvas-png")?.addEventListener("click", () => {
const canvas = document.getElementById("mockup-compositor-canvas");
const link = document.createElement("a");
link.download = `sparkvault-mockup-${Date.now()}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
});
// Drawer Whack Card button
document.getElementById("btn-drawer-draw-whack")?.addEventListener("click", drawCardForActiveIdea);
// Settings Modal
document.getElementById("btn-open-settings")?.addEventListener("click", () => {
document.getElementById("settings-modal")?.classList.remove("hidden");
});
document.getElementById("btn-close-settings")?.addEventListener("click", () => {
document.getElementById("settings-modal")?.classList.add("hidden");
});
document.getElementById("btn-cancel-settings")?.addEventListener("click", () => {
document.getElementById("settings-modal")?.classList.add("hidden");
});
document.getElementById("settings-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const keyInput = document.getElementById("settings-api-key");
const modelSel = document.getElementById("settings-model-select");
const payload = { nanoBananaModel: modelSel.value };
if (keyInput.value.trim()) {
payload.geminiApiKey = keyInput.value.trim();
}
await apiFetch("/api/settings", {
method: "PUT",
body: JSON.stringify(payload)
});
keyInput.value = "";
document.getElementById("settings-modal")?.classList.add("hidden");
showToast("🍌 Saved Nano Banana 2.1 settings!");
await loadSettings();
});
// Lightbox Close
document.getElementById("btn-close-lightbox")?.addEventListener("click", () => {
document.getElementById("lightbox-modal")?.classList.add("hidden");
});
}
initApp();