939 lines
37 KiB
JavaScript
939 lines
37 KiB
JavaScript
import * as http from "node:http";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createDatabase } from "./db.js";
|
|
import {
|
|
WHACK_CARDS,
|
|
SUIT_META,
|
|
getCardByNumber,
|
|
drawCards,
|
|
drawFourRoleSpread
|
|
} from "./whack_cards.js";
|
|
import {
|
|
NANO_BANANA_MODELS,
|
|
SURFACE_PRESETS,
|
|
buildNanoBananaPrompt,
|
|
generateWithNanoBanana,
|
|
isCodexConfigured
|
|
} from "./nano_banana.js";
|
|
import {
|
|
DEFAULT_USER_BACKGROUND,
|
|
DEV_HANDOFF_TARGETS,
|
|
generatePersonalizedRandomIdea,
|
|
generateNameSuggestions,
|
|
buildDevelopmentSpecMarkdown,
|
|
executeDevelopmentHandoff
|
|
} from "./idea_generator.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const ROOT_DIR = path.resolve(__dirname, "..");
|
|
const PUBLIC_DIR = path.join(ROOT_DIR, "public");
|
|
const BUNDLED_DATA_DIR = path.join(ROOT_DIR, "data");
|
|
const ACTIVE_DATA_DIR = process.env.DATA_DIR
|
|
? path.resolve(process.env.DATA_DIR)
|
|
: BUNDLED_DATA_DIR;
|
|
const DEFAULT_DB_PATH = path.join(ACTIVE_DATA_DIR, "ideas.db");
|
|
const DEFAULT_MOCKUPS_DIR = path.join(ACTIVE_DATA_DIR, "mockups");
|
|
|
|
const MIME_TYPES = {
|
|
".html": "text/html; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "application/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".svg": "image/svg+xml",
|
|
".webp": "image/webp",
|
|
".ico": "image/x-icon"
|
|
};
|
|
|
|
function sendJson(res, statusCode, payload) {
|
|
const body = JSON.stringify(payload);
|
|
res.writeHead(statusCode, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Cache-Control": "no-store"
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function readJsonBody(req, maxBytes = 25 * 1024 * 1024) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
let total = 0;
|
|
req.on("data", (chunk) => {
|
|
total += chunk.length;
|
|
if (total > maxBytes) {
|
|
reject(new Error("Payload too large"));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on("end", () => {
|
|
try {
|
|
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
resolve(raw ? JSON.parse(raw) : {});
|
|
} catch (err) {
|
|
reject(new Error("Invalid JSON body"));
|
|
}
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function serveStaticFile(res, filePath) {
|
|
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
sendJson(res, 404, { error: "File not found" });
|
|
return;
|
|
}
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
res.writeHead(200, { "Content-Type": contentType });
|
|
fs.createReadStream(filePath).pipe(res);
|
|
}
|
|
|
|
function ensureSeedMockupsCopied(targetMockupsDir) {
|
|
fs.mkdirSync(targetMockupsDir, { recursive: true });
|
|
const bundledMockupsDir = path.join(BUNDLED_DATA_DIR, "mockups");
|
|
if (!fs.existsSync(bundledMockupsDir) || path.resolve(bundledMockupsDir) === path.resolve(targetMockupsDir)) {
|
|
return;
|
|
}
|
|
for (const file of fs.readdirSync(bundledMockupsDir)) {
|
|
const src = path.join(bundledMockupsDir, file);
|
|
const dest = path.join(targetMockupsDir, file);
|
|
if (!fs.existsSync(dest) && fs.statSync(src).isFile()) {
|
|
fs.copyFileSync(src, dest);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createAppServer({
|
|
dbPath = DEFAULT_DB_PATH,
|
|
mockupsDir = DEFAULT_MOCKUPS_DIR
|
|
} = {}) {
|
|
ensureSeedMockupsCopied(mockupsDir);
|
|
const store = createDatabase(dbPath);
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
const pathname = decodeURIComponent(parsedUrl.pathname);
|
|
const method = req.method.toUpperCase();
|
|
|
|
// --- HEALTHCHECK ---
|
|
if (method === "GET" && pathname === "/api/health") {
|
|
sendJson(res, 200, {
|
|
status: "ok",
|
|
service: "sparkvault",
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
return;
|
|
}
|
|
|
|
// --- API ROUTES ---
|
|
|
|
// 1. GET /api/ideas
|
|
if (method === "GET" && pathname === "/api/ideas") {
|
|
const status = parsedUrl.searchParams.get("status") || "all";
|
|
const category = parsedUrl.searchParams.get("category") || "all";
|
|
const tag = parsedUrl.searchParams.get("tag") || "all";
|
|
const search = parsedUrl.searchParams.get("search") || "";
|
|
const hasMockup = parsedUrl.searchParams.get("hasMockup") || "";
|
|
const sort = parsedUrl.searchParams.get("sort") || "score";
|
|
|
|
const ideas = store.listIdeas({ status, category, tag, search, hasMockup, sort });
|
|
const allIdeas = store.listIdeas({});
|
|
|
|
const tagCounts = {};
|
|
const statusCounts = { spark: 0, exploring: 0, building: 0, shipped: 0, archived: 0 };
|
|
let totalMockups = 0;
|
|
let totalWhacks = 0;
|
|
|
|
for (const item of allIdeas) {
|
|
if (statusCounts[item.status] !== undefined) statusCounts[item.status]++;
|
|
totalMockups += item.mockups.length;
|
|
totalWhacks += item.whack_history.length;
|
|
for (const t of item.tags) {
|
|
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
sendJson(res, 200, {
|
|
ideas,
|
|
stats: {
|
|
total: allIdeas.length,
|
|
statusCounts,
|
|
totalMockups,
|
|
totalWhacks,
|
|
tags: Object.entries(tagCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([name, count]) => ({ name, count }))
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 2. POST /api/ideas
|
|
if (method === "POST" && pathname === "/api/ideas") {
|
|
const body = await readJsonBody(req);
|
|
if (!body.title || !String(body.title).trim()) {
|
|
sendJson(res, 400, { error: "Idea title is required" });
|
|
return;
|
|
}
|
|
const created = store.createIdea(body);
|
|
sendJson(res, 201, { idea: created });
|
|
return;
|
|
}
|
|
|
|
// 2b. POST /api/ideas/generate-random (Personalized Random Idea Generator)
|
|
if (method === "POST" && pathname === "/api/ideas/generate-random") {
|
|
const body = await readJsonBody(req);
|
|
const userBackground =
|
|
body.userBackground ||
|
|
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) ||
|
|
DEFAULT_USER_BACKGROUND;
|
|
const categoryFocus = body.category || "any";
|
|
const existingTitles = store.listIdeas({}).map((i) => i.title);
|
|
const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || "";
|
|
|
|
const result = await generatePersonalizedRandomIdea({
|
|
userBackground,
|
|
categoryFocus,
|
|
existingTitles,
|
|
geminiApiKey: savedKey
|
|
});
|
|
|
|
const created = store.createIdea({
|
|
...result.ideaPayload,
|
|
preserveRawTitle: true
|
|
});
|
|
|
|
return sendJson(res, 201, {
|
|
idea: created,
|
|
card: result.card,
|
|
generatorUsed: result.generatorUsed
|
|
});
|
|
}
|
|
|
|
// 3. GET / PATCH / DELETE /api/ideas/:id
|
|
const ideaIdMatch = pathname.match(/^\/api\/ideas\/(\d+)$/);
|
|
if (ideaIdMatch) {
|
|
const id = Number(ideaIdMatch[1]);
|
|
if (method === "GET") {
|
|
const idea = store.getIdea(id);
|
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
|
return sendJson(res, 200, { idea });
|
|
}
|
|
if (method === "PATCH") {
|
|
const body = await readJsonBody(req);
|
|
const updated = store.updateIdea(id, body);
|
|
if (!updated) return sendJson(res, 404, { error: "Idea not found" });
|
|
return sendJson(res, 200, { idea: updated });
|
|
}
|
|
if (method === "DELETE") {
|
|
const deleted = store.deleteIdea(id);
|
|
if (!deleted) return sendJson(res, 404, { error: "Idea not found" });
|
|
return sendJson(res, 200, { deleted: true, id });
|
|
}
|
|
}
|
|
|
|
// 3b. POST /api/ideas/:id/suggest-names (AI Name & Tagline Brainstormer)
|
|
const suggestNamesMatch = pathname.match(/^\/api\/ideas\/(\d+)\/suggest-names$/);
|
|
if (method === "POST" && suggestNamesMatch) {
|
|
const id = Number(suggestNamesMatch[1]);
|
|
const idea = store.getIdea(id);
|
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
|
|
|
const userBackground =
|
|
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND;
|
|
const savedKey = store.getSetting("gemini_api_key", "") || process.env.GEMINI_API_KEY || "";
|
|
|
|
const result = await generateNameSuggestions({
|
|
idea,
|
|
userBackground,
|
|
geminiApiKey: savedKey
|
|
});
|
|
|
|
return sendJson(res, 200, result);
|
|
}
|
|
|
|
// 3c. GET /api/ideas/:id/spec.md (Download / Fetch Compiled Development Spec)
|
|
const specMdMatch = pathname.match(/^\/api\/ideas\/(\d+)\/spec\.md$/);
|
|
if (method === "GET" && specMdMatch) {
|
|
const id = Number(specMdMatch[1]);
|
|
const idea = store.getIdea(id);
|
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
|
|
|
const targetApp = parsedUrl.searchParams.get("target") || "antigravity";
|
|
const model = parsedUrl.searchParams.get("model") || "";
|
|
const includeHomelab = parsedUrl.searchParams.get("includeHomelab") !== "false";
|
|
const userBackground =
|
|
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND;
|
|
const host = req.headers.host || "sparkvault.vloser.us";
|
|
const proto = req.headers["x-forwarded-proto"] || "https";
|
|
const publicBaseUrl = `${proto}://${host}`;
|
|
|
|
const md = buildDevelopmentSpecMarkdown({
|
|
idea,
|
|
targetApp,
|
|
model,
|
|
includeHomelab,
|
|
publicBaseUrl,
|
|
userBackground
|
|
});
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/markdown; charset=utf-8",
|
|
"Content-Disposition": `attachment; filename="spark-${idea.id}-spec.md"`
|
|
});
|
|
res.end(md);
|
|
return;
|
|
}
|
|
|
|
// 3d. POST /api/ideas/:id/dev-handoff (Submit Idea to Antigravity or Codex for Development)
|
|
const devHandoffMatch = pathname.match(/^\/api\/ideas\/(\d+)\/dev-handoff$/);
|
|
if (method === "POST" && devHandoffMatch) {
|
|
const id = Number(devHandoffMatch[1]);
|
|
const idea = store.getIdea(id);
|
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
|
|
|
const body = await readJsonBody(req);
|
|
const targetApp = body.targetApp === "codex" ? "codex" : "antigravity";
|
|
const model = String(body.model || "").trim();
|
|
const customPrompt = String(body.customPrompt || "").trim();
|
|
const extraInstructions = String(body.extraInstructions || "").trim();
|
|
const includeHomelab = body.includeHomelab !== false;
|
|
const moveStatusToBuilding = body.moveStatusToBuilding !== false;
|
|
const executeServerCodex = Boolean(body.executeServerCodex);
|
|
|
|
const userBackground =
|
|
store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND;
|
|
const host = req.headers.host || "sparkvault.vloser.us";
|
|
const proto = req.headers["x-forwarded-proto"] || "https";
|
|
const publicBaseUrl = `${proto}://${host}`;
|
|
const workspacesRootDir = path.join(path.dirname(mockupsDir), "dev_workspaces");
|
|
|
|
const handoff = await executeDevelopmentHandoff({
|
|
idea,
|
|
targetApp,
|
|
model,
|
|
customPrompt,
|
|
includeHomelab,
|
|
extraInstructions,
|
|
executeServerCodex,
|
|
publicBaseUrl,
|
|
userBackground,
|
|
workspacesRootDir
|
|
});
|
|
|
|
// Record the handoff on the Spark and optionally move to "building"
|
|
const handoffAttachment = {
|
|
id: `dev-${Date.now()}`,
|
|
type: "note",
|
|
title: `🚀 Submitted to ${handoff.targetLabel} (${handoff.model})`,
|
|
textContent: handoff.codexExecution?.output
|
|
? `Submitted to ${handoff.targetLabel} using model ${handoff.model}.\n\n--- CODEX IMPLEMENTATION PLAN ---\n${handoff.codexExecution.output}`
|
|
: `Submitted full Spark specification to ${handoff.targetLabel} using model \`${handoff.model}\`.`,
|
|
sourceUrl: handoff.specUrl,
|
|
imageUrl: "",
|
|
createdAt: handoff.submittedAt
|
|
};
|
|
|
|
const patch = {
|
|
attachments: [handoffAttachment, ...(idea.attachments || [])]
|
|
};
|
|
if (moveStatusToBuilding && (idea.status === "spark" || idea.status === "exploring")) {
|
|
patch.status = "building";
|
|
}
|
|
const updatedIdea = store.updateIdea(id, patch);
|
|
|
|
return sendJson(res, 200, {
|
|
idea: updatedIdea,
|
|
handoff,
|
|
devTargets: DEV_HANDOFF_TARGETS
|
|
});
|
|
}
|
|
|
|
// 4. GET /api/whack-cards
|
|
if (method === "GET" && pathname === "/api/whack-cards") {
|
|
const allIdeas = store.listIdeas({});
|
|
const usageByCard = {};
|
|
for (const idea of allIdeas) {
|
|
if (idea.inspired_by_card) {
|
|
usageByCard[idea.inspired_by_card] = (usageByCard[idea.inspired_by_card] || 0) + 1;
|
|
}
|
|
for (const w of idea.whack_history) {
|
|
usageByCard[w.cardNumber] = (usageByCard[w.cardNumber] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
const cards = WHACK_CARDS.map((c) => ({
|
|
...c,
|
|
usageCount: usageByCard[c.number] || 0
|
|
}));
|
|
|
|
sendJson(res, 200, {
|
|
suits: SUIT_META,
|
|
cards
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 5. GET /api/whack-cards/draw
|
|
if (method === "GET" && pathname === "/api/whack-cards/draw") {
|
|
const mode = parsedUrl.searchParams.get("mode") || "single";
|
|
const suit = parsedUrl.searchParams.get("suit") || "";
|
|
const status = parsedUrl.searchParams.get("status") || "";
|
|
const ideaId = parsedUrl.searchParams.get("ideaId");
|
|
|
|
if (mode === "spread") {
|
|
const spread = drawFourRoleSpread();
|
|
return sendJson(res, 200, { mode: "spread", cards: spread });
|
|
}
|
|
|
|
if (mode === "collider") {
|
|
const activeIdeas = store
|
|
.listIdeas({})
|
|
.filter((i) => i.status !== "archived" && i.status !== "shipped");
|
|
let targetIdea = null;
|
|
if (ideaId) {
|
|
targetIdea = store.getIdea(Number(ideaId));
|
|
}
|
|
if (!targetIdea && activeIdeas.length > 0) {
|
|
targetIdea = activeIdeas[Math.floor(Math.random() * activeIdeas.length)];
|
|
}
|
|
const drawn = drawCards({
|
|
suit: suit || undefined,
|
|
status: targetIdea?.status || undefined,
|
|
count: 1
|
|
})[0];
|
|
return sendJson(res, 200, {
|
|
mode: "collider",
|
|
card: drawn,
|
|
idea: targetIdea
|
|
});
|
|
}
|
|
|
|
const drawn = drawCards({
|
|
suit: suit || undefined,
|
|
status: status || undefined,
|
|
count: 1
|
|
})[0];
|
|
|
|
sendJson(res, 200, { mode: "single", card: drawn });
|
|
return;
|
|
}
|
|
|
|
// 6. POST /api/ideas/:id/whack
|
|
const whackMatch = pathname.match(/^\/api\/ideas\/(\d+)\/whack$/);
|
|
if (method === "POST" && whackMatch) {
|
|
const id = Number(whackMatch[1]);
|
|
const body = await readJsonBody(req);
|
|
if (!body.cardNumber) {
|
|
return sendJson(res, 400, { error: "cardNumber is required" });
|
|
}
|
|
const updated = store.logWhackOnIdea(id, {
|
|
cardNumber: Number(body.cardNumber),
|
|
reflection: body.reflection || "",
|
|
addChecklistItem: Boolean(body.addChecklistItem),
|
|
promoteStatus: body.promoteStatus || null
|
|
});
|
|
if (!updated) return sendJson(res, 404, { error: "Idea not found" });
|
|
return sendJson(res, 200, { idea: updated });
|
|
}
|
|
|
|
// 7. POST /api/ideas/:id/mockups/nano-banana
|
|
const nbMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/nano-banana$/);
|
|
if (method === "POST" && nbMatch) {
|
|
const id = Number(nbMatch[1]);
|
|
const idea = store.getIdea(id);
|
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
|
|
|
const body = await readJsonBody(req);
|
|
const template = body.template || "tshirt-front";
|
|
const garmentColor = body.garmentColor || "#F5E8D3";
|
|
const garmentColorName = body.garmentColorName || "Vintage Cream";
|
|
const placement = body.placement || (template === "tshirt-left-chest" ? "left-chest" : "center-chest");
|
|
const whackCard = body.whackCardNumber ? getCardByNumber(body.whackCardNumber) : null;
|
|
|
|
// Check if editing an existing mockup
|
|
let referenceImagePath = null;
|
|
let parentMockupId = body.parentMockupId || null;
|
|
if (parentMockupId) {
|
|
const parentMockup = idea.mockups.find((m) => m.id === parentMockupId);
|
|
if (parentMockup && parentMockup.imageUrl.startsWith("/mockups/")) {
|
|
const candidate = path.join(mockupsDir, path.basename(parentMockup.imageUrl));
|
|
if (fs.existsSync(candidate)) {
|
|
referenceImagePath = candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
const finalPrompt = buildNanoBananaPrompt({
|
|
rawPrompt: body.prompt,
|
|
ideaTitle: idea.title,
|
|
elevatorPitch: idea.elevator_pitch,
|
|
template,
|
|
garmentColorName,
|
|
garmentHex: garmentColor,
|
|
whackCard,
|
|
editInstruction: body.editInstruction
|
|
});
|
|
|
|
const storedKey = store.getSetting("gemini_api_key", "");
|
|
const selectedModel = body.model || store.getSetting("nano_banana_model", "codex-image-gen");
|
|
|
|
try {
|
|
const genResult = await generateWithNanoBanana({
|
|
apiKey: body.apiKey || storedKey,
|
|
model: selectedModel,
|
|
prompt: finalPrompt,
|
|
referenceImagePath,
|
|
referenceImageBase64: body.referenceImageBase64 || null,
|
|
outputDir: mockupsDir
|
|
});
|
|
|
|
const updated = store.addMockupToIdea(id, {
|
|
title: body.title || (body.editInstruction ? `Edited: ${body.editInstruction.slice(0, 40)}` : `${SURFACE_PRESETS[template]?.label || "Mockup"}`),
|
|
template,
|
|
garmentColor,
|
|
garmentColorName,
|
|
placement,
|
|
prompt: finalPrompt,
|
|
editInstruction: body.editInstruction || "",
|
|
parentMockupId,
|
|
modelUsed: genResult.modelUsed,
|
|
imageUrl: genResult.imageUrl
|
|
});
|
|
|
|
return sendJson(res, 201, {
|
|
idea: updated,
|
|
mockup: updated.mockups[0],
|
|
textNotes: genResult.textNotes
|
|
});
|
|
} catch (err) {
|
|
const status = err.code === "MISSING_API_KEY" ? 400 : 502;
|
|
return sendJson(res, status, {
|
|
error: err.message || "Nano Banana image generation failed",
|
|
code: err.code || "GENERATION_ERROR",
|
|
promptBuilt: finalPrompt
|
|
});
|
|
}
|
|
}
|
|
|
|
// 8. POST /api/ideas/:id/mockups/canvas
|
|
const canvasMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/canvas$/);
|
|
if (method === "POST" && canvasMatch) {
|
|
const id = Number(canvasMatch[1]);
|
|
const idea = store.getIdea(id);
|
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
|
|
|
const body = await readJsonBody(req);
|
|
if (!body.imageDataUrl || !String(body.imageDataUrl).startsWith("data:image/")) {
|
|
return sendJson(res, 400, { error: "Valid imageDataUrl (data:image/png;base64,...) is required" });
|
|
}
|
|
|
|
const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, "");
|
|
const filename = `mockup-${id}-${Date.now()}.png`;
|
|
const filePath = path.join(mockupsDir, filename);
|
|
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
|
|
|
|
const updated = store.addMockupToIdea(id, {
|
|
title: body.title || `${idea.title} (${body.placement === "left-chest" ? "Left-Chest" : "Mockup"})`,
|
|
template: body.template || "tshirt-front",
|
|
garmentColor: body.garmentColor || "#F5E8D3",
|
|
garmentColorName: body.garmentColorName || "Vintage Cream",
|
|
placement: body.placement || "center-chest",
|
|
prompt: body.prompt || "",
|
|
topText: body.topText || "",
|
|
bottomText: body.bottomText || "",
|
|
modelUsed: body.modelUsed || "Interactive Mockup Compositor",
|
|
imageUrl: `/mockups/${filename}`
|
|
});
|
|
|
|
return sendJson(res, 201, {
|
|
idea: updated,
|
|
mockup: updated.mockups[0]
|
|
});
|
|
}
|
|
|
|
// 9. PATCH /api/ideas/:id/mockups/:mockupId/cover & DELETE /api/ideas/:id/mockups/:mockupId
|
|
const mockupItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/([^/]+)(?:\/(cover))?$/);
|
|
if (mockupItemMatch) {
|
|
const id = Number(mockupItemMatch[1]);
|
|
const mockupId = mockupItemMatch[2];
|
|
const isCoverAction = mockupItemMatch[3] === "cover";
|
|
const idea = store.getIdea(id);
|
|
if (!idea) return sendJson(res, 404, { error: "Idea not found" });
|
|
|
|
if (method === "PATCH" && isCoverAction) {
|
|
const target = idea.mockups.find((m) => m.id === mockupId);
|
|
if (!target) return sendJson(res, 404, { error: "Mockup not found" });
|
|
const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl });
|
|
return sendJson(res, 200, { idea: updated });
|
|
}
|
|
|
|
if (method === "DELETE" && !isCoverAction) {
|
|
const nextMockups = idea.mockups.filter((m) => m.id !== mockupId);
|
|
const nextCover =
|
|
idea.cover_mockup_url && nextMockups.some((m) => m.imageUrl === idea.cover_mockup_url)
|
|
? idea.cover_mockup_url
|
|
: nextMockups[0]?.imageUrl || "";
|
|
const updated = store.updateIdea(id, {
|
|
mockups: nextMockups,
|
|
cover_mockup_url: nextCover
|
|
});
|
|
return sendJson(res, 200, { idea: updated });
|
|
}
|
|
}
|
|
|
|
// 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({});
|
|
const activeIdeas = allIdeas.filter((i) => i.status !== "archived" && i.status !== "shipped");
|
|
|
|
const triageQueue = [...activeIdeas].sort((a, b) => {
|
|
if (!a.last_reviewed_at && b.last_reviewed_at) return -1;
|
|
if (a.last_reviewed_at && !b.last_reviewed_at) return 1;
|
|
if (a.last_reviewed_at && b.last_reviewed_at) {
|
|
return a.last_reviewed_at.localeCompare(b.last_reviewed_at);
|
|
}
|
|
return a.created_at.localeCompare(b.created_at);
|
|
});
|
|
|
|
let arenaPair = [];
|
|
if (activeIdeas.length >= 2) {
|
|
const shuffled = [...activeIdeas].sort(() => Math.random() - 0.5);
|
|
arenaPair = [shuffled[0], shuffled[1]];
|
|
}
|
|
|
|
return sendJson(res, 200, {
|
|
triageQueue,
|
|
arenaPair
|
|
});
|
|
}
|
|
|
|
if (method === "POST" && pathname === "/api/review/arena") {
|
|
const body = await readJsonBody(req);
|
|
if (!body.winnerId || !body.loserId) {
|
|
return sendJson(res, 400, { error: "winnerId and loserId are required" });
|
|
}
|
|
const result = store.recordArenaMatch({
|
|
winnerId: Number(body.winnerId),
|
|
loserId: Number(body.loserId)
|
|
});
|
|
if (!result) return sendJson(res, 404, { error: "Ideas not found" });
|
|
return sendJson(res, 200, result);
|
|
}
|
|
|
|
// 11. GET / PUT /api/settings
|
|
if (pathname === "/api/settings") {
|
|
if (method === "GET") {
|
|
const savedKey = store.getSetting("gemini_api_key", "");
|
|
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
|
|
const activeKey = savedKey || envKey;
|
|
const codexReady = isCodexConfigured();
|
|
const model = store.getSetting("nano_banana_model", "codex-image-gen");
|
|
|
|
return sendJson(res, 200, {
|
|
hasApiKey: Boolean(activeKey) || codexReady,
|
|
codexReady,
|
|
apiKeySource: codexReady
|
|
? "OpenAI Codex (OAuth)"
|
|
: savedKey
|
|
? "settings"
|
|
: envKey
|
|
? "environment"
|
|
: "none",
|
|
maskedKey: activeKey
|
|
? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}`
|
|
: codexReady
|
|
? "Authenticated via ~/.codex/auth.json"
|
|
: "",
|
|
nanoBananaModel: model,
|
|
userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND,
|
|
availableModels: NANO_BANANA_MODELS,
|
|
surfacePresets: Object.entries(SURFACE_PRESETS).map(([id, val]) => ({
|
|
id,
|
|
label: val.label
|
|
}))
|
|
});
|
|
}
|
|
if (method === "PUT") {
|
|
const body = await readJsonBody(req);
|
|
if (body.geminiApiKey !== undefined) {
|
|
store.setSetting("gemini_api_key", String(body.geminiApiKey).trim());
|
|
}
|
|
if (body.nanoBananaModel !== undefined) {
|
|
store.setSetting("nano_banana_model", String(body.nanoBananaModel).trim());
|
|
}
|
|
if (body.userBackground !== undefined) {
|
|
store.setSetting("user_background", String(body.userBackground).trim());
|
|
}
|
|
const savedKey = store.getSetting("gemini_api_key", "");
|
|
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
|
|
const activeKey = savedKey || envKey;
|
|
const codexReady = isCodexConfigured();
|
|
return sendJson(res, 200, {
|
|
saved: true,
|
|
hasApiKey: Boolean(activeKey) || codexReady,
|
|
codexReady,
|
|
nanoBananaModel: store.getSetting("nano_banana_model", "codex-image-gen"),
|
|
userBackground: store.getSetting("user_background", DEFAULT_USER_BACKGROUND) || DEFAULT_USER_BACKGROUND
|
|
});
|
|
}
|
|
}
|
|
|
|
// 12. GET /api/export
|
|
if (method === "GET" && pathname === "/api/export") {
|
|
const format = parsedUrl.searchParams.get("format") || "json";
|
|
const ideas = store.listIdeas({});
|
|
if (format === "markdown") {
|
|
const mdLines = [
|
|
`# SparkVault Idea Export (${new Date().toISOString().slice(0, 10)})`,
|
|
""
|
|
];
|
|
for (const idea of ideas) {
|
|
mdLines.push(`## [${idea.status.toUpperCase()}] ${idea.title} (Score: ${idea.composite_score})`);
|
|
if (idea.elevator_pitch) mdLines.push(`> ${idea.elevator_pitch}`);
|
|
mdLines.push(`- **Category**: ${idea.category} | **Tags**: ${idea.tags.map((t) => `#${t}`).join(" ") || "none"}`);
|
|
mdLines.push(`- **Ratings**: Excitement ${idea.rating_excitement}/5, Impact ${idea.rating_impact}/5, Ease ${idea.rating_ease}/5 | **Arena Wins**: ${idea.arena_wins}`);
|
|
if (idea.notes_markdown) {
|
|
mdLines.push("", idea.notes_markdown);
|
|
}
|
|
if (idea.checklist.length > 0) {
|
|
mdLines.push("", "### Checklist");
|
|
for (const item of idea.checklist) {
|
|
mdLines.push(`- [${item.done ? "x" : " "}] ${item.text}`);
|
|
}
|
|
}
|
|
if (idea.whack_history.length > 0) {
|
|
mdLines.push("", "### Creative Whack Pack Reflections");
|
|
for (const w of idea.whack_history) {
|
|
mdLines.push(`- **Card #${w.cardNumber} (${w.cardTitle})**: ${w.reflection}`);
|
|
}
|
|
}
|
|
mdLines.push("", "---", "");
|
|
}
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/markdown; charset=utf-8",
|
|
"Content-Disposition": 'attachment; filename="sparkvault-ideas.md"'
|
|
});
|
|
res.end(mdLines.join("\n"));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
"Content-Disposition": 'attachment; filename="sparkvault-ideas.json"'
|
|
});
|
|
res.end(JSON.stringify({ exportedAt: new Date().toISOString(), ideas }, null, 2));
|
|
return;
|
|
}
|
|
|
|
// --- STATIC ASSETS ---
|
|
if (pathname.startsWith("/mockups/")) {
|
|
const rel = pathname.slice("/mockups/".length);
|
|
const safePath = path.normalize(path.join(mockupsDir, rel));
|
|
if (!safePath.startsWith(mockupsDir)) {
|
|
return sendJson(res, 403, { error: "Forbidden" });
|
|
}
|
|
return serveStaticFile(res, safePath);
|
|
}
|
|
|
|
const relPath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
const staticPath = path.normalize(path.join(PUBLIC_DIR, relPath));
|
|
if (!staticPath.startsWith(PUBLIC_DIR)) {
|
|
return sendJson(res, 403, { error: "Forbidden" });
|
|
}
|
|
if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
|
|
return serveStaticFile(res, staticPath);
|
|
}
|
|
|
|
// SPA Fallback
|
|
return serveStaticFile(res, path.join(PUBLIC_DIR, "index.html"));
|
|
} catch (err) {
|
|
console.error("Server error:", err);
|
|
sendJson(res, 500, { error: err.message || "Internal server error" });
|
|
}
|
|
});
|
|
|
|
return { server, store };
|
|
}
|
|
|
|
// Start server if invoked directly
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
|
const port = Number(process.env.PORT || 3456);
|
|
const { server } = createAppServer();
|
|
server.listen(port, () => {
|
|
console.log(`✨ SparkVault Idea Tracker running at http://localhost:${port}`);
|
|
});
|
|
}
|