600 lines
22 KiB
JavaScript
600 lines
22 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
|
|
} 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}`);
|
|
});
|
|
}
|