create-volt 0.41.0 → 0.43.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.
- package/CHANGELOG.md +28 -0
- package/index.js +30 -12
- package/package.json +1 -1
- package/templates/blog/server.js +103 -26
- package/templates/blog/setup/setup.js +100 -30
- package/templates/default/server.js +103 -26
- package/templates/default/setup/setup.js +100 -30
- package/templates/docs/server.js +103 -26
- package/templates/docs/setup/setup.js +100 -30
- package/templates/starter/server.js +104 -27
- package/templates/starter/setup/setup.js +100 -30
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// server.js
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
205
|
-
// blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml)
|
|
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
|
|
|
@@ -217,7 +217,7 @@ async function startApp() {
|
|
|
217
217
|
res.json({ ok: true });
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
// third-party add-ons
|
|
220
|
+
// third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
|
|
221
221
|
// are provided so add-ons can gate routes by login.
|
|
222
222
|
let requireAuth = null;
|
|
223
223
|
let sessionFromReq = null;
|
|
@@ -233,7 +233,7 @@ async function startApp() {
|
|
|
233
233
|
if (typeof register === "function") {
|
|
234
234
|
await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
|
|
235
235
|
} else {
|
|
236
|
-
console.warn(`[volt] add-on "${name}" not found or missing a register() export
|
|
236
|
+
console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
@@ -241,7 +241,7 @@ async function startApp() {
|
|
|
241
241
|
const onChange = (file) => {
|
|
242
242
|
clearTimeout(timer);
|
|
243
243
|
timer = setTimeout(() => {
|
|
244
|
-
console.log(`[volt] change: ${file ?? "?"}
|
|
244
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
245
245
|
io.emit("volt:reload", { file });
|
|
246
246
|
}, 80);
|
|
247
247
|
};
|
|
@@ -284,7 +284,7 @@ async function startApp() {
|
|
|
284
284
|
}
|
|
285
285
|
throw e;
|
|
286
286
|
});
|
|
287
|
-
server.listen(PORT, () => console.log(
|
|
287
|
+
server.listen(PORT, () => console.log(`Volt at http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
// Packages an .env's selections need, beyond what package.json already has.
|
|
@@ -308,7 +308,7 @@ function neededPackages(env) {
|
|
|
308
308
|
function ensureDriverInstalled(driver) {
|
|
309
309
|
const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
|
|
310
310
|
if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
|
|
311
|
-
console.log(`[volt] installing ${pkg} for the connection test
|
|
311
|
+
console.log(`[volt] installing ${pkg} for the connection test…`);
|
|
312
312
|
spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
313
313
|
}
|
|
314
314
|
|
|
@@ -338,6 +338,83 @@ function startSetup() {
|
|
|
338
338
|
res.setHeader("Content-Type", "application/json");
|
|
339
339
|
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
340
340
|
}
|
|
341
|
+
// --- upgrade: compare .volt/version to npm latest, and run the update ---
|
|
342
|
+
if (req.method === "GET" && p === "/setup/upgrade-check") {
|
|
343
|
+
const vf = path.join(__dirname, ".volt", "version");
|
|
344
|
+
const current = (fs.existsSync(vf) ? fs.readFileSync(vf, "utf8").trim() : "") || "?";
|
|
345
|
+
fetch("https://registry.npmjs.org/create-volt/latest")
|
|
346
|
+
.then((r) => r.json())
|
|
347
|
+
.then((j) => {
|
|
348
|
+
const latest = j.version || "?";
|
|
349
|
+
res.setHeader("Content-Type", "application/json");
|
|
350
|
+
res.end(JSON.stringify({ current, latest, available: latest !== "?" && current !== "?" && latest !== current }));
|
|
351
|
+
})
|
|
352
|
+
.catch(() => {
|
|
353
|
+
res.setHeader("Content-Type", "application/json");
|
|
354
|
+
res.end(JSON.stringify({ current, latest: "?", available: false }));
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (req.method === "POST" && p === "/setup/upgrade") {
|
|
359
|
+
res.setHeader("Content-Type", "application/json");
|
|
360
|
+
try {
|
|
361
|
+
const r = spawnSync("npx", ["--yes", "create-volt@latest", "update"], { cwd: __dirname, encoding: "utf8", shell: process.platform === "win32" });
|
|
362
|
+
res.end(JSON.stringify({ ok: r.status === 0, output: ((r.stdout || "") + (r.stderr || "")).slice(-2000) }));
|
|
363
|
+
} catch (e) {
|
|
364
|
+
res.statusCode = 500;
|
|
365
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// --- content manager: list / read / write / delete pages + posts ---
|
|
370
|
+
if (req.method === "GET" && p === "/setup/content") {
|
|
371
|
+
const list = (type) => {
|
|
372
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
373
|
+
if (!fs.existsSync(dir)) return [];
|
|
374
|
+
return fs
|
|
375
|
+
.readdirSync(dir)
|
|
376
|
+
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
|
377
|
+
.map((f) => {
|
|
378
|
+
const slug = f.replace(/\.md$/, "");
|
|
379
|
+
const title = (fs.readFileSync(path.join(dir, f), "utf8").match(/^title:\s*(.+)$/m) || [])[1];
|
|
380
|
+
return { type, slug, title: (title || slug).trim() };
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
res.setHeader("Content-Type", "application/json");
|
|
384
|
+
return res.end(JSON.stringify({ pages: list("page"), posts: list("post") }));
|
|
385
|
+
}
|
|
386
|
+
if (req.method === "GET" && p === "/setup/content/raw") {
|
|
387
|
+
const type = u.searchParams.get("type") === "post" ? "posts" : "pages";
|
|
388
|
+
const slug = u.searchParams.get("slug") || "";
|
|
389
|
+
res.setHeader("Content-Type", "application/json");
|
|
390
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return res.end(JSON.stringify({ ok: false, error: "invalid slug" }));
|
|
391
|
+
const file = path.join(__dirname, type, slug + ".md");
|
|
392
|
+
return res.end(JSON.stringify({ ok: true, body: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "" }));
|
|
393
|
+
}
|
|
394
|
+
if (req.method === "POST" && (p === "/setup/content/save" || p === "/setup/content/delete")) {
|
|
395
|
+
let cbody = "";
|
|
396
|
+
req.on("data", (c) => (cbody += c));
|
|
397
|
+
req.on("end", () => {
|
|
398
|
+
res.setHeader("Content-Type", "application/json");
|
|
399
|
+
try {
|
|
400
|
+
const { type, slug, body } = JSON.parse(cbody || "{}");
|
|
401
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug || "")) throw new Error("slug: lowercase letters, numbers, hyphens");
|
|
402
|
+
const dir = path.join(__dirname, type === "post" ? "posts" : "pages");
|
|
403
|
+
const file = path.join(dir, slug + ".md");
|
|
404
|
+
if (p === "/setup/content/delete") {
|
|
405
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
406
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
407
|
+
}
|
|
408
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
409
|
+
fs.writeFileSync(file, String(body ?? ""));
|
|
410
|
+
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
411
|
+
} catch (e) {
|
|
412
|
+
res.statusCode = 400;
|
|
413
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
341
418
|
// "Customize": copy a bundled theme into pages/_theme.js so it can be edited.
|
|
342
419
|
if (req.method === "POST" && p === "/setup/eject-theme") {
|
|
343
420
|
let body = "";
|
|
@@ -431,15 +508,15 @@ function startSetup() {
|
|
|
431
508
|
server.closeIdleConnections?.();
|
|
432
509
|
};
|
|
433
510
|
if (added.length) {
|
|
434
|
-
console.log(`[volt] installing ${added.join(", ")}
|
|
511
|
+
console.log(`[volt] installing ${added.join(", ")}…`);
|
|
435
512
|
const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
436
513
|
npm.on("error", () => handoff());
|
|
437
514
|
npm.on("close", () => {
|
|
438
|
-
console.log("[volt] saved .env
|
|
515
|
+
console.log("[volt] saved .env — starting the app…");
|
|
439
516
|
handoff();
|
|
440
517
|
});
|
|
441
518
|
} else {
|
|
442
|
-
console.log("[volt] saved .env
|
|
519
|
+
console.log("[volt] saved .env — starting the app…");
|
|
443
520
|
handoff();
|
|
444
521
|
}
|
|
445
522
|
});
|
|
@@ -457,18 +534,18 @@ function startSetup() {
|
|
|
457
534
|
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; });
|
|
458
535
|
server.listen(PORT, "127.0.0.1", () => {
|
|
459
536
|
const url = `http://localhost:${PORT}`;
|
|
460
|
-
console.log(`\
|
|
537
|
+
console.log(`\nVolt setup at ${url}`);
|
|
461
538
|
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
462
539
|
const ssh = process.env.SSH_CONNECTION;
|
|
463
540
|
if (ssh) {
|
|
464
541
|
const host = ssh.split(" ")[2];
|
|
465
542
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
466
|
-
console.log(" Remote box
|
|
543
|
+
console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
|
|
467
544
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
468
|
-
console.log(`
|
|
545
|
+
console.log(` …then open ${url} on your machine (the tunnel points it here).`);
|
|
469
546
|
}
|
|
470
547
|
console.log("");
|
|
471
|
-
if (openBrowser(url)) console.log(" (opening your browser
|
|
548
|
+
if (openBrowser(url)) console.log(" (opening your browser…)\n");
|
|
472
549
|
});
|
|
473
550
|
}
|
|
474
551
|
|
|
@@ -476,8 +553,8 @@ function readEnvFileLines() {
|
|
|
476
553
|
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
477
554
|
}
|
|
478
555
|
|
|
479
|
-
// --- Studio: an ephemeral, localhost-only data browser (
|
|
480
|
-
// Not a route in the running app
|
|
556
|
+
// --- Studio: an ephemeral, localhost-only data browser (— la Prisma Studio).
|
|
557
|
+
// Not a route in the running app — it only exists while you run `--studio`, on
|
|
481
558
|
// loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
|
|
482
559
|
const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
|
|
483
560
|
async function startStudio() {
|
|
@@ -490,7 +567,7 @@ async function startStudio() {
|
|
|
490
567
|
try {
|
|
491
568
|
store = await (await addonMod("db")).createStore();
|
|
492
569
|
} catch (e) {
|
|
493
|
-
console.error("Studio: couldn't connect the store
|
|
570
|
+
console.error("Studio: couldn't connect the store — " + e.message);
|
|
494
571
|
process.exit(1);
|
|
495
572
|
}
|
|
496
573
|
const PORT = configPort();
|
|
@@ -548,15 +625,15 @@ async function startStudio() {
|
|
|
548
625
|
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; });
|
|
549
626
|
server.listen(PORT, "127.0.0.1", () => {
|
|
550
627
|
const url = `http://localhost:${PORT}`;
|
|
551
|
-
console.log(`\
|
|
552
|
-
console.log(" Browse your data. localhost-only, disposable
|
|
628
|
+
console.log(`\nVolt Studio at ${url} (${store.name})`);
|
|
629
|
+
console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
|
|
553
630
|
const ssh = process.env.SSH_CONNECTION;
|
|
554
631
|
if (ssh) {
|
|
555
632
|
const host = ssh.split(" ")[2];
|
|
556
633
|
const user = process.env.USER || process.env.USERNAME || "you";
|
|
557
|
-
console.log(" Remote box
|
|
634
|
+
console.log(" Remote box — from your LOCAL machine:");
|
|
558
635
|
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
559
|
-
console.log(`
|
|
636
|
+
console.log(` …then open ${url}.`);
|
|
560
637
|
}
|
|
561
638
|
console.log("");
|
|
562
639
|
openBrowser(url);
|
|
@@ -249,46 +249,116 @@ const themePicker = () =>
|
|
|
249
249
|
</div>`;
|
|
250
250
|
|
|
251
251
|
// AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
|
|
252
|
+
const AI_KEY_URL = {
|
|
253
|
+
anthropic: "https://console.anthropic.com/settings/keys",
|
|
254
|
+
openai: "https://platform.openai.com/api-keys",
|
|
255
|
+
gemini: "https://aistudio.google.com/app/apikey",
|
|
256
|
+
};
|
|
252
257
|
const aiSettings = () =>
|
|
253
|
-
html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI
|
|
258
|
+
html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI assistant for the editor (optional)</summary>
|
|
254
259
|
<div class="mt-2">
|
|
255
|
-
<
|
|
256
|
-
<
|
|
260
|
+
<p class="small text-muted mb-2">Powers the WYSIWYG editor's "write with AI" button. <strong>Totally optional</strong> — leave the key blank and the editor still works, just without AI.</p>
|
|
261
|
+
<label class="form-label small mb-1">Provider</label>
|
|
262
|
+
<select class="form-select mb-1" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
|
|
257
263
|
<option value="anthropic">Anthropic (Claude)</option>
|
|
258
264
|
<option value="openai">OpenAI</option>
|
|
259
265
|
<option value="gemini">Google Gemini</option>
|
|
260
266
|
</select>
|
|
261
|
-
${
|
|
267
|
+
${() => html`<a class="small d-inline-block mb-1" href=${AI_KEY_URL[state().aiProvider] || AI_KEY_URL.anthropic} target="_blank" rel="noopener">Get a ${state().aiProvider} key → paste it below (stays server-side in .env)</a>`}
|
|
268
|
+
${field("API key", "aiKey", "sk-…")}
|
|
262
269
|
</div>
|
|
263
270
|
</details>`;
|
|
264
271
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
272
|
+
// --- Manage content (a second screen reached via "Manage content →") ---
|
|
273
|
+
const view = signal("config"); // "config" | "manage"
|
|
274
|
+
// upgrade check: compare bundled version to npm latest; offer a one-click upgrade
|
|
275
|
+
const upgrade = signal(null); // { current, latest, available }
|
|
276
|
+
fetch("/setup/upgrade-check").then((r) => r.json()).then((u) => upgrade(u)).catch(() => {});
|
|
277
|
+
async function doUpgrade() {
|
|
278
|
+
status("Upgrading via npx create-volt@latest update…");
|
|
279
|
+
try {
|
|
280
|
+
const r = await (await fetch("/setup/upgrade", { method: "POST" })).json();
|
|
281
|
+
status(r.ok ? "Upgraded — restart the wizard/app to load the new version." : "Upgrade failed (see terminal).");
|
|
282
|
+
if (r.ok) upgrade({ ...upgrade(), available: false });
|
|
283
|
+
} catch {
|
|
284
|
+
status("Upgrade request failed.");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const items = signal({ pages: [], posts: [] });
|
|
288
|
+
const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
|
|
289
|
+
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
290
|
+
async function editItem(type, slug) {
|
|
291
|
+
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
292
|
+
editing({ type, slug, body: d.body || "", isNew: false });
|
|
293
|
+
}
|
|
294
|
+
function newItem(type) {
|
|
295
|
+
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";
|
|
296
|
+
editing({ type, slug: "", body, isNew: true });
|
|
297
|
+
}
|
|
298
|
+
async function saveItem() {
|
|
299
|
+
const e = editing();
|
|
300
|
+
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
301
|
+
const body = document.querySelector("#mg-body").value;
|
|
302
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
303
|
+
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
304
|
+
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
305
|
+
status("Saved → " + r.file);
|
|
306
|
+
editing(null);
|
|
307
|
+
loadItems();
|
|
308
|
+
}
|
|
309
|
+
async function delItem(type, slug) {
|
|
310
|
+
if (typeof confirm === "function" && !confirm(`Delete ${slug}?`)) return;
|
|
311
|
+
await fetch("/setup/content/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, slug }) });
|
|
312
|
+
status("Deleted " + slug);
|
|
313
|
+
loadItems();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const itemRow = (it) =>
|
|
317
|
+
html`<li class="list-group-item bg-transparent text-light d-flex justify-content-between align-items-center py-1 px-2">
|
|
318
|
+
<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>
|
|
319
|
+
<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>
|
|
320
|
+
</li>`;
|
|
321
|
+
const section = (label, type, key) =>
|
|
322
|
+
html`<div class="mb-3">
|
|
323
|
+
<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>
|
|
324
|
+
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
325
|
+
</div>`;
|
|
326
|
+
const editorPanel = () => {
|
|
327
|
+
const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
|
|
328
|
+
return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
|
|
329
|
+
<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>
|
|
330
|
+
<textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px">${e.body}</textarea>
|
|
331
|
+
<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>
|
|
332
|
+
</div>`;
|
|
333
|
+
};
|
|
334
|
+
const manageView = () =>
|
|
286
335
|
html`<div class="card-x p-4 mb-3">
|
|
287
|
-
<div class="d-flex justify-content-between align-items-center mb-
|
|
288
|
-
|
|
289
|
-
|
|
336
|
+
<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>
|
|
337
|
+
${() => (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>`)}
|
|
338
|
+
</div>`;
|
|
339
|
+
|
|
340
|
+
const configView = () =>
|
|
341
|
+
html`${() => (upgrade()?.available ? html`<div class="card-x p-3 mb-3 d-flex justify-content-between align-items-center"><span class="small">⬆ <strong>create-volt ${upgrade().latest}</strong> is available — you have ${upgrade().current}.</span><button class="btn btn-sm btn-primary" onclick=${doUpgrade}>Upgrade</button></div>` : "")}
|
|
342
|
+
${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>` : ""}
|
|
343
|
+
<div class="card-x p-4 mb-3">
|
|
344
|
+
<h2 class="h6 mb-3">Settings</h2>
|
|
345
|
+
${field("PORT", "port", String(defaultPort))}
|
|
346
|
+
${field("SITE_NAME", "siteName", "My Site")}
|
|
347
|
+
${() => (hasContent() ? themePicker() : null)}
|
|
348
|
+
${() => (hasDb() ? dbSettings() : null)}
|
|
349
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
350
|
+
${() => (hasMedia() ? mediaSettings() : null)}
|
|
351
|
+
${aiSettings()}
|
|
352
|
+
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
|
353
|
+
${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
|
|
290
354
|
</div>
|
|
291
|
-
<
|
|
292
|
-
|
|
355
|
+
<div class="card-x p-4 mb-3">
|
|
356
|
+
<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>
|
|
357
|
+
<pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
|
|
358
|
+
</div>`;
|
|
359
|
+
|
|
360
|
+
mount(
|
|
361
|
+
"#app",
|
|
362
|
+
() => (view() === "config" ? configView() : manageView()),
|
|
293
363
|
() => (status() ? html`<p class="small accent">${status}</p>` : null),
|
|
294
364
|
);
|