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",
|
nbGarmentColorName: "Vintage Cream",
|
||||||
nbEditParentMockup: null,
|
nbEditParentMockup: null,
|
||||||
uploadedOverlayImage: null,
|
uploadedOverlayImage: null,
|
||||||
|
stagedDrawerAttachmentDataUrl: null,
|
||||||
|
stagedClipAttachmentDataUrl: null,
|
||||||
settings: {
|
settings: {
|
||||||
hasApiKey: false,
|
hasApiKey: false,
|
||||||
apiKeySource: "none",
|
apiKeySource: "none",
|
||||||
@@ -122,6 +124,7 @@ async function initApp() {
|
|||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
await Promise.all([loadSettings(), loadWhackCatalog(), loadIdeas()]);
|
await Promise.all([loadSettings(), loadWhackCatalog(), loadIdeas()]);
|
||||||
renderCompositorCanvas();
|
renderCompositorCanvas();
|
||||||
|
checkWebShareTargetQueryParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
@@ -263,8 +266,10 @@ function renderIdeaCardHtml(idea) {
|
|||||||
${checklistTotal > 0 ? `<span> • ☑️ ${checklistDone}/${checklistTotal}</span>` : ""}
|
${checklistTotal > 0 ? `<span> • ☑️ ${checklistDone}/${checklistTotal}</span>` : ""}
|
||||||
${whackCount > 0 ? `<span title="Whack Pack reflections"> • 🃏${whackCount}</span>` : ""}
|
${whackCount > 0 ? `<span title="Whack Pack reflections"> • 🃏${whackCount}</span>` : ""}
|
||||||
${idea.mockups.length > 0 ? `<span title="Visual Mockups"> • 🍌${idea.mockups.length}</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>
|
||||||
<div class="card-quick-actions">
|
<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-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>
|
<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) => {
|
container.querySelectorAll(".btn-card-mockup").forEach((btn) => {
|
||||||
btn.addEventListener("click", (e) => {
|
btn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -910,10 +922,131 @@ function renderDrawerContent() {
|
|||||||
// Mockup Gallery
|
// Mockup Gallery
|
||||||
renderDrawerMockupGallery(idea);
|
renderDrawerMockupGallery(idea);
|
||||||
|
|
||||||
|
// External Attachments
|
||||||
|
renderDrawerAttachments(idea);
|
||||||
|
|
||||||
// Whack History
|
// Whack History
|
||||||
renderDrawerWhackHistory(idea);
|
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) {
|
function renderBulletList(elementId, items, fieldName) {
|
||||||
const ul = document.getElementById(elementId);
|
const ul = document.getElementById(elementId);
|
||||||
if (!ul) return;
|
if (!ul) return;
|
||||||
@@ -1420,6 +1553,95 @@ function openImageLightbox(url, title = "Mockup Preview") {
|
|||||||
document.getElementById("lightbox-meta").innerHTML = "";
|
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) {
|
function switchWhackSubmode(mode) {
|
||||||
state.whackSubmode = mode;
|
state.whackSubmode = mode;
|
||||||
document.querySelectorAll("[data-whack-mode]").forEach((btn) => {
|
document.querySelectorAll("[data-whack-mode]").forEach((btn) => {
|
||||||
@@ -1937,6 +2159,198 @@ function bindGlobalEvents() {
|
|||||||
document.getElementById("btn-close-lightbox")?.addEventListener("click", () => {
|
document.getElementById("btn-close-lightbox")?.addEventListener("click", () => {
|
||||||
document.getElementById("lightbox-modal")?.classList.add("hidden");
|
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();
|
initApp();
|
||||||
@@ -37,6 +37,14 @@
|
|||||||
<option value="research">🔬 Research</option>
|
<option value="research">🔬 Research</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Capture</button>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="btn-quick-whack-prompt"
|
id="btn-quick-whack-prompt"
|
||||||
@@ -466,6 +474,56 @@
|
|||||||
<button type="submit" class="btn btn-ghost btn-sm">+ Add Link</button>
|
<button type="submit" class="btn btn-ghost btn-sm">+ Add Link</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- TAB 2: NANO BANANA 2.1 VISUAL MOCKUP STUDIO -->
|
<!-- TAB 2: NANO BANANA 2.1 VISUAL MOCKUP STUDIO -->
|
||||||
@@ -700,6 +758,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- MODAL: CARD / MOCKUP LIGHTBOX INSPECTOR -->
|
||||||
<div id="lightbox-modal" class="modal-backdrop hidden">
|
<div id="lightbox-modal" class="modal-backdrop hidden">
|
||||||
<div class="lightbox-card">
|
<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 = []) {
|
function safeParse(jsonStr, fallback = []) {
|
||||||
try {
|
try {
|
||||||
return jsonStr ? JSON.parse(jsonStr) : fallback;
|
return jsonStr ? JSON.parse(jsonStr) : fallback;
|
||||||
@@ -124,6 +131,7 @@ export function createDatabase(dbFilePath) {
|
|||||||
const links = safeParse(row.links_json, []);
|
const links = safeParse(row.links_json, []);
|
||||||
const whack_history = safeParse(row.whack_history_json, []);
|
const whack_history = safeParse(row.whack_history_json, []);
|
||||||
const mockups = safeParse(row.mockups_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 inspiredCard = row.inspired_by_card ? getCardByNumber(row.inspired_by_card) : null;
|
||||||
|
|
||||||
const hydrated = {
|
const hydrated = {
|
||||||
@@ -142,7 +150,12 @@ export function createDatabase(dbFilePath) {
|
|||||||
inspired_by_card: row.inspired_by_card || null,
|
inspired_by_card: row.inspired_by_card || null,
|
||||||
inspired_card_detail: inspiredCard,
|
inspired_card_detail: inspiredCard,
|
||||||
mockups,
|
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_excitement: Number(row.rating_excitement ?? 3),
|
||||||
rating_impact: Number(row.rating_impact ?? 3),
|
rating_impact: Number(row.rating_impact ?? 3),
|
||||||
rating_ease: Number(row.rating_ease ?? 3),
|
rating_ease: Number(row.rating_ease ?? 3),
|
||||||
@@ -217,17 +230,22 @@ export function createDatabase(dbFilePath) {
|
|||||||
const impact = payload.rating_impact ?? 3;
|
const impact = payload.rating_impact ?? 3;
|
||||||
const ease = payload.rating_ease ?? 3;
|
const ease = payload.rating_ease ?? 3;
|
||||||
const mockups = Array.isArray(payload.mockups) ? payload.mockups : [];
|
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 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(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO ideas (
|
INSERT INTO ideas (
|
||||||
title, elevator_pitch, category, status, notes_markdown,
|
title, elevator_pitch, category, status, notes_markdown,
|
||||||
tags_json, checklist_json, pros_json, cons_json, links_json,
|
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,
|
rating_excitement, rating_impact, rating_ease,
|
||||||
arena_wins, arena_matches, pinned, last_reviewed_at, created_at, updated_at
|
arena_wins, arena_matches, pinned, last_reviewed_at, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
@@ -244,6 +262,7 @@ export function createDatabase(dbFilePath) {
|
|||||||
JSON.stringify(whackHistory),
|
JSON.stringify(whackHistory),
|
||||||
payload.inspired_by_card ? Number(payload.inspired_by_card) : null,
|
payload.inspired_by_card ? Number(payload.inspired_by_card) : null,
|
||||||
JSON.stringify(mockups),
|
JSON.stringify(mockups),
|
||||||
|
JSON.stringify(attachments),
|
||||||
coverMockupUrl,
|
coverMockupUrl,
|
||||||
Number(excitement),
|
Number(excitement),
|
||||||
Number(impact),
|
Number(impact),
|
||||||
@@ -278,6 +297,7 @@ export function createDatabase(dbFilePath) {
|
|||||||
whack_history: Array.isArray(patch.whack_history) ? patch.whack_history : existing.whack_history,
|
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,
|
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,
|
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,
|
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_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,
|
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
|
UPDATE ideas SET
|
||||||
title = ?, elevator_pitch = ?, category = ?, status = ?, notes_markdown = ?,
|
title = ?, elevator_pitch = ?, category = ?, status = ?, notes_markdown = ?,
|
||||||
tags_json = ?, checklist_json = ?, pros_json = ?, cons_json = ?, links_json = ?,
|
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 = ?,
|
rating_excitement = ?, rating_impact = ?, rating_ease = ?,
|
||||||
arena_wins = ?, arena_matches = ?, pinned = ?, last_reviewed_at = ?, updated_at = ?
|
arena_wins = ?, arena_matches = ?, pinned = ?, last_reviewed_at = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -311,6 +331,7 @@ export function createDatabase(dbFilePath) {
|
|||||||
JSON.stringify(updated.whack_history),
|
JSON.stringify(updated.whack_history),
|
||||||
updated.inspired_by_card,
|
updated.inspired_by_card,
|
||||||
JSON.stringify(updated.mockups),
|
JSON.stringify(updated.mockups),
|
||||||
|
JSON.stringify(updated.attachments),
|
||||||
updated.cover_mockup_url,
|
updated.cover_mockup_url,
|
||||||
updated.rating_excitement,
|
updated.rating_excitement,
|
||||||
updated.rating_impact,
|
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 }) {
|
function recordArenaMatch({ winnerId, loserId }) {
|
||||||
const winner = getIdea(winnerId);
|
const winner = getIdea(winnerId);
|
||||||
const loser = getIdea(loserId);
|
const loser = getIdea(loserId);
|
||||||
@@ -594,6 +646,7 @@ export function createDatabase(dbFilePath) {
|
|||||||
deleteIdea,
|
deleteIdea,
|
||||||
logWhackOnIdea,
|
logWhackOnIdea,
|
||||||
addMockupToIdea,
|
addMockupToIdea,
|
||||||
|
addAttachmentToIdea,
|
||||||
recordArenaMatch,
|
recordArenaMatch,
|
||||||
getSetting,
|
getSetting,
|
||||||
setSetting
|
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
|
// 10. GET /api/review/next & POST /api/review/arena
|
||||||
if (method === "GET" && pathname === "/api/review/next") {
|
if (method === "GET" && pathname === "/api/review/next") {
|
||||||
const allIdeas = store.listIdeas({});
|
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();
|
const arenaData = await arenaRes.json();
|
||||||
assert.equal(arenaData.winner.arena_wins, beforeWins + 1);
|
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