create-volt 0.55.1 → 0.56.1
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 +36 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/templates/blog/public/volt.js +7 -2
- package/templates/blog/server.js +101 -3
- package/templates/blog/setup/setup.js +59 -10
- package/templates/business/Dockerfile +20 -0
- package/templates/business/Procfile +1 -0
- package/templates/business/README.md +25 -0
- package/templates/business/dockerignore +6 -0
- package/templates/business/ecosystem.config.cjs +5 -0
- package/templates/business/env +2 -0
- package/templates/business/fly.toml +15 -0
- package/templates/business/gitignore +5 -0
- package/templates/business/package.json +21 -0
- package/templates/business/pages/_theme.js +65 -0
- package/templates/business/pages/about.md +30 -0
- package/templates/business/pages/contact.md +23 -0
- package/templates/business/pages/index.md +41 -0
- package/templates/business/pages/products.md +27 -0
- package/templates/business/public/app.js +89 -0
- package/templates/business/public/favicon.webp +0 -0
- package/templates/business/public/logo.webp +0 -0
- package/templates/business/public/volt-ssr.js +63 -0
- package/templates/business/public/volt.js +355 -0
- package/templates/business/render.yaml +15 -0
- package/templates/business/server.js +1065 -0
- package/templates/business/setup/index.html +46 -0
- package/templates/business/setup/logs.html +29 -0
- package/templates/business/setup/logs.js +58 -0
- package/templates/business/setup/setup.js +509 -0
- package/templates/business/setup/studio.html +29 -0
- package/templates/business/views/index.html +42 -0
- package/templates/default/public/volt.js +7 -2
- package/templates/default/server.js +101 -3
- package/templates/default/setup/setup.js +59 -10
- package/templates/docs/public/volt.js +7 -2
- package/templates/docs/server.js +101 -3
- package/templates/docs/setup/setup.js +59 -10
- package/templates/guestbook/public/volt.js +7 -2
- package/templates/starter/public/volt.js +7 -2
- package/templates/starter/server.js +100 -3
- package/templates/starter/setup/setup.js +59 -10
|
@@ -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
|
|
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
|
|
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) {
|
|
@@ -338,6 +338,20 @@ function startSetup() {
|
|
|
338
338
|
res.setHeader("Content-Type", assets[p][0]);
|
|
339
339
|
return res.end(assets[p][1]);
|
|
340
340
|
}
|
|
341
|
+
// Serve uploaded media so library thumbnails + editor previews render inside the
|
|
342
|
+
// config (the running app serves these via express.static; the config didn't).
|
|
343
|
+
if (req.method === "GET" && p.startsWith("/media/")) {
|
|
344
|
+
const base = path.join(__dirname, "public", "media");
|
|
345
|
+
const f = path.resolve(base, decodeURIComponent(p.slice("/media/".length)));
|
|
346
|
+
if ((f === base || f.startsWith(base + path.sep)) && fs.existsSync(f) && fs.statSync(f).isFile()) {
|
|
347
|
+
const ext = (f.split(".").pop() || "").toLowerCase();
|
|
348
|
+
const mime = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", ogv: "video/ogg", m4v: "video/x-m4v", ogg: "audio/ogg", mp3: "audio/mpeg", wav: "audio/wav" }[ext] || "application/octet-stream";
|
|
349
|
+
res.setHeader("Content-Type", mime);
|
|
350
|
+
return res.end(fs.readFileSync(f));
|
|
351
|
+
}
|
|
352
|
+
res.statusCode = 404;
|
|
353
|
+
return res.end("not found");
|
|
354
|
+
}
|
|
341
355
|
if (req.method === "GET" && p === "/setup/state") {
|
|
342
356
|
res.setHeader("Content-Type", "application/json");
|
|
343
357
|
return res.end(JSON.stringify({ available: availableAddons(), themes: availableThemes(), current: readEnvFile(), defaultPort: DEFAULT_PORT, configDefaultPort: CONFIG_DEFAULT_PORT }));
|
|
@@ -376,9 +390,9 @@ function startSetup() {
|
|
|
376
390
|
const env = readEnvFile();
|
|
377
391
|
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
378
392
|
fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
|
|
379
|
-
.then((r) => r.json())
|
|
393
|
+
.then((r) => (r.ok ? r.json() : { ok: false, error: `hosted AI gateway not available (HTTP ${r.status}) — is it deployed?` }))
|
|
380
394
|
.then((j) => res.end(JSON.stringify(j)))
|
|
381
|
-
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
395
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "hosted AI gateway unreachable" })));
|
|
382
396
|
return;
|
|
383
397
|
}
|
|
384
398
|
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
@@ -492,6 +506,71 @@ function startSetup() {
|
|
|
492
506
|
})();
|
|
493
507
|
return;
|
|
494
508
|
}
|
|
509
|
+
// --- media library: list / upload / delete files in public/media (served at
|
|
510
|
+
// /media/<name>). Shell-gated (config only). ---
|
|
511
|
+
if (req.method === "GET" && p === "/setup/media") {
|
|
512
|
+
res.setHeader("Content-Type", "application/json");
|
|
513
|
+
const dir = path.join(__dirname, "public", "media");
|
|
514
|
+
let items = [];
|
|
515
|
+
try {
|
|
516
|
+
items = fs
|
|
517
|
+
.readdirSync(dir)
|
|
518
|
+
.filter((f) => !f.startsWith("."))
|
|
519
|
+
.map((f) => ({ name: f, url: "/media/" + f, size: fs.statSync(path.join(dir, f)).size }))
|
|
520
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
521
|
+
} catch {
|
|
522
|
+
/* no media dir yet */
|
|
523
|
+
}
|
|
524
|
+
return res.end(JSON.stringify({ items }));
|
|
525
|
+
}
|
|
526
|
+
if (req.method === "POST" && p === "/setup/media/upload") {
|
|
527
|
+
res.setHeader("Content-Type", "application/json");
|
|
528
|
+
const name = (u.searchParams.get("name") || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120);
|
|
529
|
+
if (!name || !/\.[A-Za-z0-9]+$/.test(name)) return res.end(JSON.stringify({ ok: false, error: "bad filename" }));
|
|
530
|
+
const dir = path.join(__dirname, "public", "media");
|
|
531
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
532
|
+
const chunks = [];
|
|
533
|
+
let size = 0;
|
|
534
|
+
let tooBig = false;
|
|
535
|
+
req.on("data", (c) => {
|
|
536
|
+
if (tooBig) return;
|
|
537
|
+
size += c.length;
|
|
538
|
+
if (size > 100 * 1024 * 1024) tooBig = true;
|
|
539
|
+
else chunks.push(c);
|
|
540
|
+
});
|
|
541
|
+
req.on("end", () => {
|
|
542
|
+
if (tooBig) {
|
|
543
|
+
res.statusCode = 413;
|
|
544
|
+
return res.end(JSON.stringify({ ok: false, error: "file too large (max 100MB)" }));
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
fs.writeFileSync(path.join(dir, name), Buffer.concat(chunks));
|
|
548
|
+
res.end(JSON.stringify({ ok: true, url: "/media/" + name, name }));
|
|
549
|
+
} catch (e) {
|
|
550
|
+
res.statusCode = 400;
|
|
551
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (req.method === "POST" && p === "/setup/media/delete") {
|
|
557
|
+
let mbody = "";
|
|
558
|
+
req.on("data", (c) => (mbody += c));
|
|
559
|
+
req.on("end", () => {
|
|
560
|
+
res.setHeader("Content-Type", "application/json");
|
|
561
|
+
try {
|
|
562
|
+
const { name } = JSON.parse(mbody || "{}");
|
|
563
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name || "")) throw new Error("bad name");
|
|
564
|
+
const f = path.join(__dirname, "public", "media", name);
|
|
565
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
566
|
+
res.end(JSON.stringify({ ok: true }));
|
|
567
|
+
} catch (e) {
|
|
568
|
+
res.statusCode = 400;
|
|
569
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
495
574
|
// --- content manager: list / read / write / delete pages + posts ---
|
|
496
575
|
if (req.method === "GET" && p === "/setup/content") {
|
|
497
576
|
const list = (type) => {
|
|
@@ -532,7 +611,25 @@ function startSetup() {
|
|
|
532
611
|
return res.end(JSON.stringify({ ok: true }));
|
|
533
612
|
}
|
|
534
613
|
fs.mkdirSync(dir, { recursive: true });
|
|
535
|
-
|
|
614
|
+
// RTEPro's media picker inlines "Choose File" uploads as base64 data URLs.
|
|
615
|
+
// Extract them to public/media/<hash>.<ext> and rewrite the src, so pages
|
|
616
|
+
// stay lean and the uploads land in the media library.
|
|
617
|
+
const mediaDir = path.join(__dirname, "public", "media");
|
|
618
|
+
const extFor = (mime) =>
|
|
619
|
+
({ "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");
|
|
620
|
+
const finalBody = String(body ?? "").replace(/(<(?:img|video|audio|source)\b[^>]*?\ssrc=")data:([\w.+-]+\/[\w.+-]+);base64,([^"]+)(")/gi, (m, pre, mime, b64, post) => {
|
|
621
|
+
try {
|
|
622
|
+
const buf = Buffer.from(b64, "base64");
|
|
623
|
+
const name = crypto.createHash("sha1").update(buf).digest("hex").slice(0, 16) + "." + extFor(mime);
|
|
624
|
+
fs.mkdirSync(mediaDir, { recursive: true });
|
|
625
|
+
const dest = path.join(mediaDir, name);
|
|
626
|
+
if (!fs.existsSync(dest)) fs.writeFileSync(dest, buf);
|
|
627
|
+
return pre + "/media/" + name + post;
|
|
628
|
+
} catch {
|
|
629
|
+
return m;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
fs.writeFileSync(file, finalBody);
|
|
536
633
|
res.end(JSON.stringify({ ok: true, file: (type === "post" ? "posts/" : "pages/") + slug + ".md" }));
|
|
537
634
|
} catch (e) {
|
|
538
635
|
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()
|
|
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/<name></code>. Copy a URL and paste it into a page's image slot in the editor. (Max 100 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
|
-
|
|
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
|
-
|
|
343
|
-
} else
|
|
391
|
+
genMsg("✓ token generated — Apply to save");
|
|
392
|
+
} else genMsg("✗ " + (r.error || "no token"));
|
|
344
393
|
} catch {
|
|
345
|
-
|
|
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
|
);
|