Files
sparkvault/public/app.js
T

3031 lines
116 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,
stagedDrawerAttachmentDataUrl: null,
stagedClipAttachmentDataUrl: 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();
checkWebShareTargetQueryParams();
}
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 provider configured (${data.apiKeySource}: ${data.maskedKey})`
: "No Gemini API key stored — OpenAI Codex works automatically via OAuth.";
}
const modelSel = document.getElementById("settings-model-select");
if (modelSel && data.nanoBananaModel) {
modelSel.value = data.nanoBananaModel;
}
const studioGenSel = document.getElementById("nb-generator-select");
if (studioGenSel && data.nanoBananaModel) {
studioGenSel.value = data.nanoBananaModel;
}
const bgEl = document.getElementById("settings-user-background");
if (bgEl && data.userBackground !== undefined) {
bgEl.value = data.userBackground;
}
} 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>` : ""}
${idea.mockups.length > 0 ? `<span title="Visual Mockups"> • 🎨${idea.mockups.length}</span>` : ""}
${(idea.attachments || []).length > 0 ? `<span title="External Attachments"> • 📎${idea.attachments.length}</span>` : ""}
</div>
<div class="card-quick-actions">
<button type="button" class="icon-btn btn-card-clip" data-idea-id="${idea.id}" title="Attach External Note or Image">📎</button>
<button type="button" class="icon-btn btn-card-mockup" data-idea-id="${idea.id}" title="Open in Mockup Studio">🎨</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-clip").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openExternalClipModal({ ideaId: Number(btn.dataset.ideaId) });
});
});
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 Twist in Image Mockup 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">💡 Prompt Twist</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 & IMAGE MOCKUP STUDIO ---
function detectDefaultProductTemplate(idea) {
if (!idea) return "app-ui";
const textBlob = [
idea.title || "",
idea.elevator_pitch || "",
...(idea.tags || [])
].join(" ");
if (/\b(left[- ]chest|pocket\s+tee|pocket\s+emblem)\b/i.test(textBlob)) {
return "tshirt-left-chest";
}
if (/\b(t-?shirt|tee\b|shirt|apparel|crewneck|garment|hoodie|merch|wearable)\b/i.test(textBlob)) {
return "tshirt-front";
}
const latestMockupTemplate = idea.mockups?.[0]?.template;
if (latestMockupTemplate === "tshirt-front" || latestMockupTemplate === "tshirt-left-chest") {
return latestMockupTemplate;
}
if (/\b(sticker|decal|patch|badge)\b/i.test(textBlob) || latestMockupTemplate === "sticker") {
return "sticker";
}
if (/\b(poster|print|wall\s*art|framed)\b/i.test(textBlob) || latestMockupTemplate === "poster") {
return "poster";
}
if (idea.category === "creative") {
return "isolated-graphic";
}
return "app-ui";
}
function isCurrentProductTShirt() {
const templateSel = document.getElementById("nb-template-select");
const templateVal = templateSel ? templateSel.value : detectDefaultProductTemplate(state.activeIdea);
return templateVal === "tshirt-front" || templateVal === "tshirt-left-chest";
}
function syncTShirtGeneratorVisibility() {
const isTShirt = isCurrentProductTShirt();
const tshirtCard = document.getElementById("tshirt-compositor-card");
const garmentColorGroup = document.getElementById("nb-garment-color-group");
if (tshirtCard) {
tshirtCard.classList.toggle("hidden", !isTShirt);
}
if (garmentColorGroup) {
garmentColorGroup.classList.toggle("hidden", !isTShirt);
}
if (isTShirt) {
const surfaceSel = document.getElementById("comp-surface-select");
if (surfaceSel) surfaceSel.value = "tshirt";
const templateVal = document.getElementById("nb-template-select")?.value;
const placementSel = document.getElementById("comp-placement-select");
if (placementSel && templateVal === "tshirt-left-chest") {
placementSel.value = "left-chest";
} else if (placementSel && templateVal === "tshirt-front" && placementSel.value === "left-chest") {
placementSel.value = "center-chest";
}
renderCompositorCanvas();
}
}
async function openIdeaDrawer(id, initialTab = "details") {
const data = await apiFetch(`/api/ideas/${id}`);
state.activeIdeaId = id;
state.activeIdea = data.idea;
state.nbEditParentMockup = null;
const templateSel = document.getElementById("nb-template-select");
if (templateSel) {
templateSel.value = detectDefaultProductTemplate(state.activeIdea);
}
syncTShirtGeneratorVisibility();
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 = state.activeIdea.title.slice(0, 26).toUpperCase();
}
if (bottomInput) {
bottomInput.value = state.activeIdea.tags[0] ? `#${state.activeIdea.tags[0].toUpperCase()}` : "SPARKVAULT STUDIO";
}
if (isCurrentProductTShirt()) {
renderCompositorCanvas();
}
if (initialTab === "whack" && !state.drawerWhackCard) {
await drawCardForActiveIdea();
}
}
function closeIdeaDrawer() {
state.activeIdeaId = null;
state.activeIdea = null;
document.getElementById("drawer-name-suggestions-panel")?.classList.add("hidden");
document.getElementById("drawer-backdrop").classList.add("hidden");
document.getElementById("idea-drawer").classList.add("hidden");
}
async function requestNameSuggestionsForActiveIdea() {
if (!state.activeIdeaId || !state.activeIdea) return;
const panel = document.getElementById("drawer-name-suggestions-panel");
const btn = document.getElementById("btn-suggest-names");
if (!panel) return;
panel.classList.remove("hidden");
panel.innerHTML = `<div class="muted-label" style="padding:0.4rem;">✨ Brainstorming 6 creative names & taglines for "${escapeHtml(state.activeIdea.title)}"...</div>`;
if (btn) btn.disabled = true;
try {
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/suggest-names`, {
method: "POST"
});
const list = res.suggestions || [];
panel.innerHTML = `
<div class="name-suggestions-header">
<span>✨ <strong>Suggested Names & Taglines</strong> <small class="muted-label">(${escapeHtml(res.generatorUsed || "AI")})</small></span>
<div style="display:flex;gap:0.35rem;">
<button type="button" id="btn-resuggest-names" class="btn btn-ghost btn-xs">🔄 More</button>
<button type="button" id="btn-close-name-suggestions" class="btn btn-ghost btn-xs">✕</button>
</div>
</div>
<div class="name-suggestions-grid">
${list
.map(
(s, idx) => `
<div class="name-suggestion-card" data-idx="${idx}">
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.35rem;">
<strong style="font-size:0.86rem;color:var(--text-primary);">${escapeHtml(s.name)}</strong>
<span class="tag-chip">${escapeHtml(s.style || "Name")}</span>
</div>
${s.tagline ? `<small class="muted-label" style="display:block;">${escapeHtml(s.tagline)}</small>` : ""}
<div style="display:flex;gap:0.35rem;margin-top:0.25rem;flex-wrap:wrap;">
<button type="button" class="btn btn-primary btn-xs btn-use-suggested-title" data-name="${escapeHtml(s.name)}" data-tagline="${escapeHtml(s.tagline || "")}">
✓ Use Title
</button>
${
isCurrentProductTShirt()
? `<button type="button" class="btn btn-ghost btn-xs btn-use-suggested-tee" data-name="${escapeHtml(s.name)}" data-tagline="${escapeHtml(s.tagline || "")}">
👕 Send to Tee Canvas
</button>`
: ""
}
</div>
</div>
`
)
.join("")}
</div>
`;
panel.querySelector("#btn-resuggest-names")?.addEventListener("click", requestNameSuggestionsForActiveIdea);
panel.querySelector("#btn-close-name-suggestions")?.addEventListener("click", () => {
panel.classList.add("hidden");
});
panel.querySelectorAll(".btn-use-suggested-title").forEach((b) => {
b.addEventListener("click", async () => {
const newTitle = b.dataset.name;
const tagline = b.dataset.tagline;
const patch = { title: newTitle };
if (tagline && !state.activeIdea.elevator_pitch) {
patch.elevator_pitch = tagline;
}
document.getElementById("drawer-title-input").value = newTitle;
await saveActiveIdeaPatch(patch);
showToast(`✨ Renamed Spark to "${newTitle}"!`);
});
});
panel.querySelectorAll(".btn-use-suggested-tee").forEach((b) => {
b.addEventListener("click", () => {
const topEl = document.getElementById("comp-top-text");
const botEl = document.getElementById("comp-bottom-text");
if (topEl) topEl.value = b.dataset.name.toUpperCase();
if (botEl && b.dataset.tagline) botEl.value = b.dataset.tagline.toUpperCase();
switchDrawerTab("mockup");
renderCompositorCanvas();
showToast("👕 Loaded name & tagline onto the T-Shirt Compositor!");
});
});
} catch (err) {
panel.innerHTML = `<div class="muted-label">⚠️ ${escapeHtml(err.message)}</div>`;
} finally {
if (btn) btn.disabled = false;
}
}
function switchDrawerTab(tabName) {
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 });
});
});
// Main Spark Thumbnail on Details Tab
renderDrawerMainThumbnail(idea);
// Mockup Gallery
renderDrawerMockupGallery(idea);
// External Attachments
renderDrawerAttachments(idea);
// Whack History
renderDrawerWhackHistory(idea);
}
function renderDrawerMainThumbnail(idea) {
const box = document.getElementById("drawer-main-thumb-box");
if (!box || !idea) return;
const coverUrl = idea.cover_mockup_url || idea.mockups?.[0]?.imageUrl || "";
const mockups = idea.mockups || [];
if (coverUrl) {
box.innerHTML = `
<div class="spark-main-thumb-card">
<img
src="${escapeHtml(coverUrl)}"
alt="${escapeHtml(idea.title)}"
class="spark-main-thumb-img"
id="spark-main-cover-img"
title="Click to view full-size mockup"
/>
${
mockups.length > 1
? `<div class="spark-main-thumb-strip">
${mockups
.slice(0, 5)
.map(
(m) => `
<img
src="${escapeHtml(m.imageUrl)}"
alt="${escapeHtml(m.title)}"
class="spark-mini-thumb ${m.imageUrl === coverUrl ? "active" : ""}"
data-mk-id="${escapeHtml(m.id)}"
data-mk-url="${escapeHtml(m.imageUrl)}"
title="${escapeHtml(m.title)} (Click to set as cover)"
/>
`
)
.join("")}
</div>`
: ""
}
<div class="spark-main-thumb-actions">
<button type="button" class="btn btn-secondary btn-xs" id="btn-main-open-studio">
🖼️ Mockup Studio (${mockups.length})
</button>
<button type="button" class="btn btn-ghost btn-xs" id="btn-main-zoom-cover">
🔍 Zoom
</button>
</div>
</div>
`;
box.querySelector("#spark-main-cover-img")?.addEventListener("click", () => {
openImageLightbox(coverUrl, idea.title);
});
box.querySelector("#btn-main-zoom-cover")?.addEventListener("click", () => {
openImageLightbox(coverUrl, idea.title);
});
box.querySelector("#btn-main-open-studio")?.addEventListener("click", () => {
switchDrawerTab("mockup");
});
box.querySelectorAll(".spark-mini-thumb").forEach((thumb) => {
thumb.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${idea.id}/mockups/${thumb.dataset.mkId}/cover`, {
method: "PATCH"
});
await loadIdeas();
});
});
} else {
box.innerHTML = `
<div class="spark-main-thumb-card spark-main-thumb-empty">
<div class="spark-thumb-placeholder-icon">🖼️</div>
<span class="muted-label">No mockup image yet</span>
<button type="button" class="btn btn-secondary btn-xs" id="btn-main-generate-first-mockup">
✨ Create in Mockup Studio
</button>
</div>
`;
box.querySelector("#btn-main-generate-first-mockup")?.addEventListener("click", () => {
switchDrawerTab("mockup");
});
}
}
function renderDrawerAttachments(idea) {
const countEl = document.getElementById("drawer-attachments-count");
const listEl = document.getElementById("drawer-attachments-list");
const attachments = idea.attachments || [];
if (countEl) countEl.textContent = attachments.length;
if (!listEl) return;
if (attachments.length === 0) {
listEl.innerHTML = `<p class="muted-label">No external notes or reference images attached yet. Drop a file, paste a screenshot (Ctrl+V), or enter an external URL above.</p>`;
return;
}
listEl.innerHTML = attachments
.map((att) => {
const isCover = att.imageUrl && idea.cover_mockup_url === att.imageUrl;
return `
<div class="attachment-card ${isCover ? "is-cover" : ""}">
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.4rem;">
<strong style="font-size:0.84rem;">${att.type === "image" ? "🖼️" : "📝"} ${escapeHtml(att.title)}</strong>
<span class="muted-label">${new Date(att.createdAt).toLocaleDateString()}</span>
</div>
${
att.imageUrl
? `<img src="${escapeHtml(att.imageUrl)}" alt="${escapeHtml(att.title)}" class="attachment-thumb" data-zoom-url="${escapeHtml(att.imageUrl)}" />`
: ""
}
${
att.textContent
? `<p style="font-size:0.82rem;color:var(--text-secondary);white-space:pre-wrap;">${escapeHtml(att.textContent)}</p>`
: ""
}
${
att.sourceUrl
? `<a href="${escapeHtml(att.sourceUrl)}" target="_blank" rel="noopener noreferrer" style="font-size:0.76rem;color:var(--accent-primary);word-break:break-all;">🔗 ${escapeHtml(att.sourceUrl)}</a>`
: ""
}
<div class="mockup-gallery-actions">
${
att.imageUrl
? `
<button type="button" class="btn btn-primary btn-xs btn-att-to-mockup" data-att-id="${escapeHtml(att.id)}">
🎨 Send to Mockup Studio
</button>
${
isCurrentProductTShirt()
? `<button type="button" class="btn btn-ghost btn-xs btn-att-to-canvas" data-att-url="${escapeHtml(att.imageUrl)}">
👕 Tee Canvas
</button>`
: ""
}
${
!isCover
? `<button type="button" class="btn btn-ghost btn-xs btn-att-cover" data-att-id="${escapeHtml(att.id)}">⭐ Cover</button>`
: `<span class="tag-chip" style="color:var(--accent-primary);">★ Cover</span>`
}
`
: ""
}
<button type="button" class="btn btn-danger-ghost btn-xs btn-att-delete" data-att-id="${escapeHtml(att.id)}">🗑️</button>
</div>
</div>
`;
})
.join("");
listEl.querySelectorAll("img[data-zoom-url]").forEach((img) => {
img.addEventListener("click", () => openImageLightbox(img.dataset.zoomUrl, img.alt));
});
listEl.querySelectorAll(".btn-att-to-mockup").forEach((btn) => {
btn.addEventListener("click", async () => {
const res = await apiFetch(`/api/ideas/${idea.id}/attachments/${btn.dataset.attId}/promote-mockup`, {
method: "POST"
});
state.activeIdea = res.idea;
await loadIdeas();
switchDrawerTab("mockup");
const promoted = state.activeIdea.mockups[0];
if (promoted) {
state.nbEditParentMockup = promoted;
document.getElementById("nb-edit-mode-box").classList.remove("hidden");
document.getElementById("nb-edit-target-label").textContent = promoted.title;
}
showToast("Promoted reference image to Mockup Studio for AI refinement!");
});
});
listEl.querySelectorAll(".btn-att-to-canvas").forEach((btn) => {
btn.addEventListener("click", () => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
state.uploadedOverlayImage = img;
switchDrawerTab("mockup");
renderCompositorCanvas();
showToast("Placed attached image onto the Interactive T-Shirt Canvas!");
};
img.src = btn.dataset.attUrl;
});
});
listEl.querySelectorAll(".btn-att-cover").forEach((btn) => {
btn.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${idea.id}/attachments/${btn.dataset.attId}/cover`, {
method: "PATCH"
});
showToast("Set attached image as Spark cover!");
await loadIdeas();
});
});
listEl.querySelectorAll(".btn-att-delete").forEach((btn) => {
btn.addEventListener("click", async () => {
await apiFetch(`/api/ideas/${idea.id}/attachments/${btn.dataset.attId}`, {
method: "DELETE"
});
showToast("Removed attachment");
await loadIdeas();
});
});
}
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 in the Image Mockup Studio!</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 Mockup
</button>
${
isCurrentProductTShirt()
? `<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 Image 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 readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function stageDrawerImage(dataUrl) {
state.stagedDrawerAttachmentDataUrl = dataUrl;
const row = document.getElementById("drawer-attach-preview-row");
const img = document.getElementById("drawer-attach-preview-img");
if (dataUrl) {
if (img) img.src = dataUrl;
row?.classList.remove("hidden");
} else {
if (img) img.src = "";
row?.classList.add("hidden");
}
}
function stageClipModalImage(dataUrl) {
state.stagedClipAttachmentDataUrl = dataUrl;
const row = document.getElementById("clip-preview-row");
const img = document.getElementById("clip-preview-img");
if (dataUrl) {
if (img) img.src = dataUrl;
row?.classList.remove("hidden");
} else {
if (img) img.src = "";
row?.classList.add("hidden");
}
}
const DEV_TARGETS = {
antigravity: {
id: "antigravity",
label: "🛸 Google Antigravity",
defaultModel: "gemini-3.8-flash",
models: [
{ id: "gemini-4-argon", label: "Gemini 4 Argon (Medium)" },
{ id: "gemini-3.8-flash", label: "Gemini 3.8 Flash (Medium)" },
{ id: "claude-opus-5.5", label: "Claude Opus 5.5 (Medium)" },
{ id: "claude-sonnet-5.5", label: "Claude Sonnet 5.5 (Medium)" },
{ id: "gpt-oss-120b", label: "GPT-OSS 120B (Medium)" }
]
},
codex: {
id: "codex",
label: "🤖 OpenAI Codex",
defaultModel: "gpt-5.6-sol",
models: [
{ id: "gpt-5.6-sol", label: "GPT-5.6-Sol (Recommended — Active Codex Default)" },
{ id: "gpt-5.3-codex", label: "GPT-5.3-Codex (Specialized Agentic Coding)" },
{ id: "gpt-5.4", label: "GPT-5.4 (Flagship Reasoning & Implementation)" },
{ id: "o3", label: "OpenAI o3 (Deep Architecture & Debugging)" },
{ id: "codex-mini-latest", label: "Codex Mini (Fast Lightweight Execution)" }
]
}
};
async function copyTextToClipboard(text) {
if (!text) return false;
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall through to fallback
}
}
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand("copy");
textArea.remove();
return successful;
} catch {
return false;
}
}
let activeDevTarget = "antigravity";
function populateDevModelOptions(targetApp) {
const meta = DEV_TARGETS[targetApp] || DEV_TARGETS.antigravity;
const sel = document.getElementById("dev-model-select");
if (!sel) return;
sel.innerHTML = meta.models
.map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.label)}</option>`)
.join("");
sel.value = meta.defaultModel;
}
async function refreshDevSpecPreview() {
if (!state.activeIdeaId) return;
const model = document.getElementById("dev-model-select")?.value || "";
const includeHomelab = document.getElementById("dev-include-homelab")?.checked !== false;
const textarea = document.getElementById("dev-spec-preview-textarea");
if (!textarea) return;
const params = new URLSearchParams({
target: activeDevTarget,
model,
includeHomelab: String(includeHomelab)
});
try {
const res = await fetch(`/api/ideas/${state.activeIdeaId}/spec.md?${params.toString()}`);
if (res.ok) {
textarea.value = await res.text();
}
} catch (err) {
console.warn("Failed to load spec preview:", err);
}
}
async function openDevHandoffModal(defaultTarget = "antigravity") {
if (!state.activeIdeaId || !state.activeIdea) return;
activeDevTarget = defaultTarget;
const modal = document.getElementById("dev-handoff-modal");
if (!modal) return;
document.querySelectorAll("#dev-target-pills [data-dev-target]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.devTarget === activeDevTarget);
});
populateDevModelOptions(activeDevTarget);
const serverCodexWrap = document.getElementById("dev-server-codex-wrap");
if (serverCodexWrap) {
serverCodexWrap.classList.toggle("hidden", activeDevTarget !== "codex");
}
const submitBtn = document.getElementById("btn-submit-dev-handoff");
if (submitBtn) {
submitBtn.textContent =
activeDevTarget === "codex"
? "🚀 Submit & Open in OpenAI Codex"
: "🚀 Submit & Launch in Antigravity";
}
const homelabCb = document.getElementById("dev-include-homelab");
if (homelabCb) {
homelabCb.checked = state.activeIdea.category === "project" || !isCurrentProductTShirt();
}
const instructionNote = document.getElementById("dev-handoff-instruction-note");
if (instructionNote) {
instructionNote.innerHTML =
activeDevTarget === "codex"
? "🤖 Submitting opens OpenAI Codex with the prompt preloaded and saves the full spec to your workspace."
: "🛸 Submitting switches focus to Antigravity and copies the complete project specification to your clipboard. Simply press <kbd>Ctrl+V</kbd> in Antigravity's chat to start coding.";
}
document.getElementById("dev-handoff-result-box")?.classList.add("hidden");
modal.classList.remove("hidden");
await refreshDevSpecPreview();
}
function openExternalClipModal({ ideaId = null, prefillTitle = "", prefillText = "", prefillUrl = "" } = {}) {
const modal = document.getElementById("external-clip-modal");
const targetSel = document.getElementById("clip-target-idea");
const newIdeaGroup = document.getElementById("clip-new-idea-group");
if (!modal || !targetSel) return;
const activeIdeas = state.ideas.filter((i) => i.status !== "archived");
targetSel.innerHTML =
activeIdeas
.map((i) => `<option value="${i.id}">[${i.status.toUpperCase()}] ${escapeHtml(i.title)}</option>`)
.join("") + `<option value="new">+ ✨ Create New Spark from Clip</option>`;
if (ideaId && activeIdeas.some((i) => i.id === Number(ideaId))) {
targetSel.value = String(ideaId);
newIdeaGroup?.classList.add("hidden");
} else if (state.activeIdeaId && activeIdeas.some((i) => i.id === Number(state.activeIdeaId))) {
targetSel.value = String(state.activeIdeaId);
newIdeaGroup?.classList.add("hidden");
} else if (prefillTitle || activeIdeas.length === 0) {
targetSel.value = "new";
newIdeaGroup?.classList.remove("hidden");
} else {
targetSel.value = String(activeIdeas[0].id);
newIdeaGroup?.classList.add("hidden");
}
document.getElementById("clip-new-idea-title").value = prefillTitle || "";
document.getElementById("clip-title-input").value = prefillTitle || "";
document.getElementById("clip-text-input").value = prefillText || "";
document.getElementById("clip-source-url").value = prefillUrl || "";
document.getElementById("clip-external-img-url").value = "";
stageClipModalImage(null);
modal.classList.remove("hidden");
}
function checkWebShareTargetQueryParams() {
const params = new URLSearchParams(window.location.search);
const clipTitle = params.get("title") || params.get("clipTitle") || "";
const clipText = params.get("text") || params.get("clipText") || "";
const clipUrl = params.get("url") || params.get("clipUrl") || "";
const ideaId = params.get("ideaId") ? Number(params.get("ideaId")) : null;
if (clipTitle || clipText || clipUrl) {
openExternalClipModal({
ideaId,
prefillTitle: clipTitle,
prefillText: clipText,
prefillUrl: clipUrl
});
window.history.replaceState({}, document.title, window.location.pathname);
}
}
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");
document.getElementById("dev-handoff-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
function syncFilterControlsUI() {
const hasActiveFilter =
Boolean(state.filters.search.trim()) ||
state.filters.category !== "all" ||
state.filters.tag !== "all" ||
Boolean(state.filters.hasMockup);
document.getElementById("btn-clear-filters")?.classList.toggle("hidden", !hasActiveFilter);
const mobileBtn = document.getElementById("btn-mobile-filter-toggle");
if (mobileBtn) {
mobileBtn.textContent = hasActiveFilter ? "🔍 Filters • Active" : "🔍 Filters";
}
}
document.getElementById("btn-mobile-filter-toggle")?.addEventListener("click", () => {
const bar = document.getElementById("global-filter-bar");
const isOpen = bar?.classList.toggle("mobile-filters-open");
document.getElementById("btn-mobile-filter-toggle")?.classList.toggle("btn-primary", Boolean(isOpen));
if (isOpen) {
document.getElementById("filter-search")?.focus();
}
});
document.getElementById("btn-clear-filters")?.addEventListener("click", () => {
state.filters.search = "";
state.filters.category = "all";
state.filters.tag = "all";
state.filters.hasMockup = false;
const searchEl = document.getElementById("filter-search");
const catEl = document.getElementById("filter-category");
const tagEl = document.getElementById("filter-tag");
const mkEl = document.getElementById("filter-has-mockup");
if (searchEl) searchEl.value = "";
if (catEl) catEl.value = "all";
if (tagEl) tagEl.value = "all";
if (mkEl) mkEl.checked = false;
syncFilterControlsUI();
loadIdeas();
});
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;
syncFilterControlsUI();
loadIdeas();
});
document.getElementById("filter-category")?.addEventListener("change", (e) => {
state.filters.category = e.target.value;
syncFilterControlsUI();
loadIdeas();
});
document.getElementById("filter-tag")?.addEventListener("change", (e) => {
state.filters.tag = e.target.value;
syncFilterControlsUI();
loadIdeas();
});
document.getElementById("filter-has-mockup")?.addEventListener("change", (e) => {
state.filters.hasMockup = e.target.checked;
syncFilterControlsUI();
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("btn-suggest-names")?.addEventListener("click", requestNameSuggestionsForActiveIdea);
document.getElementById("btn-comp-suggest-names")?.addEventListener("click", requestNameSuggestionsForActiveIdea);
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("nb-template-select")?.addEventListener("change", () => {
syncTShirtGeneratorVisibility();
if (state.activeIdea) {
renderDrawerMockupGallery(state.activeIdea);
renderDrawerAttachments(state.activeIdea);
}
});
document.getElementById("nb-generator-select")?.addEventListener("change", async (e) => {
const chosenModel = e.target.value;
state.settings.nanoBananaModel = chosenModel;
const settingsSel = document.getElementById("settings-model-select");
if (settingsSel) settingsSel.value = chosenModel;
await apiFetch("/api/settings", {
method: "PUT",
body: JSON.stringify({ nanoBananaModel: chosenModel })
});
showToast(`Switched Image Mockup Generator to ${e.target.options[e.target.selectedIndex].text.split("—")[0].trim()}`);
});
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 selectedModel =
document.getElementById("nb-generator-select")?.value ||
state.settings.nanoBananaModel ||
"codex-image-gen";
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 =
selectedModel === "codex-image-gen"
? "🤖 Generating high-res PNG via OpenAI Codex (takes ~15–25s)..."
: `✨ Generating mockup via ${selectedModel}...`;
try {
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/mockups/nano-banana`, {
method: "POST",
body: JSON.stringify({
model: selectedModel,
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 mockup (${res.mockup.modelUsed}) to gallery!`;
showToast("🖼️ Generated & saved your new 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 bgInput = document.getElementById("settings-user-background");
const payload = {
nanoBananaModel: modelSel.value,
userBackground: bgInput ? bgInput.value.trim() : undefined
};
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 SparkVault & AI Generator settings!");
await loadSettings();
});
// Personalized Random Idea Button
document.getElementById("btn-generate-random-idea")?.addEventListener("click", async () => {
const btn = document.getElementById("btn-generate-random-idea");
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = "🎲 Generating...";
showToast("🎲 Inventing a fresh idea tailored to your background...");
try {
const catSel = document.getElementById("quick-capture-category")?.value || "any";
const res = await apiFetch("/api/ideas/generate-random", {
method: "POST",
body: JSON.stringify({
category: catSel === "spark" ? "any" : catSel
})
});
await Promise.all([loadIdeas(), loadWhackCatalog()]);
showToast(`✨ Created "${res.idea.title}"!`);
openIdeaDrawer(res.idea.id, "details");
} catch (err) {
showToast(`⚠️ ${err.message}`);
} finally {
btn.disabled = false;
btn.innerHTML = origText;
}
});
// Lightbox Close
document.getElementById("btn-close-lightbox")?.addEventListener("click", () => {
document.getElementById("lightbox-modal")?.classList.add("hidden");
});
// --- EXTERNAL ATTACHMENT & CLIPBOARD PASTE / DROP EVENTS ---
document.getElementById("btn-open-external-clip")?.addEventListener("click", () => {
openExternalClipModal();
});
document.getElementById("btn-close-clip-modal")?.addEventListener("click", () => {
document.getElementById("external-clip-modal")?.classList.add("hidden");
});
document.getElementById("btn-cancel-clip-modal")?.addEventListener("click", () => {
document.getElementById("external-clip-modal")?.classList.add("hidden");
});
document.getElementById("clip-target-idea")?.addEventListener("change", (e) => {
document.getElementById("clip-new-idea-group")?.classList.toggle("hidden", e.target.value !== "new");
});
// File input & Drag-and-Drop in Drawer
document.getElementById("drawer-attach-file-input")?.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const dataUrl = await readFileAsDataUrl(file);
stageDrawerImage(dataUrl);
const titleEl = document.getElementById("drawer-attach-title");
if (titleEl && !titleEl.value) titleEl.value = file.name.replace(/\.[^.]+$/, "");
});
document.getElementById("btn-drawer-clear-staged-img")?.addEventListener("click", () => {
stageDrawerImage(null);
});
const drawerDropzone = document.getElementById("drawer-attachment-dropzone");
if (drawerDropzone) {
drawerDropzone.addEventListener("dragover", (e) => {
e.preventDefault();
drawerDropzone.classList.add("drag-over");
});
drawerDropzone.addEventListener("dragleave", () => drawerDropzone.classList.remove("drag-over"));
drawerDropzone.addEventListener("drop", async (e) => {
e.preventDefault();
drawerDropzone.classList.remove("drag-over");
const file = e.dataTransfer?.files?.[0];
if (file && file.type.startsWith("image/")) {
const dataUrl = await readFileAsDataUrl(file);
stageDrawerImage(dataUrl);
const titleEl = document.getElementById("drawer-attach-title");
if (titleEl && !titleEl.value) titleEl.value = file.name.replace(/\.[^.]+$/, "");
}
});
}
document.getElementById("btn-drawer-save-attachment")?.addEventListener("click", async () => {
if (!state.activeIdeaId) return;
const title = document.getElementById("drawer-attach-title")?.value.trim() || "";
const urlInput = document.getElementById("drawer-attach-url")?.value.trim() || "";
const textContent = document.getElementById("drawer-attach-text")?.value.trim() || "";
const appendToNotes = document.getElementById("drawer-attach-append-notes")?.checked || false;
const setAsCover = document.getElementById("drawer-attach-set-cover")?.checked || false;
const isImageExt = /\.(png|jpe?g|webp|gif|svg)(\?.*)?$/i.test(urlInput);
const payload = {
title,
textContent,
appendToNotes,
setAsCover,
imageDataUrl: state.stagedDrawerAttachmentDataUrl || "",
externalImageUrl: !state.stagedDrawerAttachmentDataUrl && isImageExt ? urlInput : "",
sourceUrl: urlInput
};
try {
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/attachments`, {
method: "POST",
body: JSON.stringify(payload)
});
state.activeIdea = res.idea;
document.getElementById("drawer-attach-title").value = "";
document.getElementById("drawer-attach-url").value = "";
document.getElementById("drawer-attach-text").value = "";
stageDrawerImage(null);
showToast("📎 Attached to Spark!");
await loadIdeas();
} catch (err) {
showToast(err.message);
}
});
// File input & Drag-and-Drop in External Clip Modal
document.getElementById("clip-file-input")?.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const dataUrl = await readFileAsDataUrl(file);
stageClipModalImage(dataUrl);
const titleEl = document.getElementById("clip-title-input");
if (titleEl && !titleEl.value) titleEl.value = file.name.replace(/\.[^.]+$/, "");
});
document.getElementById("btn-clip-clear-img")?.addEventListener("click", () => {
stageClipModalImage(null);
});
const clipDropzone = document.getElementById("clip-dropzone");
if (clipDropzone) {
clipDropzone.addEventListener("dragover", (e) => {
e.preventDefault();
clipDropzone.classList.add("drag-over");
});
clipDropzone.addEventListener("dragleave", () => clipDropzone.classList.remove("drag-over"));
clipDropzone.addEventListener("drop", async (e) => {
e.preventDefault();
clipDropzone.classList.remove("drag-over");
const file = e.dataTransfer?.files?.[0];
if (file && file.type.startsWith("image/")) {
const dataUrl = await readFileAsDataUrl(file);
stageClipModalImage(dataUrl);
}
});
}
document.getElementById("external-clip-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const targetVal = document.getElementById("clip-target-idea").value;
const newIdeaTitle = document.getElementById("clip-new-idea-title").value.trim();
const title = document.getElementById("clip-title-input").value.trim();
const externalImageUrl = document.getElementById("clip-external-img-url").value.trim();
const textContent = document.getElementById("clip-text-input").value.trim();
const sourceUrl = document.getElementById("clip-source-url").value.trim();
const appendToNotes = document.getElementById("clip-append-notes").checked;
const setAsCover = document.getElementById("clip-set-cover").checked;
try {
let targetIdeaId = targetVal === "new" ? null : Number(targetVal);
if (!targetIdeaId) {
const created = await apiFetch("/api/ideas", {
method: "POST",
body: JSON.stringify({
title: newIdeaTitle || title || "Clipped External Spark",
category: "spark"
})
});
targetIdeaId = created.idea.id;
}
await apiFetch(`/api/ideas/${targetIdeaId}/attachments`, {
method: "POST",
body: JSON.stringify({
title: title || "External Clip",
imageDataUrl: state.stagedClipAttachmentDataUrl || "",
externalImageUrl,
textContent,
sourceUrl,
appendToNotes,
setAsCover
})
});
document.getElementById("external-clip-modal")?.classList.add("hidden");
stageClipModalImage(null);
showToast("📎 Attached external note/image to Spark!");
await loadIdeas();
} catch (err) {
showToast(err.message);
}
});
// --- SUBMIT TO ANTIGRAVITY OR CODEX FOR DEVELOPMENT EVENTS ---
document.getElementById("btn-open-dev-handoff")?.addEventListener("click", () => {
openDevHandoffModal(activeDevTarget || "antigravity");
});
document.getElementById("btn-close-dev-modal")?.addEventListener("click", () => {
document.getElementById("dev-handoff-modal")?.classList.add("hidden");
});
document.getElementById("btn-cancel-dev-modal")?.addEventListener("click", () => {
document.getElementById("dev-handoff-modal")?.classList.add("hidden");
});
document.querySelectorAll("#dev-target-pills [data-dev-target]").forEach((btn) => {
btn.addEventListener("click", async () => {
activeDevTarget = btn.dataset.devTarget;
document.querySelectorAll("#dev-target-pills [data-dev-target]").forEach((b) =>
b.classList.toggle("active", b === btn)
);
populateDevModelOptions(activeDevTarget);
document.getElementById("dev-server-codex-wrap")?.classList.toggle("hidden", activeDevTarget !== "codex");
const submitBtn = document.getElementById("btn-submit-dev-handoff");
if (submitBtn) {
submitBtn.textContent =
activeDevTarget === "codex"
? "🚀 Submit & Open in OpenAI Codex"
: "🚀 Submit & Launch in Antigravity";
}
const instructionNote = document.getElementById("dev-handoff-instruction-note");
if (instructionNote) {
instructionNote.innerHTML =
activeDevTarget === "codex"
? "🤖 Submitting opens OpenAI Codex with the prompt preloaded and saves the full spec to your workspace."
: "🛸 Submitting switches focus to Antigravity and copies the complete project specification to your clipboard. Simply press <kbd>Ctrl+V</kbd> in Antigravity's chat to start coding.";
}
await refreshDevSpecPreview();
});
});
document.getElementById("dev-model-select")?.addEventListener("change", refreshDevSpecPreview);
document.getElementById("dev-include-homelab")?.addEventListener("change", refreshDevSpecPreview);
document.getElementById("btn-copy-dev-spec")?.addEventListener("click", async () => {
const text = document.getElementById("dev-spec-preview-textarea")?.value || "";
if (!text) return;
const ok = await copyTextToClipboard(text);
showToast(ok ? "📋 Copied full development specification to clipboard!" : "⚠️ Unable to auto-copy; please copy text manually.");
});
document.getElementById("btn-download-dev-spec")?.addEventListener("click", () => {
if (!state.activeIdeaId) return;
const model = document.getElementById("dev-model-select")?.value || "";
const includeHomelab = document.getElementById("dev-include-homelab")?.checked !== false;
window.open(
`/api/ideas/${state.activeIdeaId}/spec.md?target=${encodeURIComponent(activeDevTarget)}&model=${encodeURIComponent(model)}&includeHomelab=${includeHomelab}`,
"_blank"
);
});
document.getElementById("dev-handoff-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
if (!state.activeIdeaId) return;
const submitBtn = document.getElementById("btn-submit-dev-handoff");
const resultBox = document.getElementById("dev-handoff-result-box");
const model = document.getElementById("dev-model-select")?.value || "";
const customPrompt = document.getElementById("dev-spec-preview-textarea")?.value || "";
const includeHomelab = document.getElementById("dev-include-homelab")?.checked !== false;
const moveStatusToBuilding = document.getElementById("dev-move-building")?.checked !== false;
const executeServerCodex =
activeDevTarget === "codex" && Boolean(document.getElementById("dev-run-server-codex")?.checked);
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = executeServerCodex
? "🤖 Running Codex Blueprint & Submitting..."
: "🚀 Packaging & Launching...";
}
try {
// 1. Copy full specification to clipboard so it's immediately ready to paste (Ctrl+V) in Antigravity or Codex
await copyTextToClipboard(customPrompt);
// 2. Persist handoff package & workspace on server
const res = await apiFetch(`/api/ideas/${state.activeIdeaId}/dev-handoff`, {
method: "POST",
body: JSON.stringify({
targetApp: activeDevTarget,
model,
customPrompt,
includeHomelab,
moveStatusToBuilding,
executeServerCodex
})
});
state.activeIdea = res.idea;
const handoff = res.handoff;
if (resultBox) {
resultBox.classList.remove("hidden");
const modelName = handoff.modelLabel || handoff.model;
if (activeDevTarget === "antigravity") {
resultBox.innerHTML = `
<div style="background:linear-gradient(135deg, rgba(76,140,245,0.12), rgba(52,199,89,0.12)); border:1px solid rgba(76,140,245,0.35); border-radius:8px; padding:0.85rem; margin-top:0.4rem;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<div style="font-weight:700;font-size:0.95rem;display:flex;align-items:center;gap:0.4rem;">
<span>🛸</span> Switched to Google Antigravity!
</div>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;">
<button type="button" id="btn-copy-dev-spec-again" class="btn btn-secondary btn-xs">
📋 Copy Spec Again
</button>
<a href="${escapeHtml(handoff.deepLinkUrl)}" class="btn btn-primary btn-xs">
🛸 Re-Focus Antigravity
</a>
<a href="${escapeHtml(handoff.specUrl)}" target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-xs">
📄 View SPEC.md
</a>
</div>
</div>
<div style="margin-top:0.6rem;padding:0.65rem 0.8rem;background:var(--bg-card);border-radius:6px;border:1px dashed var(--border-color);font-size:0.85rem;line-height:1.5;">
<div style="color:var(--text-main);font-weight:600;">
👉 Ready to develop with <strong>${escapeHtml(modelName)}</strong>!
</div>
<div style="margin-top:0.25rem;color:var(--text-secondary);">
The full specification for <strong>${escapeHtml(state.activeIdea.title)}</strong> has been loaded onto your clipboard! Since Antigravity requires starting or selecting your conversation, simply click into Antigravity's chat bar and press <kbd style="background:var(--bg-base);padding:2px 6px;border-radius:4px;border:1px solid var(--border-color);font-weight:700;">Ctrl+V</kbd> (or <kbd style="background:var(--bg-base);padding:2px 6px;border-radius:4px;border:1px solid var(--border-color);font-weight:700;">Cmd+V</kbd>) to paste the prompt and begin.
</div>
</div>
</div>
`;
document.getElementById("btn-copy-dev-spec-again")?.addEventListener("click", async () => {
const reCopied = await copyTextToClipboard(customPrompt);
showToast(reCopied ? "📋 Specification copied to clipboard! Press Ctrl+V in Antigravity." : "⚠️ Please copy manually from the preview below.");
});
} else {
resultBox.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<strong>✅ Ready in ${escapeHtml(handoff.targetLabel)} (${escapeHtml(handoff.model)})</strong>
<div style="display:flex;gap:0.4rem;">
<a href="${escapeHtml(handoff.deepLinkUrl)}" class="btn btn-primary btn-xs">
🚀 Open Codex App
</a>
<a href="${escapeHtml(handoff.specUrl)}" target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-xs">
📄 View SPEC.md
</a>
</div>
</div>
<p class="muted-label" style="margin-top:0.25rem;">
Full specification has been <strong>copied to your clipboard (<kbd>Ctrl+V</kbd>)</strong> and saved to server workspace <code>${escapeHtml(handoff.slug)}</code>.
</p>
${
handoff.codexExecution?.output
? `<pre style="max-height:160px;overflow:auto;background:var(--bg-base);padding:0.5rem;border-radius:6px;font-size:0.75rem;white-space:pre-wrap;margin-top:0.35rem;">${escapeHtml(handoff.codexExecution.output)}</pre>`
: ""
}
`;
}
}
showToast(`🛸 Copied spec & launching Antigravity! Paste with Ctrl+V.`);
await loadIdeas();
renderDrawerContent();
// 3. Trigger the OS deep link to open Antigravity or Codex Desktop App
if (handoff.deepLinkUrl) {
window.location.href = handoff.deepLinkUrl;
}
} catch (err) {
showToast(`⚠️ ${err.message}`);
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent =
activeDevTarget === "codex"
? "🚀 Submit & Open in OpenAI Codex"
: "🚀 Submit & Launch in Antigravity";
}
}
});
// Global Clipboard Paste (Ctrl+V) for screenshots & copied images
window.addEventListener("paste", async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file) continue;
e.preventDefault();
const dataUrl = await readFileAsDataUrl(file);
const clipModalOpen = !document.getElementById("external-clip-modal")?.classList.contains("hidden");
const drawerOpen = !document.getElementById("idea-drawer")?.classList.contains("hidden");
if (clipModalOpen) {
stageClipModalImage(dataUrl);
showToast("📋 Pasted screenshot into Clip dialog!");
} else if (drawerOpen) {
stageDrawerImage(dataUrl);
switchDrawerTab("details");
showToast("📋 Pasted screenshot into Spark Workbench — click 'Attach to Spark' to save!");
} else {
openExternalClipModal();
stageClipModalImage(dataUrl);
showToast("📋 Pasted screenshot — pick a Spark to attach it to!");
}
break;
}
}
});
}
initApp();