feat(attachments): attach external notes, screenshots, photos, and image URLs to sparks

This commit is contained in:
airent106 committed 2026-10-29 11:55:02 -06:00
1 parent 8a25771839
commit 4c5560a5a1
6 files changed
+888 -5

No files matched your search

+414
View File
@@ -39,6 +39,8 @@ const state = {
nbGarmentColorName: "Vintage Cream",
nbEditParentMockup: null,
uploadedOverlayImage: null,
stagedDrawerAttachmentDataUrl: null,
stagedClipAttachmentDataUrl: null,
settings: {
hasApiKey: false,
apiKeySource: "none",
@@ -122,6 +124,7 @@ async function initApp() {
bindGlobalEvents();
await Promise.all([loadSettings(), loadWhackCatalog(), loadIdeas()]);
renderCompositorCanvas();
checkWebShareTargetQueryParams();
}
async function loadSettings() {
@@ -263,8 +266,10 @@ function renderIdeaCardHtml(idea) {
${checklistTotal > 0 ? `<span> • ☑️ ${checklistDone}/${checklistTotal}</span>` : ""}
${whackCount > 0 ? `<span title="Whack Pack reflections"> • 🃏${whackCount}</span>` : ""}
${idea.mockups.length > 0 ? `<span title="Visual Mockups"> • 🍌${idea.mockups.length}</span>` : ""}
${(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 Nano Banana Mockup Studio">🍌</button>
<button type="button" class="icon-btn btn-card-whack" data-idea-id="${idea.id}" title="Draw a Whack Card for this Idea">🃏</button>
${
@@ -292,6 +297,13 @@ function bindIdeaCardEvents(container) {
});
});
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();
@@ -910,10 +922,131 @@ function renderDrawerContent() {
// Mockup Gallery
renderDrawerMockupGallery(idea);
// External Attachments
renderDrawerAttachments(idea);
// Whack History
renderDrawerWhackHistory(idea);
}
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>
<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;
@@ -1420,6 +1553,95 @@ function openImageLightbox(url, title = "Mockup Preview") {
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");
}
}
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) => {
@@ -1937,6 +2159,198 @@ function bindGlobalEvents() {
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);
}
});
// 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();