3031 lines
116 KiB
JavaScript
3031 lines
116 KiB
JavaScript
// SparkVault — Frontend Application Logic
|
||
// Pipeline Board, Rich Workbench, 64-Card Creative Whack Pack & Nano Banana 2.1 Mockup Studio
|
||
|
||
const state = {
|
||
ideas: [],
|
||
stats: { total: 0, statusCounts: {}, totalMockups: 0, totalWhacks: 0, tags: [] },
|
||
whackCards: [],
|
||
whackSuits: {},
|
||
currentView: "board",
|
||
gridStatusFilter: "all",
|
||
filters: {
|
||
search: "",
|
||
category: "all",
|
||
tag: "all",
|
||
hasMockup: false,
|
||
sort: "score"
|
||
},
|
||
// Whack Studio state
|
||
whackSubmode: "collider",
|
||
colliderCard: null,
|
||
colliderIdea: null,
|
||
spreadCards: [],
|
||
deckSuitFilter: "all",
|
||
deckSearch: "",
|
||
// Quick Banner Whack Card
|
||
quickBannerCard: null,
|
||
// Review & Arena state
|
||
reviewMode: "arena",
|
||
triageQueue: [],
|
||
triageIndex: 0,
|
||
arenaPair: [],
|
||
// Active Drawer Idea
|
||
activeIdeaId: null,
|
||
activeIdea: null,
|
||
drawerTab: "details",
|
||
drawerWhackCard: null,
|
||
// Nano Banana Studio state
|
||
nbGarmentColor: "#F5E8D3",
|
||
nbGarmentColorName: "Vintage Cream",
|
||
nbEditParentMockup: null,
|
||
uploadedOverlayImage: null,
|
||
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, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
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();
|