create-volt 0.55.1 → 0.56.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/index.js +1 -1
  3. package/package.json +1 -1
  4. package/templates/blog/public/volt.js +7 -2
  5. package/templates/blog/server.js +87 -3
  6. package/templates/blog/setup/setup.js +59 -10
  7. package/templates/business/Dockerfile +20 -0
  8. package/templates/business/Procfile +1 -0
  9. package/templates/business/README.md +25 -0
  10. package/templates/business/dockerignore +6 -0
  11. package/templates/business/ecosystem.config.cjs +5 -0
  12. package/templates/business/env +2 -0
  13. package/templates/business/fly.toml +15 -0
  14. package/templates/business/gitignore +5 -0
  15. package/templates/business/package.json +21 -0
  16. package/templates/business/pages/_theme.js +65 -0
  17. package/templates/business/pages/about.md +30 -0
  18. package/templates/business/pages/contact.md +23 -0
  19. package/templates/business/pages/index.md +41 -0
  20. package/templates/business/pages/products.md +27 -0
  21. package/templates/business/public/app.js +89 -0
  22. package/templates/business/public/favicon.webp +0 -0
  23. package/templates/business/public/logo.webp +0 -0
  24. package/templates/business/public/volt-ssr.js +63 -0
  25. package/templates/business/public/volt.js +355 -0
  26. package/templates/business/render.yaml +15 -0
  27. package/templates/business/server.js +1051 -0
  28. package/templates/business/setup/index.html +46 -0
  29. package/templates/business/setup/logs.html +29 -0
  30. package/templates/business/setup/logs.js +58 -0
  31. package/templates/business/setup/setup.js +509 -0
  32. package/templates/business/setup/studio.html +29 -0
  33. package/templates/business/views/index.html +42 -0
  34. package/templates/default/public/volt.js +7 -2
  35. package/templates/default/server.js +87 -3
  36. package/templates/default/setup/setup.js +59 -10
  37. package/templates/docs/public/volt.js +7 -2
  38. package/templates/docs/server.js +87 -3
  39. package/templates/docs/setup/setup.js +59 -10
  40. package/templates/guestbook/public/volt.js +7 -2
  41. package/templates/starter/public/volt.js +7 -2
  42. package/templates/starter/server.js +86 -3
  43. package/templates/starter/setup/setup.js +59 -10
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
7
+ <title>Volt Studio</title>
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
9
+ <style>
10
+ body { background: #0f1115; color: #e7e9ee; }
11
+ .accent { color: #ffd24a; }
12
+ .card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <main class="container py-5" style="max-width: 820px;">
17
+ <header class="mb-4">
18
+ <h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Volt Studio</span> <small class="text-muted">data browser</small></h1>
19
+ <p class="text-muted mb-0">Browse the database in your <code>.env</code>. Disposable &amp; localhost-only — close the terminal when done.</p>
20
+ </header>
21
+ <div id="app"></div>
22
+ </main>
23
+ <script type="module">
24
+ import { mount } from "/volt.js";
25
+ import { dbAdminPanel } from "/db-admin-ui.js";
26
+ mount("#app", dbAdminPanel());
27
+ </script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,42 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" href="/favicon.webp" />
7
+ <title>Volt — signals, no build, hot reload</title>
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
9
+ <style>
10
+ body { background: #0f1115; color: #e7e9ee; }
11
+ .brand { font-weight: 800; letter-spacing: -.02em; }
12
+ .accent { color: #ffd24a; }
13
+ .card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
14
+ .form-control, .input-group-text { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
15
+ .form-control:focus { background: #0f1115; color: #e7e9ee; border-color: #ffd24a; box-shadow: none; }
16
+ .list-group-item { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
17
+ a { color: #ffd24a; }
18
+ .text-muted { color: #9aa4b2 !important; } /* readable muted on the dark bg */
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <main class="container py-5" style="max-width: 720px;">
23
+ <header class="text-center mb-5">
24
+ <h1 class="brand display-5"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Volt</span></h1>
25
+ <p class="text-muted mb-0">
26
+ Fine-grained signals · no JSX · no virtual DOM · live hot reload.
27
+ Edit <code>public/app.js</code> and save — this page reloads itself.
28
+ </p>
29
+ </header>
30
+
31
+ <div id="app"></div>
32
+
33
+ <footer class="text-center text-muted mt-5">
34
+ <small><a href="https://github.com/MIR-2025/volt#readme" target="_blank" rel="noopener">📖 How to build a Volt app →</a></small>
35
+ </footer>
36
+ </main>
37
+
38
+ <!-- Socket.io client (auto-served by the dev server) powers hot reload. -->
39
+ <script src="/socket.io/socket.io.js"></script>
40
+ <script type="module" src="/app.js"></script>
41
+ </body>
42
+ </html>
@@ -125,14 +125,19 @@ export function mount(target, ...children) {
125
125
  return parent;
126
126
  }
127
127
 
128
+ // Boolean attributes: presence means "on", so a false/null/"false" value must turn
129
+ // them OFF (readonly="false" is still readonly in HTML). Set via the DOM property;
130
+ // readonly's property is readOnly, so map it.
131
+ const BOOL_ATTRS = new Set(["checked", "disabled", "selected", "readonly", "required", "multiple", "hidden", "autofocus", "open"]);
132
+ const BOOL_PROP = { readonly: "readOnly" };
128
133
  function setAttr(node, name, value) {
129
134
  if (name === "value") {
130
135
  const v = value ?? "";
131
136
  if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
132
137
  return;
133
138
  }
134
- if (name === "checked" || name === "disabled" || name === "selected") {
135
- node[name] = !!value && value !== "false";
139
+ if (BOOL_ATTRS.has(name)) {
140
+ node[BOOL_PROP[name] || name] = !!value && value !== "false";
136
141
  return;
137
142
  }
138
143
  if (value === false || value == null) {
@@ -11,6 +11,7 @@
11
11
  import http from "node:http";
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
+ import crypto from "node:crypto";
14
15
  import { spawn, spawnSync } from "node:child_process";
15
16
  import { fileURLToPath, pathToFileURL } from "node:url";
16
17
  import os from "node:os";
@@ -350,9 +351,9 @@ function startSetup() {
350
351
  const env = readEnvFile();
351
352
  const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
352
353
  fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
353
- .then((r) => r.json())
354
+ .then((r) => (r.ok ? r.json() : { ok: false, error: `hosted AI gateway not available (HTTP ${r.status}) — is it deployed?` }))
354
355
  .then((j) => res.end(JSON.stringify(j)))
355
- .catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
356
+ .catch(() => res.end(JSON.stringify({ ok: false, error: "hosted AI gateway unreachable" })));
356
357
  return;
357
358
  }
358
359
  // --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
@@ -466,6 +467,71 @@ function startSetup() {
466
467
  })();
467
468
  return;
468
469
  }
470
+ // --- media library: list / upload / delete files in public/media (served at
471
+ // /media/<name>). Shell-gated (config only). ---
472
+ if (req.method === "GET" && p === "/setup/media") {
473
+ res.setHeader("Content-Type", "application/json");
474
+ const dir = path.join(__dirname, "public", "media");
475
+ let items = [];
476
+ try {
477
+ items = fs
478
+ .readdirSync(dir)
479
+ .filter((f) => !f.startsWith("."))
480
+ .map((f) => ({ name: f, url: "/media/" + f, size: fs.statSync(path.join(dir, f)).size }))
481
+ .sort((a, b) => a.name.localeCompare(b.name));
482
+ } catch {
483
+ /* no media dir yet */
484
+ }
485
+ return res.end(JSON.stringify({ items }));
486
+ }
487
+ if (req.method === "POST" && p === "/setup/media/upload") {
488
+ res.setHeader("Content-Type", "application/json");
489
+ const name = (u.searchParams.get("name") || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120);
490
+ if (!name || !/\.[A-Za-z0-9]+$/.test(name)) return res.end(JSON.stringify({ ok: false, error: "bad filename" }));
491
+ const dir = path.join(__dirname, "public", "media");
492
+ fs.mkdirSync(dir, { recursive: true });
493
+ const chunks = [];
494
+ let size = 0;
495
+ let tooBig = false;
496
+ req.on("data", (c) => {
497
+ if (tooBig) return;
498
+ size += c.length;
499
+ if (size > 100 * 1024 * 1024) tooBig = true;
500
+ else chunks.push(c);
501
+ });
502
+ req.on("end", () => {
503
+ if (tooBig) {
504
+ res.statusCode = 413;
505
+ return res.end(JSON.stringify({ ok: false, error: "file too large (max 100MB)" }));
506
+ }
507
+ try {
508
+ fs.writeFileSync(path.join(dir, name), Buffer.concat(chunks));
509
+ res.end(JSON.stringify({ ok: true, url: "/media/" + name, name }));
510
+ } catch (e) {
511
+ res.statusCode = 400;
512
+ res.end(JSON.stringify({ ok: false, error: e.message }));
513
+ }
514
+ });
515
+ return;
516
+ }
517
+ if (req.method === "POST" && p === "/setup/media/delete") {
518
+ let mbody = "";
519
+ req.on("data", (c) => (mbody += c));
520
+ req.on("end", () => {
521
+ res.setHeader("Content-Type", "application/json");
522
+ try {
523
+ const { name } = JSON.parse(mbody || "{}");
524
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || "")) throw new Error("bad name");
525
+ const f = path.join(__dirname, "public", "media", name);
526
+ if (fs.existsSync(f)) fs.unlinkSync(f);
527
+ res.end(JSON.stringify({ ok: true }));
528
+ } catch (e) {
529
+ res.statusCode = 400;
530
+ res.end(JSON.stringify({ ok: false, error: e.message }));
531
+ }
532
+ });
533
+ return;
534
+ }
469
535
  // --- content manager: list / read / write / delete pages + posts ---
470
536
  if (req.method === "GET" && p === "/setup/content") {
471
537
  const list = (type) => {
@@ -506,7 +572,25 @@ function startSetup() {
506
572
  return res.end(JSON.stringify({ ok: true }));
507
573
  }
508
574
  fs.mkdirSync(dir, { recursive: true });
509
- fs.writeFileSync(file, String(body ?? ""));
575
+ // RTEPro's media picker inlines "Choose File" uploads as base64 data URLs.
576
+ // Extract them to public/media/<hash>.<ext> and rewrite the src, so pages
577
+ // stay lean and the uploads land in the media library.
578
+ const mediaDir = path.join(__dirname, "public", "media");
579
+ const extFor = (mime) =>
580
+ ({ "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/svg+xml": "svg", "video/mp4": "mp4", "video/webm": "webm", "video/ogg": "ogv", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav" }[mime.toLowerCase()] || (mime.split("/")[1] || "bin").replace(/[^a-z0-9]+/gi, "").slice(0, 8) || "bin");
581
+ const finalBody = String(body ?? "").replace(/(<(?:img|video|audio|source)\b[^>]*?\ssrc=")data:([\w.+-]+\/[\w.+-]+);base64,([^"]+)(")/gi, (m, pre, mime, b64, post) => {
582
+ try {
583
+ const buf = Buffer.from(b64, "base64");
584
+ const name = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 16) + "." + extFor(mime);
585
+ fs.mkdirSync(mediaDir, { recursive: true });
586
+ const dest = path.join(mediaDir, name);
587
+ if (!fs.existsSync(dest)) fs.writeFileSync(dest, buf);
588
+ return pre + "/media/" + name + post;
589
+ } catch {
590
+ return m;
591
+ }
592
+ });
593
+ fs.writeFileSync(file, finalBody);
510
594
  res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
511
595
  } catch (e) {
512
596
  res.statusCode = 400;
@@ -43,6 +43,7 @@ const status = signal("");
43
43
  const dbTest = signal("");
44
44
  const smtpTest = signal("");
45
45
  const aiTest = signal("");
46
+ const genMsg = signal("");
46
47
  const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
47
48
  const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
48
49
 
@@ -293,18 +294,66 @@ const aiSettings = () =>
293
294
  ${() => 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>`}
294
295
  ${field("API key", "aiKey", "sk-…")}
295
296
  <div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
296
- ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
297
+ ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>${() => testResult(genMsg())}`)}
297
298
  <div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
298
299
  </div>
299
300
  </details>`;
300
301
 
301
302
  // --- Manage content (a second screen reached via "Manage content →") ---
302
- const view = signal("config"); // "config" | "manage"
303
- // Desktop-only config: keep settings readable, but let the editor go wide.
303
+ const view = signal("config"); // "config" | "manage" | "media"
304
+ // Desktop-only config: keep settings readable, but let the editor + library go wide.
304
305
  effect(() => {
305
306
  const w = document.getElementById("wrap");
306
- if (w) w.style.maxWidth = view() === "manage" ? "min(1200px, 95vw)" : "720px";
307
+ if (w) w.style.maxWidth = view() !== "config" ? "min(1200px, 95vw)" : "720px";
307
308
  });
309
+ // --- media library: upload / browse / delete files served at /media/<name> ---
310
+ const media = signal([]);
311
+ const loadMedia = async () => media(((await (await fetch("/setup/media")).json()).items) || []);
312
+ async function uploadMedia(file) {
313
+ status(`Uploading ${file.name}…`);
314
+ try {
315
+ const r = await (await fetch("/setup/media/upload?name=" + encodeURIComponent(file.name), { method: "POST", body: file })).json();
316
+ status(r.ok ? `Uploaded → ${r.url}` : `Upload failed: ${r.error || "?"}`);
317
+ } catch {
318
+ status("Upload failed.");
319
+ }
320
+ loadMedia();
321
+ }
322
+ async function delMedia(name) {
323
+ if (typeof confirm === "function" && !confirm(`Delete ${name}?`)) return;
324
+ await fetch("/setup/media/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
325
+ loadMedia();
326
+ }
327
+ const isImg = (n) => /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(n);
328
+ const isVid = (n) => /\.(mp4|webm|mov|ogg|ogv|m4v)$/i.test(n);
329
+ const kb = (n) => (n < 1024 ? n + " B" : n < 1048576 ? Math.round(n / 1024) + " KB" : (n / 1048576).toFixed(1) + " MB");
330
+ const mediaThumb = (m) =>
331
+ isImg(m.name)
332
+ ? html`<img src=${m.url} loading="lazy" class="object-fit-cover" alt=${m.name} />`
333
+ : isVid(m.name)
334
+ ? html`<video src=${m.url} muted class="object-fit-cover"></video>`
335
+ : html`<div class="d-flex align-items-center justify-content-center text-white-50 small text-uppercase">${m.name.split(".").pop()}</div>`;
336
+ const mediaTile = (m) =>
337
+ html`<div class="col"><div class="card h-100 shadow-sm">
338
+ <div class="ratio ratio-4x3 bg-dark rounded-top overflow-hidden">${mediaThumb(m)}</div>
339
+ <div class="card-body p-2">
340
+ <div class="small text-truncate" title=${m.name}>${m.name}</div>
341
+ <div class="small text-muted mb-2">${kb(m.size)}</div>
342
+ <div class="btn-group btn-group-sm w-100" role="group">
343
+ <button type="button" class="btn btn-outline-secondary" onclick=${() => (navigator.clipboard && navigator.clipboard.writeText(m.url), status(`Copied ${m.url}`))}>Copy URL</button>
344
+ <button type="button" class="btn btn-outline-danger flex-grow-0" title="Delete" onclick=${() => delMedia(m.name)}>✕</button>
345
+ </div>
346
+ </div>
347
+ </div></div>`;
348
+ const mediaView = () =>
349
+ html`<div class="card">
350
+ <div class="card-header d-flex justify-content-between align-items-center"><h2 class="h6 mb-0">Media library</h2><button class="btn btn-sm btn-outline-secondary" onclick=${() => view("config")}>← Settings</button></div>
351
+ <div class="card-body">
352
+ <input type="file" class="form-control mb-2" accept="image/*,video/*" multiple onchange=${(e) => { for (const f of e.target.files) uploadMedia(f); e.target.value = ""; }} />
353
+ <p class="small text-muted">Uploads are stored in <code>public/media/</code> and served at <code>/media/&lt;name&gt;</code>. Copy a URL and paste it into a page's image slot in the editor. (Max 100&nbsp;MB per file.)</p>
354
+ ${() => (media().length ? html`<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 g-3">${media().map(mediaTile)}</div>` : html`<div class="text-muted small border rounded p-4 text-center">No media yet — upload above.</div>`)}
355
+ </div>
356
+ </div>`;
308
357
  // upgrade check: compare bundled version to npm latest; offer a one-click upgrade
309
358
  const upgrade = signal(null); // { current, latest, available }
310
359
  fetch("/setup/upgrade-check").then((r) => r.json()).then((u) => upgrade(u)).catch(() => {});
@@ -334,15 +383,15 @@ async function buyCredits(amountUsd) {
334
383
  }
335
384
  }
336
385
  async function genToken() {
337
- status("Requesting a hosted token…");
386
+ genMsg("Requesting…");
338
387
  try {
339
388
  const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
340
389
  if (r.ok && r.token) {
341
390
  set({ aiToken: r.token });
342
- status("Hosted token generated — click Apply to save it.");
343
- } else status("Could not get a token: " + (r.error || "?"));
391
+ genMsg(" token generated — Apply to save");
392
+ } else genMsg(" " + (r.error || "no token"));
344
393
  } catch {
345
- status("Token request failed.");
394
+ genMsg(" request failed");
346
395
  }
347
396
  }
348
397
  const items = signal({ pages: [], posts: [] });
@@ -449,12 +498,12 @@ const configView = () =>
449
498
  ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
450
499
  </div>
451
500
  <div class="card-x p-4 mb-3">
452
- <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>
501
+ <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-outline-light btn-sm" onclick=${() => (view("media"), loadMedia())}>Media →</button><button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button></div></div>
453
502
  <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
454
503
  </div>`;
455
504
 
456
505
  mount(
457
506
  "#app",
458
- () => (view() === "config" ? configView() : manageView()),
507
+ () => (view() === "config" ? configView() : view() === "media" ? mediaView() : manageView()),
459
508
  () => (status() ? html`<p class="small accent">${status}</p>` : null),
460
509
  );
@@ -125,14 +125,19 @@ export function mount(target, ...children) {
125
125
  return parent;
126
126
  }
127
127
 
128
+ // Boolean attributes: presence means "on", so a false/null/"false" value must turn
129
+ // them OFF (readonly="false" is still readonly in HTML). Set via the DOM property;
130
+ // readonly's property is readOnly, so map it.
131
+ const BOOL_ATTRS = new Set(["checked", "disabled", "selected", "readonly", "required", "multiple", "hidden", "autofocus", "open"]);
132
+ const BOOL_PROP = { readonly: "readOnly" };
128
133
  function setAttr(node, name, value) {
129
134
  if (name === "value") {
130
135
  const v = value ?? "";
131
136
  if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
132
137
  return;
133
138
  }
134
- if (name === "checked" || name === "disabled" || name === "selected") {
135
- node[name] = !!value && value !== "false";
139
+ if (BOOL_ATTRS.has(name)) {
140
+ node[BOOL_PROP[name] || name] = !!value && value !== "false";
136
141
  return;
137
142
  }
138
143
  if (value === false || value == null) {
@@ -11,6 +11,7 @@
11
11
  import http from "node:http";
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
+ import crypto from "node:crypto";
14
15
  import { spawn, spawnSync } from "node:child_process";
15
16
  import { fileURLToPath, pathToFileURL } from "node:url";
16
17
  import os from "node:os";
@@ -350,9 +351,9 @@ function startSetup() {
350
351
  const env = readEnvFile();
351
352
  const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
352
353
  fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
353
- .then((r) => r.json())
354
+ .then((r) => (r.ok ? r.json() : { ok: false, error: `hosted AI gateway not available (HTTP ${r.status}) — is it deployed?` }))
354
355
  .then((j) => res.end(JSON.stringify(j)))
355
- .catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
356
+ .catch(() => res.end(JSON.stringify({ ok: false, error: "hosted AI gateway unreachable" })));
356
357
  return;
357
358
  }
358
359
  // --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
@@ -466,6 +467,71 @@ function startSetup() {
466
467
  })();
467
468
  return;
468
469
  }
470
+ // --- media library: list / upload / delete files in public/media (served at
471
+ // /media/<name>). Shell-gated (config only). ---
472
+ if (req.method === "GET" && p === "/setup/media") {
473
+ res.setHeader("Content-Type", "application/json");
474
+ const dir = path.join(__dirname, "public", "media");
475
+ let items = [];
476
+ try {
477
+ items = fs
478
+ .readdirSync(dir)
479
+ .filter((f) => !f.startsWith("."))
480
+ .map((f) => ({ name: f, url: "/media/" + f, size: fs.statSync(path.join(dir, f)).size }))
481
+ .sort((a, b) => a.name.localeCompare(b.name));
482
+ } catch {
483
+ /* no media dir yet */
484
+ }
485
+ return res.end(JSON.stringify({ items }));
486
+ }
487
+ if (req.method === "POST" && p === "/setup/media/upload") {
488
+ res.setHeader("Content-Type", "application/json");
489
+ const name = (u.searchParams.get("name") || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120);
490
+ if (!name || !/\.[A-Za-z0-9]+$/.test(name)) return res.end(JSON.stringify({ ok: false, error: "bad filename" }));
491
+ const dir = path.join(__dirname, "public", "media");
492
+ fs.mkdirSync(dir, { recursive: true });
493
+ const chunks = [];
494
+ let size = 0;
495
+ let tooBig = false;
496
+ req.on("data", (c) => {
497
+ if (tooBig) return;
498
+ size += c.length;
499
+ if (size > 100 * 1024 * 1024) tooBig = true;
500
+ else chunks.push(c);
501
+ });
502
+ req.on("end", () => {
503
+ if (tooBig) {
504
+ res.statusCode = 413;
505
+ return res.end(JSON.stringify({ ok: false, error: "file too large (max 100MB)" }));
506
+ }
507
+ try {
508
+ fs.writeFileSync(path.join(dir, name), Buffer.concat(chunks));
509
+ res.end(JSON.stringify({ ok: true, url: "/media/" + name, name }));
510
+ } catch (e) {
511
+ res.statusCode = 400;
512
+ res.end(JSON.stringify({ ok: false, error: e.message }));
513
+ }
514
+ });
515
+ return;
516
+ }
517
+ if (req.method === "POST" && p === "/setup/media/delete") {
518
+ let mbody = "";
519
+ req.on("data", (c) => (mbody += c));
520
+ req.on("end", () => {
521
+ res.setHeader("Content-Type", "application/json");
522
+ try {
523
+ const { name } = JSON.parse(mbody || "{}");
524
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || "")) throw new Error("bad name");
525
+ const f = path.join(__dirname, "public", "media", name);
526
+ if (fs.existsSync(f)) fs.unlinkSync(f);
527
+ res.end(JSON.stringify({ ok: true }));
528
+ } catch (e) {
529
+ res.statusCode = 400;
530
+ res.end(JSON.stringify({ ok: false, error: e.message }));
531
+ }
532
+ });
533
+ return;
534
+ }
469
535
  // --- content manager: list / read / write / delete pages + posts ---
470
536
  if (req.method === "GET" && p === "/setup/content") {
471
537
  const list = (type) => {
@@ -506,7 +572,25 @@ function startSetup() {
506
572
  return res.end(JSON.stringify({ ok: true }));
507
573
  }
508
574
  fs.mkdirSync(dir, { recursive: true });
509
- fs.writeFileSync(file, String(body ?? ""));
575
+ // RTEPro's media picker inlines "Choose File" uploads as base64 data URLs.
576
+ // Extract them to public/media/<hash>.<ext> and rewrite the src, so pages
577
+ // stay lean and the uploads land in the media library.
578
+ const mediaDir = path.join(__dirname, "public", "media");
579
+ const extFor = (mime) =>
580
+ ({ "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/svg+xml": "svg", "video/mp4": "mp4", "video/webm": "webm", "video/ogg": "ogv", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav" }[mime.toLowerCase()] || (mime.split("/")[1] || "bin").replace(/[^a-z0-9]+/gi, "").slice(0, 8) || "bin");
581
+ const finalBody = String(body ?? "").replace(/(<(?:img|video|audio|source)\b[^>]*?\ssrc=")data:([\w.+-]+\/[\w.+-]+);base64,([^"]+)(")/gi, (m, pre, mime, b64, post) => {
582
+ try {
583
+ const buf = Buffer.from(b64, "base64");
584
+ const name = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 16) + "." + extFor(mime);
585
+ fs.mkdirSync(mediaDir, { recursive: true });
586
+ const dest = path.join(mediaDir, name);
587
+ if (!fs.existsSync(dest)) fs.writeFileSync(dest, buf);
588
+ return pre + "/media/" + name + post;
589
+ } catch {
590
+ return m;
591
+ }
592
+ });
593
+ fs.writeFileSync(file, finalBody);
510
594
  res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
511
595
  } catch (e) {
512
596
  res.statusCode = 400;
@@ -43,6 +43,7 @@ const status = signal("");
43
43
  const dbTest = signal("");
44
44
  const smtpTest = signal("");
45
45
  const aiTest = signal("");
46
+ const genMsg = signal("");
46
47
  const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
47
48
  const envObj = () => Object.fromEntries(env().split("\n").filter((l) => /^[A-Za-z0-9_]+=/.test(l)).map((l) => { const i = l.indexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
48
49
 
@@ -293,18 +294,66 @@ const aiSettings = () =>
293
294
  ${() => 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>`}
294
295
  ${field("API key", "aiKey", "sk-…")}
295
296
  <div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
296
- ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
297
+ ${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>${() => testResult(genMsg())}`)}
297
298
  <div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
298
299
  </div>
299
300
  </details>`;
300
301
 
301
302
  // --- Manage content (a second screen reached via "Manage content →") ---
302
- const view = signal("config"); // "config" | "manage"
303
- // Desktop-only config: keep settings readable, but let the editor go wide.
303
+ const view = signal("config"); // "config" | "manage" | "media"
304
+ // Desktop-only config: keep settings readable, but let the editor + library go wide.
304
305
  effect(() => {
305
306
  const w = document.getElementById("wrap");
306
- if (w) w.style.maxWidth = view() === "manage" ? "min(1200px, 95vw)" : "720px";
307
+ if (w) w.style.maxWidth = view() !== "config" ? "min(1200px, 95vw)" : "720px";
307
308
  });
309
+ // --- media library: upload / browse / delete files served at /media/<name> ---
310
+ const media = signal([]);
311
+ const loadMedia = async () => media(((await (await fetch("/setup/media")).json()).items) || []);
312
+ async function uploadMedia(file) {
313
+ status(`Uploading ${file.name}…`);
314
+ try {
315
+ const r = await (await fetch("/setup/media/upload?name=" + encodeURIComponent(file.name), { method: "POST", body: file })).json();
316
+ status(r.ok ? `Uploaded → ${r.url}` : `Upload failed: ${r.error || "?"}`);
317
+ } catch {
318
+ status("Upload failed.");
319
+ }
320
+ loadMedia();
321
+ }
322
+ async function delMedia(name) {
323
+ if (typeof confirm === "function" && !confirm(`Delete ${name}?`)) return;
324
+ await fetch("/setup/media/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) });
325
+ loadMedia();
326
+ }
327
+ const isImg = (n) => /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(n);
328
+ const isVid = (n) => /\.(mp4|webm|mov|ogg|ogv|m4v)$/i.test(n);
329
+ const kb = (n) => (n < 1024 ? n + " B" : n < 1048576 ? Math.round(n / 1024) + " KB" : (n / 1048576).toFixed(1) + " MB");
330
+ const mediaThumb = (m) =>
331
+ isImg(m.name)
332
+ ? html`<img src=${m.url} loading="lazy" class="object-fit-cover" alt=${m.name} />`
333
+ : isVid(m.name)
334
+ ? html`<video src=${m.url} muted class="object-fit-cover"></video>`
335
+ : html`<div class="d-flex align-items-center justify-content-center text-white-50 small text-uppercase">${m.name.split(".").pop()}</div>`;
336
+ const mediaTile = (m) =>
337
+ html`<div class="col"><div class="card h-100 shadow-sm">
338
+ <div class="ratio ratio-4x3 bg-dark rounded-top overflow-hidden">${mediaThumb(m)}</div>
339
+ <div class="card-body p-2">
340
+ <div class="small text-truncate" title=${m.name}>${m.name}</div>
341
+ <div class="small text-muted mb-2">${kb(m.size)}</div>
342
+ <div class="btn-group btn-group-sm w-100" role="group">
343
+ <button type="button" class="btn btn-outline-secondary" onclick=${() => (navigator.clipboard && navigator.clipboard.writeText(m.url), status(`Copied ${m.url}`))}>Copy URL</button>
344
+ <button type="button" class="btn btn-outline-danger flex-grow-0" title="Delete" onclick=${() => delMedia(m.name)}>✕</button>
345
+ </div>
346
+ </div>
347
+ </div></div>`;
348
+ const mediaView = () =>
349
+ html`<div class="card">
350
+ <div class="card-header d-flex justify-content-between align-items-center"><h2 class="h6 mb-0">Media library</h2><button class="btn btn-sm btn-outline-secondary" onclick=${() => view("config")}>← Settings</button></div>
351
+ <div class="card-body">
352
+ <input type="file" class="form-control mb-2" accept="image/*,video/*" multiple onchange=${(e) => { for (const f of e.target.files) uploadMedia(f); e.target.value = ""; }} />
353
+ <p class="small text-muted">Uploads are stored in <code>public/media/</code> and served at <code>/media/&lt;name&gt;</code>. Copy a URL and paste it into a page's image slot in the editor. (Max 100&nbsp;MB per file.)</p>
354
+ ${() => (media().length ? html`<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 g-3">${media().map(mediaTile)}</div>` : html`<div class="text-muted small border rounded p-4 text-center">No media yet — upload above.</div>`)}
355
+ </div>
356
+ </div>`;
308
357
  // upgrade check: compare bundled version to npm latest; offer a one-click upgrade
309
358
  const upgrade = signal(null); // { current, latest, available }
310
359
  fetch("/setup/upgrade-check").then((r) => r.json()).then((u) => upgrade(u)).catch(() => {});
@@ -334,15 +383,15 @@ async function buyCredits(amountUsd) {
334
383
  }
335
384
  }
336
385
  async function genToken() {
337
- status("Requesting a hosted token…");
386
+ genMsg("Requesting…");
338
387
  try {
339
388
  const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
340
389
  if (r.ok && r.token) {
341
390
  set({ aiToken: r.token });
342
- status("Hosted token generated — click Apply to save it.");
343
- } else status("Could not get a token: " + (r.error || "?"));
391
+ genMsg(" token generated — Apply to save");
392
+ } else genMsg(" " + (r.error || "no token"));
344
393
  } catch {
345
- status("Token request failed.");
394
+ genMsg(" request failed");
346
395
  }
347
396
  }
348
397
  const items = signal({ pages: [], posts: [] });
@@ -449,12 +498,12 @@ const configView = () =>
449
498
  ${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
450
499
  </div>
451
500
  <div class="card-x p-4 mb-3">
452
- <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>
501
+ <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-outline-light btn-sm" onclick=${() => (view("media"), loadMedia())}>Media →</button><button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button></div></div>
453
502
  <pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
454
503
  </div>`;
455
504
 
456
505
  mount(
457
506
  "#app",
458
- () => (view() === "config" ? configView() : manageView()),
507
+ () => (view() === "config" ? configView() : view() === "media" ? mediaView() : manageView()),
459
508
  () => (status() ? html`<p class="small accent">${status}</p>` : null),
460
509
  );
@@ -125,14 +125,19 @@ export function mount(target, ...children) {
125
125
  return parent;
126
126
  }
127
127
 
128
+ // Boolean attributes: presence means "on", so a false/null/"false" value must turn
129
+ // them OFF (readonly="false" is still readonly in HTML). Set via the DOM property;
130
+ // readonly's property is readOnly, so map it.
131
+ const BOOL_ATTRS = new Set(["checked", "disabled", "selected", "readonly", "required", "multiple", "hidden", "autofocus", "open"]);
132
+ const BOOL_PROP = { readonly: "readOnly" };
128
133
  function setAttr(node, name, value) {
129
134
  if (name === "value") {
130
135
  const v = value ?? "";
131
136
  if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
132
137
  return;
133
138
  }
134
- if (name === "checked" || name === "disabled" || name === "selected") {
135
- node[name] = !!value && value !== "false";
139
+ if (BOOL_ATTRS.has(name)) {
140
+ node[BOOL_PROP[name] || name] = !!value && value !== "false";
136
141
  return;
137
142
  }
138
143
  if (value === false || value == null) {
@@ -125,14 +125,19 @@ export function mount(target, ...children) {
125
125
  return parent;
126
126
  }
127
127
 
128
+ // Boolean attributes: presence means "on", so a false/null/"false" value must turn
129
+ // them OFF (readonly="false" is still readonly in HTML). Set via the DOM property;
130
+ // readonly's property is readOnly, so map it.
131
+ const BOOL_ATTRS = new Set(["checked", "disabled", "selected", "readonly", "required", "multiple", "hidden", "autofocus", "open"]);
132
+ const BOOL_PROP = { readonly: "readOnly" };
128
133
  function setAttr(node, name, value) {
129
134
  if (name === "value") {
130
135
  const v = value ?? "";
131
136
  if (node.value !== v) node.value = v; // skip redundant writes — they reset the caret while typing
132
137
  return;
133
138
  }
134
- if (name === "checked" || name === "disabled" || name === "selected") {
135
- node[name] = !!value && value !== "false";
139
+ if (BOOL_ATTRS.has(name)) {
140
+ node[BOOL_PROP[name] || name] = !!value && value !== "false";
136
141
  return;
137
142
  }
138
143
  if (value === false || value == null) {