create-volt 0.46.0 → 0.48.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 +21 -0
- package/package.json +1 -1
- package/templates/blog/setup/index.html +25 -6
- package/templates/blog/setup/setup.js +43 -11
- package/templates/default/setup/index.html +25 -6
- package/templates/default/setup/setup.js +43 -11
- package/templates/docs/setup/index.html +25 -6
- package/templates/docs/setup/setup.js +43 -11
- package/templates/starter/setup/index.html +25 -6
- package/templates/starter/setup/setup.js +43 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.48.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **WYSIWYG editor in the config.** Manage content embeds the RTEPro rich editor
|
|
11
|
+
(loaded from CDN) instead of a raw textarea — visual editing, shell-gated, no
|
|
12
|
+
public route or auth. Opens markdown rendered to HTML, saves markdown (or HTML
|
|
13
|
+
for complex layouts), with a title field beside the slug.
|
|
14
|
+
|
|
15
|
+
## [0.47.0] - 2026-06-29
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Config shows the create-volt version** at the top, always; with `(X available)`
|
|
19
|
+
+ an Upgrade button when behind, `(up to date)` otherwise.
|
|
20
|
+
- **Light-mode switcher** in the config wizard (top-right; persists; defaults to dark).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Content-editor textarea** showed a template placeholder instead of the page
|
|
24
|
+
body — now binds the body via the value property.
|
|
25
|
+
|
|
7
26
|
## [0.46.0] - 2026-06-29
|
|
8
27
|
|
|
9
28
|
### Added
|
|
@@ -609,6 +628,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
609
628
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
610
629
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
611
630
|
|
|
631
|
+
[0.48.0]: https://github.com/MIR-2025/volt/releases/tag/v0.48.0
|
|
632
|
+
[0.47.0]: https://github.com/MIR-2025/volt/releases/tag/v0.47.0
|
|
612
633
|
[0.46.0]: https://github.com/MIR-2025/volt/releases/tag/v0.46.0
|
|
613
634
|
[0.45.1]: https://github.com/MIR-2025/volt/releases/tag/v0.45.1
|
|
614
635
|
[0.45.0]: https://github.com/MIR-2025/volt/releases/tag/v0.45.0
|
package/package.json
CHANGED
|
@@ -7,15 +7,19 @@
|
|
|
7
7
|
<title>Set up your Volt app</title>
|
|
8
8
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
9
9
|
<style>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
|
|
10
|
+
:root { --bg: #0f1115; --fg: #e7e9ee; --card: #161a22; --border: #232a36; --accent: #ffd24a; --field: #0f1115; }
|
|
11
|
+
[data-theme="light"] { --bg: #f6f7f9; --fg: #1b1f27; --card: #ffffff; --border: #d8dce3; --accent: #b07d00; --field: #ffffff; }
|
|
12
|
+
body { background: var(--bg); color: var(--fg); }
|
|
13
|
+
.accent { color: var(--accent); }
|
|
14
|
+
.card-x { background: var(--card); border: 1px solid var(--border); border-radius: 14px; }
|
|
15
|
+
.form-control, .form-select { background: var(--field); color: var(--fg); border-color: var(--border); }
|
|
16
|
+
.form-control:focus, .form-select:focus { background: var(--field); color: var(--fg); border-color: var(--accent); box-shadow: none; }
|
|
17
|
+
code { color: var(--accent); }
|
|
18
|
+
#theme-toggle { position: fixed; top: 12px; right: 14px; z-index: 10; }
|
|
16
19
|
</style>
|
|
17
20
|
</head>
|
|
18
21
|
<body>
|
|
22
|
+
<button id="theme-toggle" class="btn btn-sm btn-outline-secondary">Light mode</button>
|
|
19
23
|
<main class="container py-5" style="max-width: 640px;">
|
|
20
24
|
<header class="mb-4">
|
|
21
25
|
<h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Set up your Volt app</span></h1>
|
|
@@ -23,6 +27,21 @@
|
|
|
23
27
|
</header>
|
|
24
28
|
<div id="app"></div>
|
|
25
29
|
</main>
|
|
30
|
+
<script>
|
|
31
|
+
(function () {
|
|
32
|
+
const root = document.documentElement;
|
|
33
|
+
const btn = document.getElementById("theme-toggle");
|
|
34
|
+
const apply = (t) => { root.setAttribute("data-theme", t); btn.textContent = t === "light" ? "Dark mode" : "Light mode"; };
|
|
35
|
+
apply(localStorage.getItem("volt-setup-theme") || "dark");
|
|
36
|
+
btn.addEventListener("click", () => {
|
|
37
|
+
const next = root.getAttribute("data-theme") === "light" ? "dark" : "light";
|
|
38
|
+
apply(next);
|
|
39
|
+
localStorage.setItem("volt-setup-theme", next);
|
|
40
|
+
});
|
|
41
|
+
})();
|
|
42
|
+
</script>
|
|
43
|
+
<script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
|
|
44
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
26
45
|
<script type="module" src="/setup.js"></script>
|
|
27
46
|
</body>
|
|
28
47
|
</html>
|
|
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
const items = signal({ pages: [], posts: [] });
|
|
303
|
-
const editing = signal(null); // { type, slug,
|
|
303
|
+
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
304
|
+
let ed = null; // live RTEPro instance for the open editor
|
|
304
305
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
306
|
+
// raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
|
|
307
|
+
function parseDoc(raw) {
|
|
308
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
309
|
+
const front = fm ? fm[1] : "";
|
|
310
|
+
const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
|
|
311
|
+
const body = fm ? raw.slice(fm[0].length) : raw;
|
|
312
|
+
const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
|
|
313
|
+
return { title, bodyHtml };
|
|
314
|
+
}
|
|
315
|
+
function mountEditor(bodyHtml) {
|
|
316
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
|
|
317
|
+
ed.setHTML(bodyHtml || "");
|
|
318
|
+
}
|
|
305
319
|
async function editItem(type, slug) {
|
|
306
320
|
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
307
|
-
|
|
321
|
+
const { title, bodyHtml } = parseDoc(d.body || "");
|
|
322
|
+
editing({ type, slug, title, isNew: false });
|
|
323
|
+
queueMicrotask(() => mountEditor(bodyHtml));
|
|
308
324
|
}
|
|
309
325
|
function newItem(type) {
|
|
310
|
-
|
|
311
|
-
|
|
326
|
+
editing({ type, slug: "", title: "", isNew: true });
|
|
327
|
+
queueMicrotask(() => mountEditor(""));
|
|
328
|
+
}
|
|
329
|
+
// markdown can't round-trip complex layouts (columns, inline styles, merged cells,
|
|
330
|
+
// embeds) — save those as HTML so they aren't flattened.
|
|
331
|
+
function isComplex(h) {
|
|
332
|
+
return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
|
|
312
333
|
}
|
|
313
334
|
async function saveItem() {
|
|
314
335
|
const e = editing();
|
|
315
336
|
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
316
|
-
const body = document.querySelector("#mg-body").value;
|
|
317
337
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
338
|
+
const title = (document.querySelector("#mg-title").value || "").trim() || slug;
|
|
339
|
+
const htmlOut = ed ? ed.getHTML() : "";
|
|
340
|
+
const complex = isComplex(htmlOut);
|
|
341
|
+
const front = [`title: ${title}`];
|
|
342
|
+
if (complex) front.push("format: html");
|
|
343
|
+
const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
|
|
344
|
+
const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
|
|
318
345
|
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
319
346
|
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
320
|
-
status("Saved → " + r.file);
|
|
347
|
+
status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
|
|
348
|
+
ed = null;
|
|
321
349
|
editing(null);
|
|
322
350
|
loadItems();
|
|
323
351
|
}
|
|
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
|
|
|
339
367
|
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
340
368
|
</div>`;
|
|
341
369
|
const editorPanel = () => {
|
|
342
|
-
const e = editing(); // inputs
|
|
343
|
-
return html`<div class="p-3 mb-2" style="border:1px solid
|
|
344
|
-
<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>
|
|
345
|
-
<
|
|
370
|
+
const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
|
|
371
|
+
return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
|
|
372
|
+
<div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
|
|
373
|
+
<div id="mg-editor"></div>
|
|
346
374
|
<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>
|
|
347
375
|
</div>`;
|
|
348
376
|
};
|
|
@@ -353,7 +381,11 @@ const manageView = () =>
|
|
|
353
381
|
</div>`;
|
|
354
382
|
|
|
355
383
|
const configView = () =>
|
|
356
|
-
html`${() =>
|
|
384
|
+
html`${() => {
|
|
385
|
+
const u = upgrade();
|
|
386
|
+
if (!u || !u.current || u.current === "?") return "";
|
|
387
|
+
return html`<div class="card-x p-3 mb-3 d-flex justify-content-between align-items-center"><span class="small">create-volt <strong>${u.current}</strong> ${u.available ? html`<span class="accent">(${u.latest} available)</span>` : u.latest && u.latest !== "?" ? html`<span class="text-muted">(up to date)</span>` : ""}</span>${u.available ? html`<button class="btn btn-sm btn-primary" onclick=${doUpgrade}>Upgrade</button>` : ""}</div>`;
|
|
388
|
+
}}
|
|
357
389
|
${() => (aiCredits()?.ok ? html`<div class="card-x p-3 mb-3"><div class="d-flex justify-content-between align-items-center mb-2"><strong>AI credits</strong><span class="small text-muted">${aiCredits().tier}${typeof aiCredits().creditBalanceUsd === "number" ? ` · $${aiCredits().creditBalanceUsd.toFixed(2)} left` : ""}</span></div>${aiCredits().payments ? html`<div class="d-flex gap-2 align-items-center"><span class="small text-muted me-1">Top up:</span>${[10, 25, 50].map((a) => html`<button class="btn btn-sm btn-outline-primary" onclick=${() => buyCredits(a)}>$${a}</button>`)}</div>` : html`<div class="small text-muted">Pay-as-you-go isn't enabled on the gateway yet — using the free tier.</div>`}</div>` : "")}
|
|
358
390
|
${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>` : ""}
|
|
359
391
|
<div class="card-x p-4 mb-3">
|
|
@@ -7,15 +7,19 @@
|
|
|
7
7
|
<title>Set up your Volt app</title>
|
|
8
8
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
9
9
|
<style>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
|
|
10
|
+
:root { --bg: #0f1115; --fg: #e7e9ee; --card: #161a22; --border: #232a36; --accent: #ffd24a; --field: #0f1115; }
|
|
11
|
+
[data-theme="light"] { --bg: #f6f7f9; --fg: #1b1f27; --card: #ffffff; --border: #d8dce3; --accent: #b07d00; --field: #ffffff; }
|
|
12
|
+
body { background: var(--bg); color: var(--fg); }
|
|
13
|
+
.accent { color: var(--accent); }
|
|
14
|
+
.card-x { background: var(--card); border: 1px solid var(--border); border-radius: 14px; }
|
|
15
|
+
.form-control, .form-select { background: var(--field); color: var(--fg); border-color: var(--border); }
|
|
16
|
+
.form-control:focus, .form-select:focus { background: var(--field); color: var(--fg); border-color: var(--accent); box-shadow: none; }
|
|
17
|
+
code { color: var(--accent); }
|
|
18
|
+
#theme-toggle { position: fixed; top: 12px; right: 14px; z-index: 10; }
|
|
16
19
|
</style>
|
|
17
20
|
</head>
|
|
18
21
|
<body>
|
|
22
|
+
<button id="theme-toggle" class="btn btn-sm btn-outline-secondary">Light mode</button>
|
|
19
23
|
<main class="container py-5" style="max-width: 640px;">
|
|
20
24
|
<header class="mb-4">
|
|
21
25
|
<h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Set up your Volt app</span></h1>
|
|
@@ -23,6 +27,21 @@
|
|
|
23
27
|
</header>
|
|
24
28
|
<div id="app"></div>
|
|
25
29
|
</main>
|
|
30
|
+
<script>
|
|
31
|
+
(function () {
|
|
32
|
+
const root = document.documentElement;
|
|
33
|
+
const btn = document.getElementById("theme-toggle");
|
|
34
|
+
const apply = (t) => { root.setAttribute("data-theme", t); btn.textContent = t === "light" ? "Dark mode" : "Light mode"; };
|
|
35
|
+
apply(localStorage.getItem("volt-setup-theme") || "dark");
|
|
36
|
+
btn.addEventListener("click", () => {
|
|
37
|
+
const next = root.getAttribute("data-theme") === "light" ? "dark" : "light";
|
|
38
|
+
apply(next);
|
|
39
|
+
localStorage.setItem("volt-setup-theme", next);
|
|
40
|
+
});
|
|
41
|
+
})();
|
|
42
|
+
</script>
|
|
43
|
+
<script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
|
|
44
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
26
45
|
<script type="module" src="/setup.js"></script>
|
|
27
46
|
</body>
|
|
28
47
|
</html>
|
|
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
const items = signal({ pages: [], posts: [] });
|
|
303
|
-
const editing = signal(null); // { type, slug,
|
|
303
|
+
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
304
|
+
let ed = null; // live RTEPro instance for the open editor
|
|
304
305
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
306
|
+
// raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
|
|
307
|
+
function parseDoc(raw) {
|
|
308
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
309
|
+
const front = fm ? fm[1] : "";
|
|
310
|
+
const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
|
|
311
|
+
const body = fm ? raw.slice(fm[0].length) : raw;
|
|
312
|
+
const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
|
|
313
|
+
return { title, bodyHtml };
|
|
314
|
+
}
|
|
315
|
+
function mountEditor(bodyHtml) {
|
|
316
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
|
|
317
|
+
ed.setHTML(bodyHtml || "");
|
|
318
|
+
}
|
|
305
319
|
async function editItem(type, slug) {
|
|
306
320
|
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
307
|
-
|
|
321
|
+
const { title, bodyHtml } = parseDoc(d.body || "");
|
|
322
|
+
editing({ type, slug, title, isNew: false });
|
|
323
|
+
queueMicrotask(() => mountEditor(bodyHtml));
|
|
308
324
|
}
|
|
309
325
|
function newItem(type) {
|
|
310
|
-
|
|
311
|
-
|
|
326
|
+
editing({ type, slug: "", title: "", isNew: true });
|
|
327
|
+
queueMicrotask(() => mountEditor(""));
|
|
328
|
+
}
|
|
329
|
+
// markdown can't round-trip complex layouts (columns, inline styles, merged cells,
|
|
330
|
+
// embeds) — save those as HTML so they aren't flattened.
|
|
331
|
+
function isComplex(h) {
|
|
332
|
+
return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
|
|
312
333
|
}
|
|
313
334
|
async function saveItem() {
|
|
314
335
|
const e = editing();
|
|
315
336
|
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
316
|
-
const body = document.querySelector("#mg-body").value;
|
|
317
337
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
338
|
+
const title = (document.querySelector("#mg-title").value || "").trim() || slug;
|
|
339
|
+
const htmlOut = ed ? ed.getHTML() : "";
|
|
340
|
+
const complex = isComplex(htmlOut);
|
|
341
|
+
const front = [`title: ${title}`];
|
|
342
|
+
if (complex) front.push("format: html");
|
|
343
|
+
const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
|
|
344
|
+
const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
|
|
318
345
|
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
319
346
|
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
320
|
-
status("Saved → " + r.file);
|
|
347
|
+
status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
|
|
348
|
+
ed = null;
|
|
321
349
|
editing(null);
|
|
322
350
|
loadItems();
|
|
323
351
|
}
|
|
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
|
|
|
339
367
|
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
340
368
|
</div>`;
|
|
341
369
|
const editorPanel = () => {
|
|
342
|
-
const e = editing(); // inputs
|
|
343
|
-
return html`<div class="p-3 mb-2" style="border:1px solid
|
|
344
|
-
<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>
|
|
345
|
-
<
|
|
370
|
+
const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
|
|
371
|
+
return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
|
|
372
|
+
<div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
|
|
373
|
+
<div id="mg-editor"></div>
|
|
346
374
|
<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>
|
|
347
375
|
</div>`;
|
|
348
376
|
};
|
|
@@ -353,7 +381,11 @@ const manageView = () =>
|
|
|
353
381
|
</div>`;
|
|
354
382
|
|
|
355
383
|
const configView = () =>
|
|
356
|
-
html`${() =>
|
|
384
|
+
html`${() => {
|
|
385
|
+
const u = upgrade();
|
|
386
|
+
if (!u || !u.current || u.current === "?") return "";
|
|
387
|
+
return html`<div class="card-x p-3 mb-3 d-flex justify-content-between align-items-center"><span class="small">create-volt <strong>${u.current}</strong> ${u.available ? html`<span class="accent">(${u.latest} available)</span>` : u.latest && u.latest !== "?" ? html`<span class="text-muted">(up to date)</span>` : ""}</span>${u.available ? html`<button class="btn btn-sm btn-primary" onclick=${doUpgrade}>Upgrade</button>` : ""}</div>`;
|
|
388
|
+
}}
|
|
357
389
|
${() => (aiCredits()?.ok ? html`<div class="card-x p-3 mb-3"><div class="d-flex justify-content-between align-items-center mb-2"><strong>AI credits</strong><span class="small text-muted">${aiCredits().tier}${typeof aiCredits().creditBalanceUsd === "number" ? ` · $${aiCredits().creditBalanceUsd.toFixed(2)} left` : ""}</span></div>${aiCredits().payments ? html`<div class="d-flex gap-2 align-items-center"><span class="small text-muted me-1">Top up:</span>${[10, 25, 50].map((a) => html`<button class="btn btn-sm btn-outline-primary" onclick=${() => buyCredits(a)}>$${a}</button>`)}</div>` : html`<div class="small text-muted">Pay-as-you-go isn't enabled on the gateway yet — using the free tier.</div>`}</div>` : "")}
|
|
358
390
|
${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>` : ""}
|
|
359
391
|
<div class="card-x p-4 mb-3">
|
|
@@ -7,15 +7,19 @@
|
|
|
7
7
|
<title>Set up your Volt app</title>
|
|
8
8
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
9
9
|
<style>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
|
|
10
|
+
:root { --bg: #0f1115; --fg: #e7e9ee; --card: #161a22; --border: #232a36; --accent: #ffd24a; --field: #0f1115; }
|
|
11
|
+
[data-theme="light"] { --bg: #f6f7f9; --fg: #1b1f27; --card: #ffffff; --border: #d8dce3; --accent: #b07d00; --field: #ffffff; }
|
|
12
|
+
body { background: var(--bg); color: var(--fg); }
|
|
13
|
+
.accent { color: var(--accent); }
|
|
14
|
+
.card-x { background: var(--card); border: 1px solid var(--border); border-radius: 14px; }
|
|
15
|
+
.form-control, .form-select { background: var(--field); color: var(--fg); border-color: var(--border); }
|
|
16
|
+
.form-control:focus, .form-select:focus { background: var(--field); color: var(--fg); border-color: var(--accent); box-shadow: none; }
|
|
17
|
+
code { color: var(--accent); }
|
|
18
|
+
#theme-toggle { position: fixed; top: 12px; right: 14px; z-index: 10; }
|
|
16
19
|
</style>
|
|
17
20
|
</head>
|
|
18
21
|
<body>
|
|
22
|
+
<button id="theme-toggle" class="btn btn-sm btn-outline-secondary">Light mode</button>
|
|
19
23
|
<main class="container py-5" style="max-width: 640px;">
|
|
20
24
|
<header class="mb-4">
|
|
21
25
|
<h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Set up your Volt app</span></h1>
|
|
@@ -23,6 +27,21 @@
|
|
|
23
27
|
</header>
|
|
24
28
|
<div id="app"></div>
|
|
25
29
|
</main>
|
|
30
|
+
<script>
|
|
31
|
+
(function () {
|
|
32
|
+
const root = document.documentElement;
|
|
33
|
+
const btn = document.getElementById("theme-toggle");
|
|
34
|
+
const apply = (t) => { root.setAttribute("data-theme", t); btn.textContent = t === "light" ? "Dark mode" : "Light mode"; };
|
|
35
|
+
apply(localStorage.getItem("volt-setup-theme") || "dark");
|
|
36
|
+
btn.addEventListener("click", () => {
|
|
37
|
+
const next = root.getAttribute("data-theme") === "light" ? "dark" : "light";
|
|
38
|
+
apply(next);
|
|
39
|
+
localStorage.setItem("volt-setup-theme", next);
|
|
40
|
+
});
|
|
41
|
+
})();
|
|
42
|
+
</script>
|
|
43
|
+
<script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
|
|
44
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
26
45
|
<script type="module" src="/setup.js"></script>
|
|
27
46
|
</body>
|
|
28
47
|
</html>
|
|
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
const items = signal({ pages: [], posts: [] });
|
|
303
|
-
const editing = signal(null); // { type, slug,
|
|
303
|
+
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
304
|
+
let ed = null; // live RTEPro instance for the open editor
|
|
304
305
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
306
|
+
// raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
|
|
307
|
+
function parseDoc(raw) {
|
|
308
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
309
|
+
const front = fm ? fm[1] : "";
|
|
310
|
+
const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
|
|
311
|
+
const body = fm ? raw.slice(fm[0].length) : raw;
|
|
312
|
+
const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
|
|
313
|
+
return { title, bodyHtml };
|
|
314
|
+
}
|
|
315
|
+
function mountEditor(bodyHtml) {
|
|
316
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
|
|
317
|
+
ed.setHTML(bodyHtml || "");
|
|
318
|
+
}
|
|
305
319
|
async function editItem(type, slug) {
|
|
306
320
|
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
307
|
-
|
|
321
|
+
const { title, bodyHtml } = parseDoc(d.body || "");
|
|
322
|
+
editing({ type, slug, title, isNew: false });
|
|
323
|
+
queueMicrotask(() => mountEditor(bodyHtml));
|
|
308
324
|
}
|
|
309
325
|
function newItem(type) {
|
|
310
|
-
|
|
311
|
-
|
|
326
|
+
editing({ type, slug: "", title: "", isNew: true });
|
|
327
|
+
queueMicrotask(() => mountEditor(""));
|
|
328
|
+
}
|
|
329
|
+
// markdown can't round-trip complex layouts (columns, inline styles, merged cells,
|
|
330
|
+
// embeds) — save those as HTML so they aren't flattened.
|
|
331
|
+
function isComplex(h) {
|
|
332
|
+
return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
|
|
312
333
|
}
|
|
313
334
|
async function saveItem() {
|
|
314
335
|
const e = editing();
|
|
315
336
|
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
316
|
-
const body = document.querySelector("#mg-body").value;
|
|
317
337
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
338
|
+
const title = (document.querySelector("#mg-title").value || "").trim() || slug;
|
|
339
|
+
const htmlOut = ed ? ed.getHTML() : "";
|
|
340
|
+
const complex = isComplex(htmlOut);
|
|
341
|
+
const front = [`title: ${title}`];
|
|
342
|
+
if (complex) front.push("format: html");
|
|
343
|
+
const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
|
|
344
|
+
const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
|
|
318
345
|
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
319
346
|
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
320
|
-
status("Saved → " + r.file);
|
|
347
|
+
status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
|
|
348
|
+
ed = null;
|
|
321
349
|
editing(null);
|
|
322
350
|
loadItems();
|
|
323
351
|
}
|
|
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
|
|
|
339
367
|
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
340
368
|
</div>`;
|
|
341
369
|
const editorPanel = () => {
|
|
342
|
-
const e = editing(); // inputs
|
|
343
|
-
return html`<div class="p-3 mb-2" style="border:1px solid
|
|
344
|
-
<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>
|
|
345
|
-
<
|
|
370
|
+
const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
|
|
371
|
+
return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
|
|
372
|
+
<div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
|
|
373
|
+
<div id="mg-editor"></div>
|
|
346
374
|
<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>
|
|
347
375
|
</div>`;
|
|
348
376
|
};
|
|
@@ -353,7 +381,11 @@ const manageView = () =>
|
|
|
353
381
|
</div>`;
|
|
354
382
|
|
|
355
383
|
const configView = () =>
|
|
356
|
-
html`${() =>
|
|
384
|
+
html`${() => {
|
|
385
|
+
const u = upgrade();
|
|
386
|
+
if (!u || !u.current || u.current === "?") return "";
|
|
387
|
+
return html`<div class="card-x p-3 mb-3 d-flex justify-content-between align-items-center"><span class="small">create-volt <strong>${u.current}</strong> ${u.available ? html`<span class="accent">(${u.latest} available)</span>` : u.latest && u.latest !== "?" ? html`<span class="text-muted">(up to date)</span>` : ""}</span>${u.available ? html`<button class="btn btn-sm btn-primary" onclick=${doUpgrade}>Upgrade</button>` : ""}</div>`;
|
|
388
|
+
}}
|
|
357
389
|
${() => (aiCredits()?.ok ? html`<div class="card-x p-3 mb-3"><div class="d-flex justify-content-between align-items-center mb-2"><strong>AI credits</strong><span class="small text-muted">${aiCredits().tier}${typeof aiCredits().creditBalanceUsd === "number" ? ` · $${aiCredits().creditBalanceUsd.toFixed(2)} left` : ""}</span></div>${aiCredits().payments ? html`<div class="d-flex gap-2 align-items-center"><span class="small text-muted me-1">Top up:</span>${[10, 25, 50].map((a) => html`<button class="btn btn-sm btn-outline-primary" onclick=${() => buyCredits(a)}>$${a}</button>`)}</div>` : html`<div class="small text-muted">Pay-as-you-go isn't enabled on the gateway yet — using the free tier.</div>`}</div>` : "")}
|
|
358
390
|
${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>` : ""}
|
|
359
391
|
<div class="card-x p-4 mb-3">
|
|
@@ -7,15 +7,19 @@
|
|
|
7
7
|
<title>Set up your Volt app</title>
|
|
8
8
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
9
9
|
<style>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
|
|
10
|
+
:root { --bg: #0f1115; --fg: #e7e9ee; --card: #161a22; --border: #232a36; --accent: #ffd24a; --field: #0f1115; }
|
|
11
|
+
[data-theme="light"] { --bg: #f6f7f9; --fg: #1b1f27; --card: #ffffff; --border: #d8dce3; --accent: #b07d00; --field: #ffffff; }
|
|
12
|
+
body { background: var(--bg); color: var(--fg); }
|
|
13
|
+
.accent { color: var(--accent); }
|
|
14
|
+
.card-x { background: var(--card); border: 1px solid var(--border); border-radius: 14px; }
|
|
15
|
+
.form-control, .form-select { background: var(--field); color: var(--fg); border-color: var(--border); }
|
|
16
|
+
.form-control:focus, .form-select:focus { background: var(--field); color: var(--fg); border-color: var(--accent); box-shadow: none; }
|
|
17
|
+
code { color: var(--accent); }
|
|
18
|
+
#theme-toggle { position: fixed; top: 12px; right: 14px; z-index: 10; }
|
|
16
19
|
</style>
|
|
17
20
|
</head>
|
|
18
21
|
<body>
|
|
22
|
+
<button id="theme-toggle" class="btn btn-sm btn-outline-secondary">Light mode</button>
|
|
19
23
|
<main class="container py-5" style="max-width: 640px;">
|
|
20
24
|
<header class="mb-4">
|
|
21
25
|
<h1 class="h3"><span class="accent"><img src="/logo.webp" alt="" style="height:1em;vertical-align:-.15em" /> Set up your Volt app</span></h1>
|
|
@@ -23,6 +27,21 @@
|
|
|
23
27
|
</header>
|
|
24
28
|
<div id="app"></div>
|
|
25
29
|
</main>
|
|
30
|
+
<script>
|
|
31
|
+
(function () {
|
|
32
|
+
const root = document.documentElement;
|
|
33
|
+
const btn = document.getElementById("theme-toggle");
|
|
34
|
+
const apply = (t) => { root.setAttribute("data-theme", t); btn.textContent = t === "light" ? "Dark mode" : "Light mode"; };
|
|
35
|
+
apply(localStorage.getItem("volt-setup-theme") || "dark");
|
|
36
|
+
btn.addEventListener("click", () => {
|
|
37
|
+
const next = root.getAttribute("data-theme") === "light" ? "dark" : "light";
|
|
38
|
+
apply(next);
|
|
39
|
+
localStorage.setItem("volt-setup-theme", next);
|
|
40
|
+
});
|
|
41
|
+
})();
|
|
42
|
+
</script>
|
|
43
|
+
<script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
|
|
44
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
26
45
|
<script type="module" src="/setup.js"></script>
|
|
27
46
|
</body>
|
|
28
47
|
</html>
|
|
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
const items = signal({ pages: [], posts: [] });
|
|
303
|
-
const editing = signal(null); // { type, slug,
|
|
303
|
+
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
304
|
+
let ed = null; // live RTEPro instance for the open editor
|
|
304
305
|
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
306
|
+
// raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
|
|
307
|
+
function parseDoc(raw) {
|
|
308
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
309
|
+
const front = fm ? fm[1] : "";
|
|
310
|
+
const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
|
|
311
|
+
const body = fm ? raw.slice(fm[0].length) : raw;
|
|
312
|
+
const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
|
|
313
|
+
return { title, bodyHtml };
|
|
314
|
+
}
|
|
315
|
+
function mountEditor(bodyHtml) {
|
|
316
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
|
|
317
|
+
ed.setHTML(bodyHtml || "");
|
|
318
|
+
}
|
|
305
319
|
async function editItem(type, slug) {
|
|
306
320
|
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
307
|
-
|
|
321
|
+
const { title, bodyHtml } = parseDoc(d.body || "");
|
|
322
|
+
editing({ type, slug, title, isNew: false });
|
|
323
|
+
queueMicrotask(() => mountEditor(bodyHtml));
|
|
308
324
|
}
|
|
309
325
|
function newItem(type) {
|
|
310
|
-
|
|
311
|
-
|
|
326
|
+
editing({ type, slug: "", title: "", isNew: true });
|
|
327
|
+
queueMicrotask(() => mountEditor(""));
|
|
328
|
+
}
|
|
329
|
+
// markdown can't round-trip complex layouts (columns, inline styles, merged cells,
|
|
330
|
+
// embeds) — save those as HTML so they aren't flattened.
|
|
331
|
+
function isComplex(h) {
|
|
332
|
+
return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
|
|
312
333
|
}
|
|
313
334
|
async function saveItem() {
|
|
314
335
|
const e = editing();
|
|
315
336
|
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
316
|
-
const body = document.querySelector("#mg-body").value;
|
|
317
337
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
338
|
+
const title = (document.querySelector("#mg-title").value || "").trim() || slug;
|
|
339
|
+
const htmlOut = ed ? ed.getHTML() : "";
|
|
340
|
+
const complex = isComplex(htmlOut);
|
|
341
|
+
const front = [`title: ${title}`];
|
|
342
|
+
if (complex) front.push("format: html");
|
|
343
|
+
const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
|
|
344
|
+
const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
|
|
318
345
|
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
319
346
|
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
320
|
-
status("Saved → " + r.file);
|
|
347
|
+
status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
|
|
348
|
+
ed = null;
|
|
321
349
|
editing(null);
|
|
322
350
|
loadItems();
|
|
323
351
|
}
|
|
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
|
|
|
339
367
|
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
340
368
|
</div>`;
|
|
341
369
|
const editorPanel = () => {
|
|
342
|
-
const e = editing(); // inputs
|
|
343
|
-
return html`<div class="p-3 mb-2" style="border:1px solid
|
|
344
|
-
<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>
|
|
345
|
-
<
|
|
370
|
+
const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
|
|
371
|
+
return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
|
|
372
|
+
<div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
|
|
373
|
+
<div id="mg-editor"></div>
|
|
346
374
|
<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>
|
|
347
375
|
</div>`;
|
|
348
376
|
};
|
|
@@ -353,7 +381,11 @@ const manageView = () =>
|
|
|
353
381
|
</div>`;
|
|
354
382
|
|
|
355
383
|
const configView = () =>
|
|
356
|
-
html`${() =>
|
|
384
|
+
html`${() => {
|
|
385
|
+
const u = upgrade();
|
|
386
|
+
if (!u || !u.current || u.current === "?") return "";
|
|
387
|
+
return html`<div class="card-x p-3 mb-3 d-flex justify-content-between align-items-center"><span class="small">create-volt <strong>${u.current}</strong> ${u.available ? html`<span class="accent">(${u.latest} available)</span>` : u.latest && u.latest !== "?" ? html`<span class="text-muted">(up to date)</span>` : ""}</span>${u.available ? html`<button class="btn btn-sm btn-primary" onclick=${doUpgrade}>Upgrade</button>` : ""}</div>`;
|
|
388
|
+
}}
|
|
357
389
|
${() => (aiCredits()?.ok ? html`<div class="card-x p-3 mb-3"><div class="d-flex justify-content-between align-items-center mb-2"><strong>AI credits</strong><span class="small text-muted">${aiCredits().tier}${typeof aiCredits().creditBalanceUsd === "number" ? ` · $${aiCredits().creditBalanceUsd.toFixed(2)} left` : ""}</span></div>${aiCredits().payments ? html`<div class="d-flex gap-2 align-items-center"><span class="small text-muted me-1">Top up:</span>${[10, 25, 50].map((a) => html`<button class="btn btn-sm btn-outline-primary" onclick=${() => buyCredits(a)}>$${a}</button>`)}</div>` : html`<div class="small text-muted">Pay-as-you-go isn't enabled on the gateway yet — using the free tier.</div>`}</div>` : "")}
|
|
358
390
|
${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>` : ""}
|
|
359
391
|
<div class="card-x p-4 mb-3">
|