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();
+133
View File
@@ -37,6 +37,14 @@
<option value="research">🔬 Research</option>
</select>
<button type="submit" class="btn btn-primary btn-sm">Capture</button>
<button
type="button"
id="btn-open-external-clip"
class="btn btn-secondary btn-sm"
title="Attach external notes, clipboard screenshots, photos, or image URLs to a spark"
>
📎 Clip / Attach
</button>
<button
type="button"
id="btn-quick-whack-prompt"
@@ -466,6 +474,56 @@
<button type="submit" class="btn btn-ghost btn-sm">+ Add Link</button>
</form>
</div>
<!-- External Attachments: Clipped Notes, Screenshots & Reference Images -->
<div class="field-group">
<div class="field-header-row">
<label>📎 Attached External Notes & Reference Images (<span id="drawer-attachments-count">0</span>)</label>
<span class="muted-label">Paste (Ctrl+V), drop files, or clip from URL</span>
</div>
<div id="drawer-attachment-dropzone" class="attachment-dropzone">
<div class="dropzone-header">
<span>📥 <strong>Drop image here</strong>, press <kbd>Ctrl+V</kbd> to paste a screenshot, or attach a note/URL below:</span>
<label class="btn btn-secondary btn-xs" style="cursor:pointer;">
📷 Choose Image / Camera
<input type="file" id="drawer-attach-file-input" accept="image/*" hidden />
</label>
</div>
<div id="drawer-attach-preview-row" class="attach-preview-row hidden">
<img id="drawer-attach-preview-img" src="" alt="Staged attachment" />
<div style="flex:1;">
<strong style="font-size:0.82rem;">Staged Image Ready to Attach</strong>
<p class="muted-label">Add an optional caption or note below and click Attach.</p>
</div>
<button type="button" id="btn-drawer-clear-staged-img" class="btn btn-ghost btn-xs">✕ Remove</button>
</div>
<div class="form-row-2">
<input type="text" id="drawer-attach-title" class="text-input input-sm" placeholder="Title / Caption (e.g. Vintage Ski Patch Ref)" />
<input type="url" id="drawer-attach-url" class="text-input input-sm" placeholder="External Image URL or Source Link (https://...)" />
</div>
<textarea id="drawer-attach-text" rows="2" class="textarea-input" placeholder="Paste external note, quote, spec snippet, or commentary..."></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<div style="display:flex;gap:0.85rem;flex-wrap:wrap;">
<label class="filter-checkbox">
<input type="checkbox" id="drawer-attach-append-notes" />
<span>Also append text to Markdown Notes</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="drawer-attach-set-cover" />
<span>Set image as Spark Cover</span>
</label>
</div>
<button type="button" id="btn-drawer-save-attachment" class="btn btn-primary btn-sm">
📎 Attach to Spark
</button>
</div>
</div>
<div id="drawer-attachments-list" class="attachments-grid"></div>
</div>
</div>
<!-- TAB 2: NANO BANANA 2.1 VISUAL MOCKUP STUDIO -->
@@ -700,6 +758,81 @@
</div>
</div>
<!-- MODAL: EXTERNAL CLIPPER / ATTACH TO SPARK -->
<div id="external-clip-modal" class="modal-backdrop hidden">
<div class="modal-card" style="max-width:600px;">
<div class="modal-header">
<h3>📎 Attach External Note or Image to Spark</h3>
<button type="button" id="btn-close-clip-modal" class="btn btn-ghost btn-sm">✕</button>
</div>
<form id="external-clip-form" class="modal-body">
<p class="muted-label">
Clip a screenshot (<kbd>Ctrl+V</kbd> anywhere in this dialog), upload a photo from your phone, fetch an external image URL, or attach notes/links to an existing Spark (or create a new one).
</p>
<div class="field-group">
<label for="clip-target-idea">Target Spark / Idea</label>
<select id="clip-target-idea" class="filter-select full-width"></select>
</div>
<div id="clip-new-idea-group" class="field-group hidden">
<label for="clip-new-idea-title">New Spark Title</label>
<input type="text" id="clip-new-idea-title" class="text-input" placeholder="e.g. Vintage Campfire Enamel Mug #merch @creative" />
</div>
<div class="field-group">
<label for="clip-title-input">Attachment Title / Caption</label>
<input type="text" id="clip-title-input" class="text-input" placeholder="e.g. Field Notes Photo / Competitor Spec / Article Excerpt" />
</div>
<div class="field-group">
<label>Image Source (File / Camera, Clipboard Paste, or Direct Image URL)</label>
<div id="clip-dropzone" class="attachment-dropzone">
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;flex-wrap:wrap;">
<span class="muted-label">📋 Paste image (<kbd>Ctrl+V</kbd>) or drop file here</span>
<label class="btn btn-secondary btn-xs" style="cursor:pointer;">
📷 Select Photo / File
<input type="file" id="clip-file-input" accept="image/*" hidden />
</label>
</div>
<div id="clip-preview-row" class="attach-preview-row hidden">
<img id="clip-preview-img" src="" alt="Clip preview" />
<span class="muted-label" style="flex:1;">Image staged for attachment</span>
<button type="button" id="btn-clip-clear-img" class="btn btn-ghost btn-xs">✕ Clear</button>
</div>
<input type="url" id="clip-external-img-url" class="text-input input-sm" placeholder="Or paste an external image URL (https://example.com/photo.jpg)..." />
</div>
</div>
<div class="field-group">
<label for="clip-text-input">External Note / Excerpt / Markdown</label>
<textarea id="clip-text-input" rows="3" class="textarea-input" placeholder="Paste notes, thoughts, article quotes, or garment/project details..."></textarea>
</div>
<div class="field-group">
<label for="clip-source-url">Source Webpage URL (Optional)</label>
<input type="url" id="clip-source-url" class="text-input input-sm" placeholder="https://..." />
</div>
<div style="display:flex;gap:1rem;flex-wrap:wrap;">
<label class="filter-checkbox">
<input type="checkbox" id="clip-append-notes" checked />
<span>Append note text to Spark's Markdown Notes</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="clip-set-cover" checked />
<span>Set image as Spark cover</span>
</label>
</div>
<div class="modal-footer">
<button type="button" id="btn-cancel-clip-modal" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">📎 Save Attachment to Spark</button>
</div>
</form>
</div>
</div>
<!-- MODAL: CARD / MOCKUP LIGHTBOX INSPECTOR -->
<div id="lightbox-modal" class="modal-backdrop hidden">
<div class="lightbox-card">
+85
View File
@@ -1678,3 +1678,88 @@ body {
}
}
/* EXTERNAL ATTACHMENTS & CLIPPER */
.attachment-dropzone {
background: var(--bg-elevated);
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-md);
padding: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
transition: border-color 0.15s ease, background 0.15s ease;
}
.attachment-dropzone.drag-over {
border-color: var(--accent-primary);
background: rgba(245, 158, 11, 0.08);
}
.dropzone-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.82rem;
color: var(--text-secondary);
}
.attach-preview-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
.attach-preview-row img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.attachments-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
margin-top: 0.35rem;
}
.attachment-card {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.attachment-card.is-cover {
border-color: var(--accent-primary);
}
.attachment-thumb {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: var(--radius-sm);
cursor: zoom-in;
background: #10131b;
}
kbd {
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0.08rem 0.35rem;
font-size: 0.74rem;
font-family: monospace;
}
+58 -5
View File
@@ -107,6 +107,13 @@ export function createDatabase(dbFilePath) {
);
`);
// Ensure attachments_json column exists for existing databases
try {
db.exec(`ALTER TABLE ideas ADD COLUMN attachments_json TEXT DEFAULT '[]'`);
} catch {
// column already exists
}
function safeParse(jsonStr, fallback = []) {
try {
return jsonStr ? JSON.parse(jsonStr) : fallback;
@@ -124,6 +131,7 @@ export function createDatabase(dbFilePath) {
const links = safeParse(row.links_json, []);
const whack_history = safeParse(row.whack_history_json, []);
const mockups = safeParse(row.mockups_json, []);
const attachments = safeParse(row.attachments_json, []);
const inspiredCard = row.inspired_by_card ? getCardByNumber(row.inspired_by_card) : null;
const hydrated = {
@@ -142,7 +150,12 @@ export function createDatabase(dbFilePath) {
inspired_by_card: row.inspired_by_card || null,
inspired_card_detail: inspiredCard,
mockups,
cover_mockup_url: row.cover_mockup_url || (mockups[0]?.imageUrl || ""),
attachments,
cover_mockup_url:
row.cover_mockup_url ||
mockups[0]?.imageUrl ||
attachments.find((a) => a.type === "image")?.imageUrl ||
"",
rating_excitement: Number(row.rating_excitement ?? 3),
rating_impact: Number(row.rating_impact ?? 3),
rating_ease: Number(row.rating_ease ?? 3),
@@ -217,17 +230,22 @@ export function createDatabase(dbFilePath) {
const impact = payload.rating_impact ?? 3;
const ease = payload.rating_ease ?? 3;
const mockups = Array.isArray(payload.mockups) ? payload.mockups : [];
const attachments = Array.isArray(payload.attachments) ? payload.attachments : [];
const whackHistory = Array.isArray(payload.whack_history) ? payload.whack_history : [];
const coverMockupUrl = payload.cover_mockup_url || (mockups[0]?.imageUrl ?? "");
const coverMockupUrl =
payload.cover_mockup_url ||
mockups[0]?.imageUrl ||
attachments.find((a) => a.type === "image")?.imageUrl ||
"";
const stmt = db.prepare(`
INSERT INTO ideas (
title, elevator_pitch, category, status, notes_markdown,
tags_json, checklist_json, pros_json, cons_json, links_json,
whack_history_json, inspired_by_card, mockups_json, cover_mockup_url,
whack_history_json, inspired_by_card, mockups_json, attachments_json, cover_mockup_url,
rating_excitement, rating_impact, rating_ease,
arena_wins, arena_matches, pinned, last_reviewed_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -244,6 +262,7 @@ export function createDatabase(dbFilePath) {
JSON.stringify(whackHistory),
payload.inspired_by_card ? Number(payload.inspired_by_card) : null,
JSON.stringify(mockups),
JSON.stringify(attachments),
coverMockupUrl,
Number(excitement),
Number(impact),
@@ -278,6 +297,7 @@ export function createDatabase(dbFilePath) {
whack_history: Array.isArray(patch.whack_history) ? patch.whack_history : existing.whack_history,
inspired_by_card: patch.inspired_by_card !== undefined ? (patch.inspired_by_card ? Number(patch.inspired_by_card) : null) : existing.inspired_by_card,
mockups: Array.isArray(patch.mockups) ? patch.mockups : existing.mockups,
attachments: Array.isArray(patch.attachments) ? patch.attachments : existing.attachments,
cover_mockup_url: patch.cover_mockup_url !== undefined ? String(patch.cover_mockup_url) : existing.cover_mockup_url,
rating_excitement: patch.rating_excitement !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_excitement))) : existing.rating_excitement,
rating_impact: patch.rating_impact !== undefined ? Math.max(1, Math.min(5, Number(patch.rating_impact))) : existing.rating_impact,
@@ -293,7 +313,7 @@ export function createDatabase(dbFilePath) {
UPDATE ideas SET
title = ?, elevator_pitch = ?, category = ?, status = ?, notes_markdown = ?,
tags_json = ?, checklist_json = ?, pros_json = ?, cons_json = ?, links_json = ?,
whack_history_json = ?, inspired_by_card = ?, mockups_json = ?, cover_mockup_url = ?,
whack_history_json = ?, inspired_by_card = ?, mockups_json = ?, attachments_json = ?, cover_mockup_url = ?,
rating_excitement = ?, rating_impact = ?, rating_ease = ?,
arena_wins = ?, arena_matches = ?, pinned = ?, last_reviewed_at = ?, updated_at = ?
WHERE id = ?
@@ -311,6 +331,7 @@ export function createDatabase(dbFilePath) {
JSON.stringify(updated.whack_history),
updated.inspired_by_card,
JSON.stringify(updated.mockups),
JSON.stringify(updated.attachments),
updated.cover_mockup_url,
updated.rating_excitement,
updated.rating_impact,
@@ -398,6 +419,37 @@ export function createDatabase(dbFilePath) {
});
}
function addAttachmentToIdea(id, attachmentEntry) {
const idea = getIdea(id);
if (!idea) return null;
const entry = {
id: attachmentEntry.id || `att-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: attachmentEntry.type || (attachmentEntry.imageUrl ? "image" : "note"),
title: attachmentEntry.title || (attachmentEntry.imageUrl ? "Attached Image" : "External Note"),
textContent: attachmentEntry.textContent || "",
sourceUrl: attachmentEntry.sourceUrl || "",
imageUrl: attachmentEntry.imageUrl || "",
createdAt: new Date().toISOString()
};
const nextAttachments = [entry, ...(idea.attachments || [])];
const patch = {
attachments: nextAttachments,
last_reviewed_at: new Date().toISOString()
};
if (entry.type === "image" && entry.imageUrl && (!idea.cover_mockup_url || attachmentEntry.setAsCover)) {
patch.cover_mockup_url = entry.imageUrl;
}
if (attachmentEntry.appendToNotes && entry.textContent) {
const prefix = idea.notes_markdown ? `${idea.notes_markdown}\n\n` : "";
const header = entry.title ? `### 📎 ${entry.title}${entry.sourceUrl ? ` (${entry.sourceUrl})` : ""}\n` : "";
patch.notes_markdown = `${prefix}${header}${entry.textContent}`;
}
return updateIdea(id, patch);
}
function recordArenaMatch({ winnerId, loserId }) {
const winner = getIdea(winnerId);
const loser = getIdea(loserId);
@@ -594,6 +646,7 @@ export function createDatabase(dbFilePath) {
deleteIdea,
logWhackOnIdea,
addMockupToIdea,
addAttachmentToIdea,
recordArenaMatch,
getSetting,
setSetting
+164
View File
@@ -435,6 +435,170 @@ export function createAppServer({
}
}
// 9b. POST /api/ideas/:id/attachments & DELETE/PATCH /api/ideas/:id/attachments/:attId
const attPostMatch = pathname.match(/^\/api\/ideas\/(\d+)\/attachments$/);
if (method === "POST" && attPostMatch) {
const id = Number(attPostMatch[1]);
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const body = await readJsonBody(req);
let savedImageUrl = "";
// Option 1: Base64 / Data URL image (upload, drag-and-drop, or clipboard paste)
if (body.imageDataUrl && String(body.imageDataUrl).startsWith("data:image/")) {
const mimeMatch = String(body.imageDataUrl).match(/^data:image\/([a-zA-Z0-9+.-]+);base64,/);
const rawExt = mimeMatch ? mimeMatch[1].toLowerCase() : "png";
const ext = rawExt === "jpeg" ? "jpg" : rawExt === "svg+xml" ? "svg" : rawExt;
const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
const filename = `att-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
const filePath = path.join(mockupsDir, filename);
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
savedImageUrl = `/mockups/${filename}`;
}
// Option 2: External image URL (fetch & cache locally so it never breaks, or store URL fallback)
else if (body.externalImageUrl && /^https?:\/\//i.test(String(body.externalImageUrl).trim())) {
const remoteUrl = String(body.externalImageUrl).trim();
try {
const resp = await fetch(remoteUrl, {
headers: { "User-Agent": "SparkVault/1.0" },
signal: AbortSignal.timeout(10000)
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const ct = (resp.headers.get("content-type") || "").toLowerCase();
const ext = ct.includes("jpeg") || ct.includes("jpg")
? "jpg"
: ct.includes("webp")
? "webp"
: ct.includes("svg")
? "svg"
: ct.includes("gif")
? "gif"
: "png";
const arrBuf = await resp.arrayBuffer();
const filename = `att-url-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
fs.writeFileSync(path.join(mockupsDir, filename), Buffer.from(arrBuf));
savedImageUrl = `/mockups/${filename}`;
} catch {
// Fallback to direct external URL if remote host blocks server-side fetch
savedImageUrl = remoteUrl;
}
}
const type = savedImageUrl ? "image" : "note";
if (type === "note" && !String(body.textContent || "").trim() && !String(body.sourceUrl || "").trim()) {
return sendJson(res, 400, { error: "Provide an image, image URL, or note text to attach" });
}
let updated = store.addAttachmentToIdea(id, {
type,
title: String(body.title || "").trim() || (type === "image" ? "Reference Image" : "External Note Clip"),
textContent: String(body.textContent || "").trim(),
sourceUrl: String(body.sourceUrl || body.externalImageUrl || "").trim(),
imageUrl: savedImageUrl,
setAsCover: Boolean(body.setAsCover),
appendToNotes: Boolean(body.appendToNotes)
});
// Also add sourceUrl to idea links if provided
if (body.sourceUrl && /^https?:\/\//i.test(String(body.sourceUrl).trim())) {
const cleanUrl = String(body.sourceUrl).trim();
if (!updated.links.some((l) => l.url === cleanUrl)) {
updated = store.updateIdea(id, {
links: [
...updated.links,
{
id: `lnk-${Date.now()}`,
title: String(body.title || cleanUrl).trim(),
url: cleanUrl
}
]
});
}
}
return sendJson(res, 201, {
idea: updated,
attachment: updated.attachments[0]
});
}
const attItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/attachments\/([^/]+)(?:\/(cover|promote-mockup))?$/);
if (attItemMatch) {
const id = Number(attItemMatch[1]);
const attId = attItemMatch[2];
const action = attItemMatch[3];
const idea = store.getIdea(id);
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
const target = (idea.attachments || []).find((a) => a.id === attId);
if (!target) return sendJson(res, 404, { error: "Attachment not found" });
if (method === "PATCH" && action === "cover") {
if (!target.imageUrl) return sendJson(res, 400, { error: "Attachment has no image" });
const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl });
return sendJson(res, 200, { idea: updated });
}
if (method === "POST" && action === "promote-mockup") {
if (!target.imageUrl) return sendJson(res, 400, { error: "Attachment has no image" });
const updated = store.addMockupToIdea(id, {
title: target.title || "Imported Reference Image",
template: "isolated-graphic",
prompt: target.textContent || target.title || "",
modelUsed: "External Attachment",
imageUrl: target.imageUrl
});
return sendJson(res, 201, { idea: updated });
}
if (method === "DELETE" && !action) {
const nextAtt = (idea.attachments || []).filter((a) => a.id !== attId);
const updated = store.updateIdea(id, { attachments: nextAtt });
return sendJson(res, 200, { idea: updated });
}
}
// 9c. POST /api/ingest (External Clipper / iOS Shortcut / Bookmarklet / Share Target)
if (method === "POST" && pathname === "/api/ingest") {
const body = await readJsonBody(req);
let targetIdeaId = body.ideaId ? Number(body.ideaId) : null;
let idea = targetIdeaId ? store.getIdea(targetIdeaId) : null;
if (!idea) {
idea = store.createIdea({
title: body.title || "External Spark Clip",
elevator_pitch: body.elevator_pitch || "",
category: body.category || "spark",
notes_markdown: body.notes_markdown || body.textContent || "",
tags: Array.isArray(body.tags) ? body.tags : ["clipped"]
});
}
let savedImageUrl = "";
if (body.imageDataUrl && String(body.imageDataUrl).startsWith("data:image/")) {
const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
const filename = `clip-${idea.id}-${Date.now()}.png`;
fs.writeFileSync(path.join(mockupsDir, filename), Buffer.from(base64Data, "base64"));
savedImageUrl = `/mockups/${filename}`;
} else if (body.externalImageUrl && /^https?:\/\//i.test(String(body.externalImageUrl).trim())) {
savedImageUrl = String(body.externalImageUrl).trim();
}
if (savedImageUrl || body.textContent || body.sourceUrl) {
idea = store.addAttachmentToIdea(idea.id, {
type: savedImageUrl ? "image" : "note",
title: body.attachmentTitle || body.title || "External Clip",
textContent: body.textContent || "",
sourceUrl: body.sourceUrl || "",
imageUrl: savedImageUrl,
setAsCover: Boolean(savedImageUrl)
});
}
return sendJson(res, 201, { idea });
}
// 10. GET /api/review/next & POST /api/review/arena
if (method === "GET" && pathname === "/api/review/next") {
const allIdeas = store.listIdeas({});
+34
View File
@@ -182,3 +182,37 @@ test("Head-to-Head Arena and Triage Review endpoints update arena_wins and compo
const arenaData = await arenaRes.json();
assert.equal(arenaData.winner.arena_wins, beforeWins + 1);
});
test("External notes and images can be attached to a spark and promoted to Mockup Studio", async () => {
const samplePngDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const attRes = await fetch(`${baseUrl}/api/ideas/1/attachments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Vintage Ski Patch Photo",
textContent: "Found this 1978 Chamonix felt patch as color reference.",
sourceUrl: "https://example.com/chamonix-patch",
imageDataUrl: samplePngDataUrl,
appendToNotes: true,
setAsCover: true
})
});
assert.equal(attRes.status, 201);
const attData = await attRes.json();
assert.equal(attData.attachment.title, "Vintage Ski Patch Photo");
assert.equal(attData.attachment.type, "image");
assert.ok(attData.idea.notes_markdown.includes("1978 Chamonix felt patch"));
assert.ok(attData.idea.links.some((l) => l.url === "https://example.com/chamonix-patch"));
// Promote attachment to Mockup Studio
const promRes = await fetch(
`${baseUrl}/api/ideas/1/attachments/${attData.attachment.id}/promote-mockup`,
{ method: "POST" }
);
assert.equal(promRes.status, 201);
const promData = await promRes.json();
assert.equal(promData.idea.mockups[0].title, "Vintage Ski Patch Photo");
});