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
+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({});
|
||||
|
||||
Reference in new issue
Block a user