Initial commit: SparkVault Idea Tracker, 64-card Creative Whack Pack & Nano Banana 2.1 Mockup Studio
This commit is contained in:
commit
49e3134758
82 files changed
+6312
No files matched your search
+599
@@ -0,0 +1,599 @@
|
||||
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
|
||||
} from "./nano_banana.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;
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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", "gemini-nano-banana-2.1");
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 model = store.getSetting("nano_banana_model", "gemini-nano-banana-2.1");
|
||||
|
||||
return sendJson(res, 200, {
|
||||
hasApiKey: Boolean(activeKey),
|
||||
apiKeySource: savedKey ? "settings" : envKey ? "environment" : "none",
|
||||
maskedKey: activeKey ? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}` : "",
|
||||
nanoBananaModel: model,
|
||||
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());
|
||||
}
|
||||
const savedKey = store.getSetting("gemini_api_key", "");
|
||||
const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "";
|
||||
const activeKey = savedKey || envKey;
|
||||
return sendJson(res, 200, {
|
||||
saved: true,
|
||||
hasApiKey: Boolean(activeKey),
|
||||
nanoBananaModel: store.getSetting("nano_banana_model", "gemini-nano-banana-2.1")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user