create-volt 0.40.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
  );
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -7,7 +7,11 @@
7
7
  "main": "server.js",
8
8
  "scripts": {
9
9
  "start": "node server.js",
10
- "dev": "node server.js"
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs"
11
15
  },
12
16
  "dependencies": {
13
17
  "express": "^4.22.2",
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -7,7 +7,11 @@
7
7
  "main": "server.js",
8
8
  "scripts": {
9
9
  "start": "node server.js",
10
- "dev": "node server.js"
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs"
11
15
  },
12
16
  "dependencies": {
13
17
  "express": "^4.22.2",
@@ -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.
@@ -25,7 +25,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
25
25
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
26
26
  const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
27
27
 
28
- // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
28
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run lets
29
29
  // --edit/--studio dodge a port the running app already holds, and runs the app
30
30
  // itself on a one-off port. Explicit flag wins over PORT in .env.
31
31
  function cliPort() {
@@ -105,7 +105,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
105
105
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
106
106
 
107
107
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
108
- // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
108
+ // a third-party add-on a local .volt/addons/<name>/index.js or an installed
109
109
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
110
110
  const BUILTINS = new Set(Object.keys(LIB_FILE));
111
111
  async function loadAddon(name) {
@@ -129,7 +129,7 @@ function openBrowser(url) {
129
129
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
130
130
  try {
131
131
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
132
- child.on("error", () => {}); // launcher missing — emits async, don't crash
132
+ child.on("error", () => {}); // launcher missing emits async, don't crash
133
133
  child.unref();
134
134
  return true;
135
135
  } catch {
@@ -157,7 +157,7 @@ async function startApp() {
157
157
  if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
158
158
  if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
159
159
 
160
- // notes — a per-user CRUD example (auth-gated, owner-scoped, db-backed)
160
+ // notes a per-user CRUD example (auth-gated, owner-scoped, db-backed)
161
161
  if (enabled.has("db") && enabled.has("auth") && store) {
162
162
  const guard = (await addonMod("auth")).requireAuth(store);
163
163
  const notes = store.collection("notes");
@@ -201,8 +201,8 @@ async function startApp() {
201
201
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
202
202
  }
203
203
 
204
- // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
205
- // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
204
+ // markdown pages (/<slug> pages/<slug>.md) mounted last, so app routes win
205
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) before pages so /blog wins; renders in the same theme.
206
206
  if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
207
207
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
208
208
 
@@ -210,7 +210,14 @@ async function startApp() {
210
210
  const io = new SocketServer(server);
211
211
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
212
212
 
213
- // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
213
+ // Reload connected browsers on demand used when a second `npm run dev` finds
214
+ // the app already running (see the EADDRINUSE handler below) instead of crashing.
215
+ app.get("/__volt/reload", (_req, res) => {
216
+ io.emit("volt:reload", { file: "__manual__" });
217
+ res.json({ ok: true });
218
+ });
219
+
220
+ // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
214
221
  // are provided so add-ons can gate routes by login.
215
222
  let requireAuth = null;
216
223
  let sessionFromReq = null;
@@ -226,7 +233,7 @@ async function startApp() {
226
233
  if (typeof register === "function") {
227
234
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
228
235
  } else {
229
- console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
236
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export skipped`);
230
237
  }
231
238
  }
232
239
 
@@ -234,7 +241,7 @@ async function startApp() {
234
241
  const onChange = (file) => {
235
242
  clearTimeout(timer);
236
243
  timer = setTimeout(() => {
237
- console.log(`[volt] change: ${file ?? "?"} → reload`);
244
+ console.log(`[volt] change: ${file ?? "?"} reload`);
238
245
  io.emit("volt:reload", { file });
239
246
  }, 80);
240
247
  };
@@ -263,7 +270,21 @@ async function startApp() {
263
270
  }
264
271
 
265
272
  const on = [...enabled];
266
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
273
+ // If the port's taken, the app is likely already running reload it (tell the
274
+ // running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
275
+ server.on("error", async (e) => {
276
+ if (e.code === "EADDRINUSE") {
277
+ try {
278
+ await fetch(`http://localhost:${PORT}/__volt/reload`);
279
+ } catch {
280
+ /* old instance without the reload route, or not ours */
281
+ }
282
+ console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
283
+ process.exit(0);
284
+ }
285
+ throw e;
286
+ });
287
+ server.listen(PORT, () => console.log(`Volt at http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
267
288
  }
268
289
 
269
290
  // Packages an .env's selections need, beyond what package.json already has.
@@ -287,7 +308,7 @@ function neededPackages(env) {
287
308
  function ensureDriverInstalled(driver) {
288
309
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
289
310
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
290
- console.log(`[volt] installing ${pkg} for the connection test…`);
311
+ console.log(`[volt] installing ${pkg} for the connection test…`);
291
312
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
292
313
  }
293
314
 
@@ -317,6 +338,55 @@ function startSetup() {
317
338
  res.setHeader("Content-Type", "application/json");
318
339
  return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
319
340
  }
341
+ // --- content manager: list / read / write / delete pages + posts ---
342
+ if (req.method === "GET" && p === "/setup/content") {
343
+ const list = (type) => {
344
+ const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
345
+ if (!fs.existsSync(dir)) return [];
346
+ return fs
347
+ .readdirSync(dir)
348
+ .filter((f) => f.endsWith(".md") && !f.startsWith("_"))
349
+ .map((f) => {
350
+ const slug = f.replace(/\.md$/, "");
351
+ const title = (fs.readFileSync(path.join(dir, f), "utf8").match(/^title:\s*(.+)$/m) || [])[1];
352
+ return { type, slug, title: (title || slug).trim() };
353
+ });
354
+ };
355
+ res.setHeader("Content-Type", "application/json");
356
+ return res.end(JSON.stringify({ pages: list("page"), posts: list("post") }));
357
+ }
358
+ if (req.method === "GET" && p === "/setup/content/raw") {
359
+ const type = u.searchParams.get("type") === "post" ? "posts" : "pages";
360
+ const slug = u.searchParams.get("slug") || "";
361
+ res.setHeader("Content-Type", "application/json");
362
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return res.end(JSON.stringify({ ok: false, error: "invalid slug" }));
363
+ const file = path.join(__dirname, type, slug + ".md");
364
+ return res.end(JSON.stringify({ ok: true, body: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" }));
365
+ }
366
+ if (req.method === "POST" && (p === "/setup/content/save" || p === "/setup/content/delete")) {
367
+ let cbody = "";
368
+ req.on("data", (c) => (cbody += c));
369
+ req.on("end", () => {
370
+ res.setHeader("Content-Type", "application/json");
371
+ try {
372
+ const { type, slug, body } = JSON.parse(cbody || "{}");
373
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug || "")) throw new Error("slug: lowercase letters, numbers, hyphens");
374
+ const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
375
+ const file = path.join(dir, slug + ".md");
376
+ if (p === "/setup/content/delete") {
377
+ if (fs.existsSync(file)) fs.unlinkSync(file);
378
+ return res.end(JSON.stringify({ ok: true }));
379
+ }
380
+ fs.mkdirSync(dir, { recursive: true });
381
+ fs.writeFileSync(file, String(body ?? ""));
382
+ res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
383
+ } catch (e) {
384
+ res.statusCode = 400;
385
+ res.end(JSON.stringify({ ok: false, error: e.message }));
386
+ }
387
+ });
388
+ return;
389
+ }
320
390
  // "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
321
391
  if (req.method === "POST" && p === "/setup/eject-theme") {
322
392
  let body = "";
@@ -410,15 +480,15 @@ function startSetup() {
410
480
  server.closeIdleConnections?.();
411
481
  };
412
482
  if (added.length) {
413
- console.log(`[volt] installing ${added.join(", ")}…`);
483
+ console.log(`[volt] installing ${added.join(", ")}…`);
414
484
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
415
485
  npm.on("error", () => handoff());
416
486
  npm.on("close", () => {
417
- console.log("[volt] saved .env — starting the app…");
487
+ console.log("[volt] saved .env starting the app");
418
488
  handoff();
419
489
  });
420
490
  } else {
421
- console.log("[volt] saved .env — starting the app…");
491
+ console.log("[volt] saved .env starting the app");
422
492
  handoff();
423
493
  }
424
494
  });
@@ -436,18 +506,18 @@ function startSetup() {
436
506
  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; });
437
507
  server.listen(PORT, "127.0.0.1", () => {
438
508
  const url = `http://localhost:${PORT}`;
439
- console.log(`\n⚡ Volt setup → ${url}`);
509
+ console.log(`\nVolt setup at ${url}`);
440
510
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
441
511
  const ssh = process.env.SSH_CONNECTION;
442
512
  if (ssh) {
443
513
  const host = ssh.split(" ")[2];
444
514
  const user = process.env.USER || process.env.USERNAME || "you";
445
- console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
515
+ console.log(" Remote box the server is up here; bridge it from your LOCAL machine:");
446
516
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
447
- console.log(` …then open ${url} on your machine (the tunnel points it here).`);
517
+ console.log(` then open ${url} on your machine (the tunnel points it here).`);
448
518
  }
449
519
  console.log("");
450
- if (openBrowser(url)) console.log(" (opening your browser…)\n");
520
+ if (openBrowser(url)) console.log(" (opening your browser)\n");
451
521
  });
452
522
  }
453
523
 
@@ -455,8 +525,8 @@ function readEnvFileLines() {
455
525
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
456
526
  }
457
527
 
458
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
459
- // Not a route in the running app — it only exists while you run `--studio`, on
528
+ // --- Studio: an ephemeral, localhost-only data browser (la Prisma Studio).
529
+ // Not a route in the running app it only exists while you run `--studio`, on
460
530
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
461
531
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
462
532
  async function startStudio() {
@@ -469,7 +539,7 @@ async function startStudio() {
469
539
  try {
470
540
  store = await (await addonMod("db")).createStore();
471
541
  } catch (e) {
472
- console.error("Studio: couldn't connect the store — " + e.message);
542
+ console.error("Studio: couldn't connect the store " + e.message);
473
543
  process.exit(1);
474
544
  }
475
545
  const PORT = configPort();
@@ -527,15 +597,15 @@ async function startStudio() {
527
597
  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; });
528
598
  server.listen(PORT, "127.0.0.1", () => {
529
599
  const url = `http://localhost:${PORT}`;
530
- console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
531
- console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
600
+ console.log(`\nVolt Studio at ${url} (${store.name})`);
601
+ console.log(" Browse your data. localhost-only, disposable Ctrl-C when done.");
532
602
  const ssh = process.env.SSH_CONNECTION;
533
603
  if (ssh) {
534
604
  const host = ssh.split(" ")[2];
535
605
  const user = process.env.USER || process.env.USERNAME || "you";
536
- console.log(" Remote box — from your LOCAL machine:");
606
+ console.log(" Remote box from your LOCAL machine:");
537
607
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
538
- console.log(` …then open ${url}.`);
608
+ console.log(` then open ${url}.`);
539
609
  }
540
610
  console.log("");
541
611
  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
  );