From 4c5560a5a1bf32a279658fe75305480f8b18d346 Mon Sep 17 00:00:00 2001 From: airent106 Date: Thu, 29 Oct 2026 11:55:02 -0600 Subject: [PATCH] feat(attachments): attach external notes, screenshots, photos, and image URLs to sparks --- public/app.js | 414 ++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 133 +++++++++++++++ public/styles.css | 85 ++++++++++ server/db.js | 63 ++++++- server/index.js | 164 ++++++++++++++++++ test/api.test.js | 34 ++++ 6 files changed, 888 insertions(+), 5 deletions(-) diff --git a/public/app.js b/public/app.js index 31c32e0..e566407 100644 --- a/public/app.js +++ b/public/app.js @@ -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 ? ` • ☑️ ${checklistDone}/${checklistTotal}` : ""} ${whackCount > 0 ? ` • 🃏${whackCount}` : ""} ${idea.mockups.length > 0 ? ` • 🍌${idea.mockups.length}` : ""} + ${(idea.attachments || []).length > 0 ? ` • 📎${idea.attachments.length}` : ""}
+ ${ @@ -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 = `

No external notes or reference images attached yet. Drop a file, paste a screenshot (Ctrl+V), or enter an external URL above.

`; + return; + } + + listEl.innerHTML = attachments + .map((att) => { + const isCover = att.imageUrl && idea.cover_mockup_url === att.imageUrl; + return ` +
+
+ ${att.type === "image" ? "🖼️" : "📝"} ${escapeHtml(att.title)} + ${new Date(att.createdAt).toLocaleDateString()} +
+ ${ + att.imageUrl + ? `${escapeHtml(att.title)}` + : "" + } + ${ + att.textContent + ? `

${escapeHtml(att.textContent)}

` + : "" + } + ${ + att.sourceUrl + ? `🔗 ${escapeHtml(att.sourceUrl)}` + : "" + } + +
+ `; + }) + .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) => ``) + .join("") + ``; + + 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(); diff --git a/public/index.html b/public/index.html index 2f0be37..8bab07d 100644 --- a/public/index.html +++ b/public/index.html @@ -37,6 +37,14 @@ +
+ + +
+
+ + Paste (Ctrl+V), drop files, or clip from URL +
+ +
+
+ 📥 Drop image here, press Ctrl+V to paste a screenshot, or attach a note/URL below: + +
+ + + +
+ + +
+ +
+
+ + +
+ +
+
+ +
+
@@ -700,6 +758,81 @@ + + +