create-volt 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -262,33 +262,82 @@ const aiSettings = () =>
262
262
  </div>
263
263
  </details>`;
264
264
 
265
- mount(
266
- "#app",
267
- available.length
268
- ? html`<div class="card-x p-4 mb-3">
269
- <h2 class="h6 mb-3">Features</h2>
270
- ${available.map(addonRow)}
271
- <p class="small text-muted mb-0">Enabling a feature wires its backend automatically. Frontend UI (login form, chat) is yours to build — or start from <code>--template guestbook</code>.</p>
272
- </div>`
273
- : null,
274
- html`<div class="card-x p-4 mb-3">
275
- <h2 class="h6 mb-3">Settings</h2>
276
- ${field("PORT", "port", String(defaultPort))}
277
- ${field("SITE_NAME", "siteName", "My Site")}
278
- ${() => (hasContent() ? themePicker() : null)}
279
- ${() => (hasDb() ? dbSettings() : null)}
280
- ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
281
- ${() => (hasMedia() ? mediaSettings() : null)}
282
- ${aiSettings()}
283
- ${field("SITE_URL (optional absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
284
- ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
285
- </div>`,
265
+ // --- Manage content (a second screen reached via "Manage content →") ---
266
+ const view = signal("config"); // "config" | "manage"
267
+ const items = signal({ pages: [], posts: [] });
268
+ const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
269
+ const loadItems = async () => items(await (await fetch("/setup/content")).json());
270
+ async function editItem(type, slug) {
271
+ const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
272
+ editing({ type, slug, body: d.body || "", isNew: false });
273
+ }
274
+ function newItem(type) {
275
+ const body = type === "post" ? "---\ntitle: New Post\ndate: 2026-01-01\ncategory: \ntags: \n---\n\nWrite your post here.\n" : "---\ntitle: New Page\n---\n\nWrite your page here.\n";
276
+ editing({ type, slug: "", body, isNew: true });
277
+ }
278
+ async function saveItem() {
279
+ const e = editing();
280
+ const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
281
+ const body = document.querySelector("#mg-body").value;
282
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
283
+ const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
284
+ if (!r.ok) return status("Error: " + (r.error || "?"));
285
+ status("Saved → " + r.file);
286
+ editing(null);
287
+ loadItems();
288
+ }
289
+ async function delItem(type, slug) {
290
+ if (typeof confirm === "function" && !confirm(`Delete ${slug}?`)) return;
291
+ await fetch("/setup/content/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, slug }) });
292
+ status("Deleted " + slug);
293
+ loadItems();
294
+ }
295
+
296
+ const itemRow = (it) =>
297
+ html`<li class="list-group-item bg-transparent text-light d-flex justify-content-between align-items-center py-1 px-2">
298
+ <span><a href=${"http://localhost:" + state().port + (it.type === "post" ? "/blog/" : "/") + it.slug} target="_blank" rel="noopener">${it.title}</a> <span class="text-muted small">/${it.type === "post" ? "blog/" : ""}${it.slug}</span></span>
299
+ <span><button class="btn btn-sm btn-link p-0 me-3" onclick=${() => editItem(it.type, it.slug)}>edit</button><button class="btn btn-sm btn-link p-0 text-danger" onclick=${() => delItem(it.type, it.slug)}>delete</button></span>
300
+ </li>`;
301
+ const section = (label, type, key) =>
302
+ html`<div class="mb-3">
303
+ <div class="d-flex justify-content-between align-items-center mb-1"><strong>${label}</strong><button class="btn btn-sm btn-outline-secondary" onclick=${() => newItem(type)}>+ New</button></div>
304
+ ${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
305
+ </div>`;
306
+ const editorPanel = () => {
307
+ const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
308
+ return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
309
+ <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
310
+ <textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px">${e.body}</textarea>
311
+ <div class="mt-2 d-flex gap-2"><button class="btn btn-primary btn-sm" onclick=${saveItem}>Save</button><button class="btn btn-outline-secondary btn-sm" onclick=${() => editing(null)}>Cancel</button></div>
312
+ </div>`;
313
+ };
314
+ const manageView = () =>
286
315
  html`<div class="card-x p-4 mb-3">
287
- <div class="d-flex justify-content-between align-items-center mb-2">
288
- <h2 class="h6 mb-0">.env</h2>
289
- <button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button>
316
+ <div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h6 mb-0">Manage content</h2><button class="btn btn-sm btn-outline-secondary" onclick=${() => view("config")}>← Settings</button></div>
317
+ ${() => (editing() ? editorPanel() : html`${section("Pages", "page", "pages")}${section("Posts", "post", "posts")}<p class="small text-muted mb-0">Pages → <code>/slug</code>, posts → <code>/blog/slug</code>; <code>index</code> page is your home. All rendered in your theme. Edits hot-reload the running app.</p>`)}
318
+ </div>`;
319
+
320
+ const configView = () =>
321
+ html`${available.length ? html`<div class="card-x p-4 mb-3"><h2 class="h6 mb-3">Features</h2>${available.map(addonRow)}<p class="small text-muted mb-0">Enabling a feature wires its backend automatically. Frontend UI (login form, chat) is yours to build — or start from <code>--template guestbook</code>.</p></div>` : ""}
322
+ <div class="card-x p-4 mb-3">
323
+ <h2 class="h6 mb-3">Settings</h2>
324
+ ${field("PORT", "port", String(defaultPort))}
325
+ ${field("SITE_NAME", "siteName", "My Site")}
326
+ ${() => (hasContent() ? themePicker() : null)}
327
+ ${() => (hasDb() ? dbSettings() : null)}
328
+ ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
329
+ ${() => (hasMedia() ? mediaSettings() : null)}
330
+ ${aiSettings()}
331
+ ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
332
+ ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
290
333
  </div>
291
- <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
292
- </div>`,
334
+ <div class="card-x p-4 mb-3">
335
+ <div class="d-flex justify-content-between align-items-center mb-2"><h2 class="h6 mb-0">.env</h2><div class="d-flex gap-2">${() => (hasContent() ? html`<button class="btn btn-outline-light btn-sm" onclick=${() => (view("manage"), loadItems())}>Manage content →</button>` : "")}<button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button></div></div>
336
+ <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
337
+ </div>`;
338
+
339
+ mount(
340
+ "#app",
341
+ () => (view() === "config" ? configView() : manageView()),
293
342
  () => (status() ? html`<p class="small accent">${status}</p>` : null),
294
343
  );
@@ -1,9 +1,9 @@
1
- // server.js — dev server with a built-in first-run setup wizard.
1
+ // server.js dev server with a built-in first-run setup wizard.
2
2
  //
3
3
  // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
4
4
  // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
5
  // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
- // runs npm install, then starts the app — which wires whatever .env enables.
6
+ // runs npm install, then starts the app which wires whatever .env enables.
7
7
  // Add-on code is bundled under .volt/addons; nothing is copied into your code.
8
8
  //
9
9
  // No build step, no env-file flag: .env is auto-loaded below.
@@ -24,7 +24,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
25
  const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
26
26
 
27
- // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
27
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run lets
28
28
  // --edit/--studio dodge a port the running app already holds, and runs the app
29
29
  // itself on a one-off port. Explicit flag wins over PORT in .env.
30
30
  function cliPort() {
@@ -104,7 +104,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
104
104
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
105
105
 
106
106
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
107
- // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
107
+ // a third-party add-on a local .volt/addons/<name>/index.js or an installed
108
108
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
109
109
  const BUILTINS = new Set(Object.keys(LIB_FILE));
110
110
  async function loadAddon(name) {
@@ -128,7 +128,7 @@ function openBrowser(url) {
128
128
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
129
129
  try {
130
130
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
131
- child.on("error", () => {}); // launcher missing — emits async, don't crash
131
+ child.on("error", () => {}); // launcher missing emits async, don't crash
132
132
  child.unref();
133
133
  return true;
134
134
  } catch {
@@ -175,8 +175,8 @@ async function startApp() {
175
175
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
176
176
  }
177
177
 
178
- // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
179
- // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
178
+ // markdown pages (/<slug> pages/<slug>.md) mounted last, so app routes win
179
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) before pages so /blog wins; renders in the same theme.
180
180
  if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
181
181
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
182
182
 
@@ -191,7 +191,7 @@ async function startApp() {
191
191
  res.json({ ok: true });
192
192
  });
193
193
 
194
- // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
194
+ // third-party add-ons register(ctx). When auth is on, requireAuth/sessionFromReq
195
195
  // are provided so add-ons can gate routes by login.
196
196
  let requireAuth = null;
197
197
  let sessionFromReq = null;
@@ -207,7 +207,7 @@ async function startApp() {
207
207
  if (typeof register === "function") {
208
208
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
209
209
  } else {
210
- console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
210
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export skipped`);
211
211
  }
212
212
  }
213
213
 
@@ -215,7 +215,7 @@ async function startApp() {
215
215
  const onChange = (file) => {
216
216
  clearTimeout(timer);
217
217
  timer = setTimeout(() => {
218
- console.log(`[volt] change: ${file ?? "?"} → reload`);
218
+ console.log(`[volt] change: ${file ?? "?"} reload`);
219
219
  io.emit("volt:reload", { file });
220
220
  }, 80);
221
221
  };
@@ -258,7 +258,7 @@ async function startApp() {
258
258
  }
259
259
  throw e;
260
260
  });
261
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
261
+ server.listen(PORT, () => console.log(`Volt at http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
262
262
  }
263
263
 
264
264
  // Packages an .env's selections need, beyond what package.json already has.
@@ -282,7 +282,7 @@ function neededPackages(env) {
282
282
  function ensureDriverInstalled(driver) {
283
283
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
284
284
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
285
- console.log(`[volt] installing ${pkg} for the connection test…`);
285
+ console.log(`[volt] installing ${pkg} for the connection test…`);
286
286
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
287
287
  }
288
288
 
@@ -312,6 +312,55 @@ function startSetup() {
312
312
  res.setHeader("Content-Type", "application/json");
313
313
  return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
314
314
  }
315
+ // --- content manager: list / read / write / delete pages + posts ---
316
+ if (req.method === "GET" && p === "/setup/content") {
317
+ const list = (type) => {
318
+ const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
319
+ if (!fs.existsSync(dir)) return [];
320
+ return fs
321
+ .readdirSync(dir)
322
+ .filter((f) => f.endsWith(".md") && !f.startsWith("_"))
323
+ .map((f) => {
324
+ const slug = f.replace(/\.md$/, "");
325
+ const title = (fs.readFileSync(path.join(dir, f), "utf8").match(/^title:\s*(.+)$/m) || [])[1];
326
+ return { type, slug, title: (title || slug).trim() };
327
+ });
328
+ };
329
+ res.setHeader("Content-Type", "application/json");
330
+ return res.end(JSON.stringify({ pages: list("page"), posts: list("post") }));
331
+ }
332
+ if (req.method === "GET" && p === "/setup/content/raw") {
333
+ const type = u.searchParams.get("type") === "post" ? "posts" : "pages";
334
+ const slug = u.searchParams.get("slug") || "";
335
+ res.setHeader("Content-Type", "application/json");
336
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return res.end(JSON.stringify({ ok: false, error: "invalid slug" }));
337
+ const file = path.join(__dirname, type, slug + ".md");
338
+ return res.end(JSON.stringify({ ok: true, body: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" }));
339
+ }
340
+ if (req.method === "POST" && (p === "/setup/content/save" || p === "/setup/content/delete")) {
341
+ let cbody = "";
342
+ req.on("data", (c) => (cbody += c));
343
+ req.on("end", () => {
344
+ res.setHeader("Content-Type", "application/json");
345
+ try {
346
+ const { type, slug, body } = JSON.parse(cbody || "{}");
347
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug || "")) throw new Error("slug: lowercase letters, numbers, hyphens");
348
+ const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
349
+ const file = path.join(dir, slug + ".md");
350
+ if (p === "/setup/content/delete") {
351
+ if (fs.existsSync(file)) fs.unlinkSync(file);
352
+ return res.end(JSON.stringify({ ok: true }));
353
+ }
354
+ fs.mkdirSync(dir, { recursive: true });
355
+ fs.writeFileSync(file, String(body ?? ""));
356
+ res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
357
+ } catch (e) {
358
+ res.statusCode = 400;
359
+ res.end(JSON.stringify({ ok: false, error: e.message }));
360
+ }
361
+ });
362
+ return;
363
+ }
315
364
  // "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
316
365
  if (req.method === "POST" && p === "/setup/eject-theme") {
317
366
  let body = "";
@@ -405,15 +454,15 @@ function startSetup() {
405
454
  server.closeIdleConnections?.();
406
455
  };
407
456
  if (added.length) {
408
- console.log(`[volt] installing ${added.join(", ")}…`);
457
+ console.log(`[volt] installing ${added.join(", ")}…`);
409
458
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
410
459
  npm.on("error", () => handoff());
411
460
  npm.on("close", () => {
412
- console.log("[volt] saved .env — starting the app…");
461
+ console.log("[volt] saved .env starting the app");
413
462
  handoff();
414
463
  });
415
464
  } else {
416
- console.log("[volt] saved .env — starting the app…");
465
+ console.log("[volt] saved .env starting the app");
417
466
  handoff();
418
467
  }
419
468
  });
@@ -431,18 +480,18 @@ function startSetup() {
431
480
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
432
481
  server.listen(PORT, "127.0.0.1", () => {
433
482
  const url = `http://localhost:${PORT}`;
434
- console.log(`\n⚡ Volt setup → ${url}`);
483
+ console.log(`\nVolt setup at ${url}`);
435
484
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
436
485
  const ssh = process.env.SSH_CONNECTION;
437
486
  if (ssh) {
438
487
  const host = ssh.split(" ")[2];
439
488
  const user = process.env.USER || process.env.USERNAME || "you";
440
- console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
489
+ console.log(" Remote box the server is up here; bridge it from your LOCAL machine:");
441
490
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
442
- console.log(` …then open ${url} on your machine (the tunnel points it here).`);
491
+ console.log(` then open ${url} on your machine (the tunnel points it here).`);
443
492
  }
444
493
  console.log("");
445
- if (openBrowser(url)) console.log(" (opening your browser…)\n");
494
+ if (openBrowser(url)) console.log(" (opening your browser)\n");
446
495
  });
447
496
  }
448
497
 
@@ -450,8 +499,8 @@ function readEnvFileLines() {
450
499
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
451
500
  }
452
501
 
453
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
454
- // Not a route in the running app — it only exists while you run `--studio`, on
502
+ // --- Studio: an ephemeral, localhost-only data browser (la Prisma Studio).
503
+ // Not a route in the running app it only exists while you run `--studio`, on
455
504
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
456
505
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
457
506
  async function startStudio() {
@@ -464,7 +513,7 @@ async function startStudio() {
464
513
  try {
465
514
  store = await (await addonMod("db")).createStore();
466
515
  } catch (e) {
467
- console.error("Studio: couldn't connect the store — " + e.message);
516
+ console.error("Studio: couldn't connect the store " + e.message);
468
517
  process.exit(1);
469
518
  }
470
519
  const PORT = configPort();
@@ -522,15 +571,15 @@ async function startStudio() {
522
571
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
523
572
  server.listen(PORT, "127.0.0.1", () => {
524
573
  const url = `http://localhost:${PORT}`;
525
- console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
526
- console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
574
+ console.log(`\nVolt Studio at ${url} (${store.name})`);
575
+ console.log(" Browse your data. localhost-only, disposable Ctrl-C when done.");
527
576
  const ssh = process.env.SSH_CONNECTION;
528
577
  if (ssh) {
529
578
  const host = ssh.split(" ")[2];
530
579
  const user = process.env.USER || process.env.USERNAME || "you";
531
- console.log(" Remote box — from your LOCAL machine:");
580
+ console.log(" Remote box from your LOCAL machine:");
532
581
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
533
- console.log(` …then open ${url}.`);
582
+ console.log(` then open ${url}.`);
534
583
  }
535
584
  console.log("");
536
585
  openBrowser(url);
@@ -262,33 +262,82 @@ const aiSettings = () =>
262
262
  </div>
263
263
  </details>`;
264
264
 
265
- mount(
266
- "#app",
267
- available.length
268
- ? html`<div class="card-x p-4 mb-3">
269
- <h2 class="h6 mb-3">Features</h2>
270
- ${available.map(addonRow)}
271
- <p class="small text-muted mb-0">Enabling a feature wires its backend automatically. Frontend UI (login form, chat) is yours to build — or start from <code>--template guestbook</code>.</p>
272
- </div>`
273
- : null,
274
- html`<div class="card-x p-4 mb-3">
275
- <h2 class="h6 mb-3">Settings</h2>
276
- ${field("PORT", "port", String(defaultPort))}
277
- ${field("SITE_NAME", "siteName", "My Site")}
278
- ${() => (hasContent() ? themePicker() : null)}
279
- ${() => (hasDb() ? dbSettings() : null)}
280
- ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
281
- ${() => (hasMedia() ? mediaSettings() : null)}
282
- ${aiSettings()}
283
- ${field("SITE_URL (optional absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
284
- ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
285
- </div>`,
265
+ // --- Manage content (a second screen reached via "Manage content →") ---
266
+ const view = signal("config"); // "config" | "manage"
267
+ const items = signal({ pages: [], posts: [] });
268
+ const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
269
+ const loadItems = async () => items(await (await fetch("/setup/content")).json());
270
+ async function editItem(type, slug) {
271
+ const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
272
+ editing({ type, slug, body: d.body || "", isNew: false });
273
+ }
274
+ function newItem(type) {
275
+ const body = type === "post" ? "---\ntitle: New Post\ndate: 2026-01-01\ncategory: \ntags: \n---\n\nWrite your post here.\n" : "---\ntitle: New Page\n---\n\nWrite your page here.\n";
276
+ editing({ type, slug: "", body, isNew: true });
277
+ }
278
+ async function saveItem() {
279
+ const e = editing();
280
+ const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
281
+ const body = document.querySelector("#mg-body").value;
282
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
283
+ const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
284
+ if (!r.ok) return status("Error: " + (r.error || "?"));
285
+ status("Saved → " + r.file);
286
+ editing(null);
287
+ loadItems();
288
+ }
289
+ async function delItem(type, slug) {
290
+ if (typeof confirm === "function" && !confirm(`Delete ${slug}?`)) return;
291
+ await fetch("/setup/content/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, slug }) });
292
+ status("Deleted " + slug);
293
+ loadItems();
294
+ }
295
+
296
+ const itemRow = (it) =>
297
+ html`<li class="list-group-item bg-transparent text-light d-flex justify-content-between align-items-center py-1 px-2">
298
+ <span><a href=${"http://localhost:" + state().port + (it.type === "post" ? "/blog/" : "/") + it.slug} target="_blank" rel="noopener">${it.title}</a> <span class="text-muted small">/${it.type === "post" ? "blog/" : ""}${it.slug}</span></span>
299
+ <span><button class="btn btn-sm btn-link p-0 me-3" onclick=${() => editItem(it.type, it.slug)}>edit</button><button class="btn btn-sm btn-link p-0 text-danger" onclick=${() => delItem(it.type, it.slug)}>delete</button></span>
300
+ </li>`;
301
+ const section = (label, type, key) =>
302
+ html`<div class="mb-3">
303
+ <div class="d-flex justify-content-between align-items-center mb-1"><strong>${label}</strong><button class="btn btn-sm btn-outline-secondary" onclick=${() => newItem(type)}>+ New</button></div>
304
+ ${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
305
+ </div>`;
306
+ const editorPanel = () => {
307
+ const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
308
+ return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
309
+ <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
310
+ <textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px">${e.body}</textarea>
311
+ <div class="mt-2 d-flex gap-2"><button class="btn btn-primary btn-sm" onclick=${saveItem}>Save</button><button class="btn btn-outline-secondary btn-sm" onclick=${() => editing(null)}>Cancel</button></div>
312
+ </div>`;
313
+ };
314
+ const manageView = () =>
286
315
  html`<div class="card-x p-4 mb-3">
287
- <div class="d-flex justify-content-between align-items-center mb-2">
288
- <h2 class="h6 mb-0">.env</h2>
289
- <button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button>
316
+ <div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h6 mb-0">Manage content</h2><button class="btn btn-sm btn-outline-secondary" onclick=${() => view("config")}>← Settings</button></div>
317
+ ${() => (editing() ? editorPanel() : html`${section("Pages", "page", "pages")}${section("Posts", "post", "posts")}<p class="small text-muted mb-0">Pages → <code>/slug</code>, posts → <code>/blog/slug</code>; <code>index</code> page is your home. All rendered in your theme. Edits hot-reload the running app.</p>`)}
318
+ </div>`;
319
+
320
+ const configView = () =>
321
+ html`${available.length ? html`<div class="card-x p-4 mb-3"><h2 class="h6 mb-3">Features</h2>${available.map(addonRow)}<p class="small text-muted mb-0">Enabling a feature wires its backend automatically. Frontend UI (login form, chat) is yours to build — or start from <code>--template guestbook</code>.</p></div>` : ""}
322
+ <div class="card-x p-4 mb-3">
323
+ <h2 class="h6 mb-3">Settings</h2>
324
+ ${field("PORT", "port", String(defaultPort))}
325
+ ${field("SITE_NAME", "siteName", "My Site")}
326
+ ${() => (hasContent() ? themePicker() : null)}
327
+ ${() => (hasDb() ? dbSettings() : null)}
328
+ ${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
329
+ ${() => (hasMedia() ? mediaSettings() : null)}
330
+ ${aiSettings()}
331
+ ${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
332
+ ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
290
333
  </div>
291
- <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
292
- </div>`,
334
+ <div class="card-x p-4 mb-3">
335
+ <div class="d-flex justify-content-between align-items-center mb-2"><h2 class="h6 mb-0">.env</h2><div class="d-flex gap-2">${() => (hasContent() ? html`<button class="btn btn-outline-light btn-sm" onclick=${() => (view("manage"), loadItems())}>Manage content →</button>` : "")}<button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button></div></div>
336
+ <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
337
+ </div>`;
338
+
339
+ mount(
340
+ "#app",
341
+ () => (view() === "config" ? configView() : manageView()),
293
342
  () => (status() ? html`<p class="small accent">${status}</p>` : null),
294
343
  );