feat(attachments): attach external notes, screenshots, photos, and image URLs to sparks
This commit is contained in:
1 parent
8a25771839
commit
4c5560a5a1
6 files changed
+888
-5
No files matched your search
+414
@@ -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();
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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({});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user