Files
sparkvault/public/app.js
T

1943 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 mcountEl = document.getElementById(`mcount-${st}`);
const list = state.ideas.filter((i) => i.status === st);
if (countEl) countEl.textContent = list.length;
if (mcountEl) mcountEl.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("btn-mobile-filter-toggle")?.addEventListener("click", () => {
const bar = document.getElementById("global-filter-bar");
bar?.classList.toggle("mobile-filters-open");
});
document.querySelectorAll("#mobile-stage-switcher .stage-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const stage = tab.dataset.mobileStage;
document.querySelectorAll("#mobile-stage-switcher .stage-tab").forEach((t) =>
t.classList.toggle("active", t === tab)
);
document.getElementById("kanban-board")?.setAttribute("data-mobile-active-stage", stage);
});
});
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 = "🎨 Generating high-res AI mockup via OpenAI Codex (takes ~15–25s)...";
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();