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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-bs-theme="dark">
|
|
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>Set up your Volt app</title>
|
|
8
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
9
|
+
<style>
|
|
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; }
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<button id="theme-toggle" class="btn btn-sm btn-outline-secondary">Light mode</button>
|
|
23
|
+
<main id="wrap" class="container py-5" style="max-width: 720px;">
|
|
24
|
+
<header class="mb-4">
|
|
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>
|
|
26
|
+
<p class="text-muted mb-0">Fill these in and the app starts. This page is disposable — it disappears once you click Apply. Re-open it anytime with <code>npm run dev -- --edit</code>.</p>
|
|
27
|
+
</header>
|
|
28
|
+
<div id="app"></div>
|
|
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); root.setAttribute("data-bs-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/rte-pro.js"></script>
|
|
44
|
+
<script type="module" src="/setup.js"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<title>Logs — Volt</title><meta name="robots" content="noindex" /><link rel="icon" href="/favicon.webp" />
|
|
6
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
7
|
+
<style>body{background:#0f1115;color:#e7e9ee}pre{background:#0b0d11;color:#cfe3ff;border:1px solid #232a36;border-radius:8px;padding:12px;max-height:72vh;overflow:auto;font-size:12.5px;white-space:pre-wrap}</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container-fluid py-3">
|
|
11
|
+
<div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
|
|
12
|
+
<strong class="me-2">Volt logs</strong>
|
|
13
|
+
<select id="src" class="form-select form-select-sm" style="max-width:200px"></select>
|
|
14
|
+
<select id="view" class="form-select form-select-sm" style="max-width:160px"><option value="tail">Raw tail</option><option value="analytics">Analytics</option></select>
|
|
15
|
+
<button id="refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
|
16
|
+
<button id="toggleadd" class="btn btn-sm btn-outline-secondary" title="Add another log source (server, app, mounted/tunneled path)">+ source</button>
|
|
17
|
+
<label class="form-check-label small ms-1"><input id="follow" type="checkbox" class="form-check-input" /> follow</label>
|
|
18
|
+
<input id="filter" class="form-control form-control-sm ms-auto" style="max-width:240px" placeholder="filter…" />
|
|
19
|
+
</div>
|
|
20
|
+
<div id="addbar" class="d-flex gap-2 mb-2" style="display:none">
|
|
21
|
+
<input id="newlabel" class="form-control form-control-sm" style="max-width:160px" placeholder="label (e.g. prod)" />
|
|
22
|
+
<input id="newfile" class="form-control form-control-sm" style="max-width:420px" placeholder="/var/log/nginx/access.log (local, mounted, or shipped path)" />
|
|
23
|
+
<button id="addsrc" class="btn btn-sm btn-primary">Add</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="out"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<script type="module" src="/logs.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// logs.js — the --logs viewer client (raw tail + mir-sentinel analytics).
|
|
2
|
+
const $ = (s) => document.querySelector(s);
|
|
3
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
4
|
+
let timer = null;
|
|
5
|
+
|
|
6
|
+
async function loadSources() {
|
|
7
|
+
const { sources = [] } = await (await fetch("/api/sources")).json();
|
|
8
|
+
$("#src").innerHTML = sources.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("") || `<option>(no logs found)</option>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function render() {
|
|
12
|
+
const src = $("#src").value;
|
|
13
|
+
if (!src) return;
|
|
14
|
+
if ($("#view").value === "analytics") {
|
|
15
|
+
const a = await (await fetch(`/api/analytics?source=${encodeURIComponent(src)}`)).json();
|
|
16
|
+
if (!a.ok) {
|
|
17
|
+
$("#out").innerHTML = `<div class="text-muted small">${esc(a.error || "no analytics")}</div>`;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const tbl = (title, rows) => `<h6 class="mt-3">${title}</h6><table class="table table-dark table-sm mb-0"><tbody>${(rows || []).map(([k, v]) => `<tr><td>${esc(String(k))}</td><td class="text-end">${v}</td></tr>`).join("")}</tbody></table>`;
|
|
21
|
+
$("#out").innerHTML = `<div class="small text-muted mb-2">${a.total} lines · ${a.bots} bot · ${a.attacks} attack</div>` + tbl("Top paths", a.paths) + tbl("Status codes", a.statuses) + tbl("Top IPs", a.ips);
|
|
22
|
+
} else {
|
|
23
|
+
const { lines = [] } = await (await fetch(`/api/tail?source=${encodeURIComponent(src)}&lines=400`)).json();
|
|
24
|
+
const filter = $("#filter").value.toLowerCase();
|
|
25
|
+
const shown = filter ? lines.filter((l) => l.toLowerCase().includes(filter)) : lines;
|
|
26
|
+
const pre = document.createElement("pre");
|
|
27
|
+
pre.textContent = shown.join("\n") || "(empty)";
|
|
28
|
+
$("#out").innerHTML = "";
|
|
29
|
+
$("#out").appendChild(pre);
|
|
30
|
+
pre.scrollTop = pre.scrollHeight;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
$("#toggleadd").onclick = () => {
|
|
35
|
+
const b = $("#addbar");
|
|
36
|
+
b.style.display = b.style.display === "none" ? "flex" : "none";
|
|
37
|
+
};
|
|
38
|
+
$("#addsrc").onclick = async () => {
|
|
39
|
+
const label = $("#newlabel").value.trim();
|
|
40
|
+
const file = $("#newfile").value.trim();
|
|
41
|
+
if (!label || !file) return;
|
|
42
|
+
const r = await (await fetch("/api/source", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label, file }) })).json();
|
|
43
|
+
if (!r.ok) return alert(r.error || "could not add source");
|
|
44
|
+
$("#newlabel").value = $("#newfile").value = "";
|
|
45
|
+
$("#addbar").style.display = "none";
|
|
46
|
+
await loadSources();
|
|
47
|
+
$("#src").value = label;
|
|
48
|
+
render();
|
|
49
|
+
};
|
|
50
|
+
$("#refresh").onclick = render;
|
|
51
|
+
$("#src").onchange = render;
|
|
52
|
+
$("#view").onchange = render;
|
|
53
|
+
$("#filter").oninput = render;
|
|
54
|
+
$("#follow").onchange = (e) => {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
if (e.target.checked) timer = setInterval(render, 3000);
|
|
57
|
+
};
|
|
58
|
+
loadSources().then(render);
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// setup.js — first-run / --edit wizard, built with Volt. Tick add-ons + fill
|
|
2
|
+
// settings → writes .env (a VOLT_ADDONS list + settings), adds any needed
|
|
3
|
+
// packages, installs, and starts the app. Add-on code is bundled; enabling is
|
|
4
|
+
// just config.
|
|
5
|
+
import { signal, computed, effect, html, mount } from "/volt.js";
|
|
6
|
+
|
|
7
|
+
const { available, themes = [], current, defaultPort, configDefaultPort = 5050 } = await (await fetch("/setup/state")).json();
|
|
8
|
+
const depsOf = Object.fromEntries(available.map((a) => [a.name, a.dependsOn || []]));
|
|
9
|
+
const order = available.map((a) => a.name);
|
|
10
|
+
const enabledNow = new Set(String(current.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
11
|
+
|
|
12
|
+
const state = signal({
|
|
13
|
+
addons: Object.fromEntries(available.map((a) => [a.name, enabledNow.has(a.name)])),
|
|
14
|
+
dbDriver: current.DB_DRIVER || "memory",
|
|
15
|
+
mongoUri: current.MONGODB_URI || "",
|
|
16
|
+
mongoDb: current.MONGODB_DATABASE || "",
|
|
17
|
+
dbUrl: current.DATABASE_URL || "",
|
|
18
|
+
smtpUrl: current.SMTP_URL || "",
|
|
19
|
+
mailFrom: current.MAIL_FROM || "",
|
|
20
|
+
mediaDriver: current.MEDIA_DRIVER || "local",
|
|
21
|
+
s3Endpoint: current.S3_ENDPOINT || "",
|
|
22
|
+
s3Region: current.S3_REGION || "",
|
|
23
|
+
s3Bucket: current.S3_BUCKET || "",
|
|
24
|
+
s3Key: current.S3_KEY || "",
|
|
25
|
+
s3Secret: current.S3_SECRET || "",
|
|
26
|
+
s3PublicBase: current.S3_PUBLIC_BASE || "",
|
|
27
|
+
port: current.PORT || String(defaultPort),
|
|
28
|
+
// detect the admin's timezone from their browser (the wizard runs here), so
|
|
29
|
+
// dates render in their zone — not the server's (usually UTC on a host).
|
|
30
|
+
tz: current.SITE_TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
|
31
|
+
siteName: current.SITE_NAME || "",
|
|
32
|
+
siteUrl: current.SITE_URL || "",
|
|
33
|
+
configPort: current.CONFIG_PORT || "",
|
|
34
|
+
theme: current.THEME || "",
|
|
35
|
+
aiProvider: current.AI_PROVIDER || "anthropic",
|
|
36
|
+
aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
|
|
37
|
+
aiToken: current.VOLT_AI_TOKEN || "",
|
|
38
|
+
});
|
|
39
|
+
const set = (patch) => state({ ...state(), ...patch });
|
|
40
|
+
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
41
|
+
const status = signal("");
|
|
42
|
+
// per-test inline results (shown right next to each Test button)
|
|
43
|
+
const dbTest = signal("");
|
|
44
|
+
const smtpTest = signal("");
|
|
45
|
+
const aiTest = signal("");
|
|
46
|
+
const genMsg = signal("");
|
|
47
|
+
const testResult = (m) => (m ? html`<span class="small ms-2 ${m.startsWith("✓") ? "text-success" : m.startsWith("✗") ? "text-danger" : "text-muted"}">${m}</span>` : "");
|
|
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)]; }));
|
|
49
|
+
|
|
50
|
+
// selected add-ons, dependencies expanded, in display order
|
|
51
|
+
function effective(s) {
|
|
52
|
+
const want = new Set();
|
|
53
|
+
const visit = (n) => {
|
|
54
|
+
if (want.has(n)) return;
|
|
55
|
+
want.add(n);
|
|
56
|
+
(depsOf[n] || []).forEach(visit);
|
|
57
|
+
};
|
|
58
|
+
for (const n of order) if (s.addons[n]) visit(n);
|
|
59
|
+
return order.filter((n) => want.has(n));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// which *enabled* add-ons pull in `name` as a (transitive) dependency
|
|
63
|
+
function requiredBy(s, name) {
|
|
64
|
+
const causes = [];
|
|
65
|
+
for (const n of order) {
|
|
66
|
+
if (n === name || !s.addons[n]) continue;
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const visit = (x) => {
|
|
69
|
+
if (seen.has(x)) return;
|
|
70
|
+
seen.add(x);
|
|
71
|
+
(depsOf[x] || []).forEach(visit);
|
|
72
|
+
};
|
|
73
|
+
(depsOf[n] || []).forEach(visit);
|
|
74
|
+
if (seen.has(name)) causes.push(n);
|
|
75
|
+
}
|
|
76
|
+
return causes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
|
|
80
|
+
function genEnv(s) {
|
|
81
|
+
const eff = effective(s);
|
|
82
|
+
const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
|
|
83
|
+
if (s.tz) out.push(`SITE_TZ=${clean(s.tz)}`); // admin's timezone, for date display
|
|
84
|
+
if (s.siteName) out.push(`SITE_NAME=${clean(s.siteName)}`);
|
|
85
|
+
if (s.siteUrl) out.push(`SITE_URL=${clean(s.siteUrl)}`);
|
|
86
|
+
if (s.configPort) out.push(`CONFIG_PORT=${clean(s.configPort)}`);
|
|
87
|
+
if ((eff.includes("pages") || eff.includes("posts")) && s.theme) out.push(`THEME=${clean(s.theme)}`);
|
|
88
|
+
if (s.aiKey) {
|
|
89
|
+
out.push(`AI_PROVIDER=${clean(s.aiProvider)}`);
|
|
90
|
+
const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
|
|
91
|
+
out.push(`${keyVar}=${clean(s.aiKey)}`);
|
|
92
|
+
}
|
|
93
|
+
if (s.aiToken) out.push(`VOLT_AI_TOKEN=${clean(s.aiToken)}`); // hosted-tier token (used when no local key)
|
|
94
|
+
if (eff.includes("db")) {
|
|
95
|
+
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
96
|
+
if (s.dbDriver === "mongodb") {
|
|
97
|
+
out.push(`MONGODB_URI=${clean(s.mongoUri)}`);
|
|
98
|
+
if (s.mongoDb) out.push(`MONGODB_DATABASE=${clean(s.mongoDb)}`);
|
|
99
|
+
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
100
|
+
out.push(`DATABASE_URL=${clean(s.dbUrl)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (eff.includes("mailer")) {
|
|
104
|
+
if (s.smtpUrl) out.push(`SMTP_URL=${clean(s.smtpUrl)}`);
|
|
105
|
+
else out.push("# SMTP_URL= # unset → emails print to the console");
|
|
106
|
+
if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
|
|
107
|
+
}
|
|
108
|
+
if (eff.includes("media")) {
|
|
109
|
+
out.push(`MEDIA_DRIVER=${clean(s.mediaDriver)}`);
|
|
110
|
+
if (s.mediaDriver === "s3") {
|
|
111
|
+
out.push(`S3_ENDPOINT=${clean(s.s3Endpoint)}`);
|
|
112
|
+
out.push(`S3_REGION=${clean(s.s3Region)}`);
|
|
113
|
+
out.push(`S3_BUCKET=${clean(s.s3Bucket)}`);
|
|
114
|
+
out.push(`S3_KEY=${clean(s.s3Key)}`);
|
|
115
|
+
out.push(`S3_SECRET=${clean(s.s3Secret)}`);
|
|
116
|
+
if (s.s3PublicBase) out.push(`S3_PUBLIC_BASE=${clean(s.s3PublicBase)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out.join("\n") + "\n";
|
|
120
|
+
}
|
|
121
|
+
const env = computed(() => genEnv(state()));
|
|
122
|
+
const eff = computed(() => effective(state()));
|
|
123
|
+
// Memoized, primitive-valued derivations: a conditional section keyed on these
|
|
124
|
+
// only re-renders when the *discriminant* changes — not on every keystroke in a
|
|
125
|
+
// field it contains (which would recreate the input and drop focus).
|
|
126
|
+
const dbDriver = computed(() => state().dbDriver);
|
|
127
|
+
const mediaDriver = computed(() => state().mediaDriver);
|
|
128
|
+
const hasDb = computed(() => eff().includes("db"));
|
|
129
|
+
const hasMailer = computed(() => eff().includes("mailer"));
|
|
130
|
+
const hasMedia = computed(() => eff().includes("media"));
|
|
131
|
+
const hasContent = computed(() => eff().includes("pages") || eff().includes("posts")); // themes apply to pages/posts
|
|
132
|
+
|
|
133
|
+
// "Customize": copy the selected bundled theme to pages/_theme.js, then use it
|
|
134
|
+
// locally (THEME cleared) so edits take effect.
|
|
135
|
+
async function ejectTheme() {
|
|
136
|
+
const theme = state().theme;
|
|
137
|
+
if (!theme) return;
|
|
138
|
+
status("Copying theme…");
|
|
139
|
+
try {
|
|
140
|
+
const r = await (await fetch("/setup/eject-theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme }) })).json();
|
|
141
|
+
if (r.ok) {
|
|
142
|
+
set({ theme: "" });
|
|
143
|
+
status(`Copied ${theme} → ${r.path}. Edit it freely; THEME was cleared so your local copy is used.`);
|
|
144
|
+
} else status("Error: " + (r.error || "?"));
|
|
145
|
+
} catch {
|
|
146
|
+
status("Network error copying theme.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function testDb() {
|
|
151
|
+
const s = state();
|
|
152
|
+
const e = { DB_DRIVER: s.dbDriver };
|
|
153
|
+
if (s.dbDriver === "mongodb") {
|
|
154
|
+
e.MONGODB_URI = s.mongoUri;
|
|
155
|
+
e.MONGODB_DATABASE = s.mongoDb;
|
|
156
|
+
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
157
|
+
e.DATABASE_URL = s.dbUrl;
|
|
158
|
+
}
|
|
159
|
+
dbTest("Testing…");
|
|
160
|
+
try {
|
|
161
|
+
const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
|
|
162
|
+
dbTest(r.ok ? `✓ Connected (${r.driver})` : `✗ ${r.error}`);
|
|
163
|
+
} catch {
|
|
164
|
+
dbTest("✗ network error");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function testSmtp() {
|
|
168
|
+
smtpTest("Testing…");
|
|
169
|
+
try {
|
|
170
|
+
const r = await (await fetch("/setup/test-smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
171
|
+
smtpTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
172
|
+
} catch {
|
|
173
|
+
smtpTest("✗ network error");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function testAi() {
|
|
177
|
+
aiTest("Testing…");
|
|
178
|
+
try {
|
|
179
|
+
const r = await (await fetch("/setup/test-ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: envObj() }) })).json();
|
|
180
|
+
aiTest(r.ok ? `✓ ${r.detail || "OK"}` : `✗ ${r.error}`);
|
|
181
|
+
} catch {
|
|
182
|
+
aiTest("✗ network error");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function apply() {
|
|
187
|
+
status("Saving…");
|
|
188
|
+
let d;
|
|
189
|
+
try {
|
|
190
|
+
d = await (await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ addons: eff(), env: env() }) })).json();
|
|
191
|
+
} catch {
|
|
192
|
+
return status("Network error.");
|
|
193
|
+
}
|
|
194
|
+
if (!d.ok) return status("Error: " + d.error);
|
|
195
|
+
status(d.installing?.length ? `Installing ${d.installing.join(", ")}, then starting…` : "Starting the app…");
|
|
196
|
+
const target = `http://localhost:${d.port}/`;
|
|
197
|
+
const tries = d.installing?.length ? 90 : 20; // npm install can take a while
|
|
198
|
+
const go = async (n) => {
|
|
199
|
+
try {
|
|
200
|
+
await fetch(target, { mode: "no-cors" });
|
|
201
|
+
location.href = target;
|
|
202
|
+
} catch {
|
|
203
|
+
if (n > 0) setTimeout(() => go(n - 1), 500);
|
|
204
|
+
else location.href = target;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
setTimeout(() => go(tries), 600);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- views ---
|
|
211
|
+
const field = (label, key, placeholder = "") =>
|
|
212
|
+
html`<div class="mb-2">
|
|
213
|
+
<label class="form-label small mb-1">${label}</label>
|
|
214
|
+
<input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
|
|
215
|
+
</div>`;
|
|
216
|
+
|
|
217
|
+
// A dependency pulled in by another enabled add-on shows as checked + disabled
|
|
218
|
+
// (you can't turn it off while something needs it), with a "required by" note —
|
|
219
|
+
// so the .env's VOLT_ADDONS always matches what the boxes show.
|
|
220
|
+
const addonRow = (a) =>
|
|
221
|
+
html`<div class="form-check mb-2">
|
|
222
|
+
<input class="form-check-input" type="checkbox" id=${"x-" + a.name}
|
|
223
|
+
checked=${() => eff().includes(a.name)}
|
|
224
|
+
disabled=${() => !state().addons[a.name] && eff().includes(a.name)}
|
|
225
|
+
onchange=${() => toggle(a.name)} />
|
|
226
|
+
<label class="form-check-label" for=${"x-" + a.name}>
|
|
227
|
+
<span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}${() =>
|
|
228
|
+
!state().addons[a.name] && eff().includes(a.name) ? html` <span class="text-muted small">· required by ${requiredBy(state(), a.name).join(", ")}</span>` : ""}
|
|
229
|
+
<div class="small text-muted">${a.description}</div>
|
|
230
|
+
</label>
|
|
231
|
+
</div>`;
|
|
232
|
+
|
|
233
|
+
const dbSettings = () =>
|
|
234
|
+
html`<div class="mb-2">
|
|
235
|
+
<label class="form-label small mb-1">Database (DB_DRIVER)</label>
|
|
236
|
+
<select class="form-select" value=${() => dbDriver()} onchange=${(e) => set({ dbDriver: e.target.value })}>
|
|
237
|
+
<option value="memory">memory (no setup)</option>
|
|
238
|
+
<option value="mongodb">mongodb</option>
|
|
239
|
+
<option value="mysql">mysql</option>
|
|
240
|
+
<option value="postgres">postgres</option>
|
|
241
|
+
</select>
|
|
242
|
+
</div>
|
|
243
|
+
${() =>
|
|
244
|
+
dbDriver() === "mongodb"
|
|
245
|
+
? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
|
|
246
|
+
: dbDriver() === "mysql" || dbDriver() === "postgres"
|
|
247
|
+
? field("DATABASE_URL", "dbUrl", dbDriver() + "://user:pass@host/db")
|
|
248
|
+
: null}
|
|
249
|
+
${() => (dbDriver() !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>${() => testResult(dbTest())}` : null)}`;
|
|
250
|
+
|
|
251
|
+
const mediaSettings = () =>
|
|
252
|
+
html`<div class="mb-2">
|
|
253
|
+
<label class="form-label small mb-1">Media storage (MEDIA_DRIVER)</label>
|
|
254
|
+
<select class="form-select" value=${() => mediaDriver()} onchange=${(e) => set({ mediaDriver: e.target.value })}>
|
|
255
|
+
<option value="local">local (disk)</option>
|
|
256
|
+
<option value="s3">s3 — AWS S3 / DigitalOcean Spaces</option>
|
|
257
|
+
</select>
|
|
258
|
+
</div>
|
|
259
|
+
${() =>
|
|
260
|
+
mediaDriver() === "s3"
|
|
261
|
+
? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
|
|
262
|
+
: null}`;
|
|
263
|
+
|
|
264
|
+
// theme chooser: a bundled theme (or the built-in/local one), with Customize
|
|
265
|
+
const themePicker = () =>
|
|
266
|
+
html`<div class="mb-2">
|
|
267
|
+
<label class="form-label small mb-1">Theme (THEME)</label>
|
|
268
|
+
<select class="form-select" value=${() => state().theme} onchange=${(e) => set({ theme: e.target.value })}>
|
|
269
|
+
<option value="">default — built-in, or your pages/_theme.js</option>
|
|
270
|
+
${themes.map((t) => html`<option value=${t.name}>${t.name}${t.description ? " — " + t.description : ""}</option>`)}
|
|
271
|
+
</select>
|
|
272
|
+
${() =>
|
|
273
|
+
state().theme
|
|
274
|
+
? html`<button class="btn btn-sm btn-outline-secondary mt-1" onclick=${ejectTheme}>Customize → copy to pages/_theme.js</button>`
|
|
275
|
+
: html`<div class="small text-muted mt-1">Pick a starter theme, or keep the built-in / your local <code>pages/_theme.js</code>.</div>`}
|
|
276
|
+
</div>`;
|
|
277
|
+
|
|
278
|
+
// AI keys (optional) — used by the WYSIWYG editor's assistant. Kept server-side.
|
|
279
|
+
const AI_KEY_URL = {
|
|
280
|
+
anthropic: "https://console.anthropic.com/settings/keys",
|
|
281
|
+
openai: "https://platform.openai.com/api-keys",
|
|
282
|
+
gemini: "https://aistudio.google.com/app/apikey",
|
|
283
|
+
};
|
|
284
|
+
const aiSettings = () =>
|
|
285
|
+
html`<details class="mb-2"><summary class="form-label small mb-0" style="cursor:pointer">AI assistant for the editor (optional)</summary>
|
|
286
|
+
<div class="mt-2">
|
|
287
|
+
<p class="small text-muted mb-2">Powers the WYSIWYG editor's "write with AI" button. <strong>Totally optional</strong> — leave the key blank and the editor still works, just without AI.</p>
|
|
288
|
+
<label class="form-label small mb-1">Provider</label>
|
|
289
|
+
<select class="form-select mb-1" value=${() => state().aiProvider} onchange=${(e) => set({ aiProvider: e.target.value })}>
|
|
290
|
+
<option value="anthropic">Anthropic (Claude)</option>
|
|
291
|
+
<option value="openai">OpenAI</option>
|
|
292
|
+
<option value="gemini">Google Gemini</option>
|
|
293
|
+
</select>
|
|
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>`}
|
|
295
|
+
${field("API key", "aiKey", "sk-…")}
|
|
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>
|
|
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())}`)}
|
|
298
|
+
<div class="mt-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testAi}>Test AI</button>${() => testResult(aiTest())}</div>
|
|
299
|
+
</div>
|
|
300
|
+
</details>`;
|
|
301
|
+
|
|
302
|
+
// --- Manage content (a second screen reached via "Manage content →") ---
|
|
303
|
+
const view = signal("config"); // "config" | "manage" | "media"
|
|
304
|
+
// Desktop-only config: keep settings readable, but let the editor + library go wide.
|
|
305
|
+
effect(() => {
|
|
306
|
+
const w = document.getElementById("wrap");
|
|
307
|
+
if (w) w.style.maxWidth = view() !== "config" ? "min(1200px, 95vw)" : "720px";
|
|
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>`;
|
|
357
|
+
// upgrade check: compare bundled version to npm latest; offer a one-click upgrade
|
|
358
|
+
const upgrade = signal(null); // { current, latest, available }
|
|
359
|
+
fetch("/setup/upgrade-check").then((r) => r.json()).then((u) => upgrade(u)).catch(() => {});
|
|
360
|
+
async function doUpgrade() {
|
|
361
|
+
status("Upgrading via npx create-volt@latest update…");
|
|
362
|
+
try {
|
|
363
|
+
const r = await (await fetch("/setup/upgrade", { method: "POST" })).json();
|
|
364
|
+
status(r.ok ? "Upgraded — restart the wizard/app to load the new version." : "Upgrade failed (see terminal).");
|
|
365
|
+
if (r.ok) upgrade({ ...upgrade(), available: false });
|
|
366
|
+
} catch {
|
|
367
|
+
status("Upgrade request failed.");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// AI credits — config-only purchase flow (gateway mode). Hidden unless a
|
|
372
|
+
// VOLT_AI_TOKEN is set and the gateway answers.
|
|
373
|
+
const aiCredits = signal(null); // { ok, tier, creditBalanceUsd, payments } | { ok:false }
|
|
374
|
+
fetch("/setup/ai-credits").then((r) => r.json()).then((c) => aiCredits(c)).catch(() => {});
|
|
375
|
+
async function buyCredits(amountUsd) {
|
|
376
|
+
status("Starting checkout…");
|
|
377
|
+
try {
|
|
378
|
+
const r = await (await fetch("/setup/ai-credits/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amountUsd }) })).json();
|
|
379
|
+
if (r.ok && r.url) window.open(r.url, "_blank");
|
|
380
|
+
else status("Checkout failed: " + (r.error || "?"));
|
|
381
|
+
} catch {
|
|
382
|
+
status("Checkout request failed.");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function genToken() {
|
|
386
|
+
genMsg("Requesting…");
|
|
387
|
+
try {
|
|
388
|
+
const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
|
|
389
|
+
if (r.ok && r.token) {
|
|
390
|
+
set({ aiToken: r.token });
|
|
391
|
+
genMsg("✓ token generated — Apply to save");
|
|
392
|
+
} else genMsg("✗ " + (r.error || "no token"));
|
|
393
|
+
} catch {
|
|
394
|
+
genMsg("✗ request failed");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const items = signal({ pages: [], posts: [] });
|
|
398
|
+
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
399
|
+
let ed = null; // live RTEPro instance for the open editor
|
|
400
|
+
let themeCss = ""; // active theme's CSS, so the editor renders pages themed
|
|
401
|
+
fetch("/setup/theme-css").then((r) => r.text()).then((c) => { themeCss = c; }).catch(() => {});
|
|
402
|
+
const loadItems = async () => items(await (await fetch("/setup/content")).json());
|
|
403
|
+
// raw .md → { title, body, isHtml }; RTEPro takes markdown directly (setMarkdown),
|
|
404
|
+
// so no markdown library is needed.
|
|
405
|
+
function parseDoc(raw) {
|
|
406
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
407
|
+
const front = fm ? fm[1] : "";
|
|
408
|
+
const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
|
|
409
|
+
return { title, body: fm ? raw.slice(fm[0].length) : raw, isHtml: /^format:\s*html\s*$/m.test(front) };
|
|
410
|
+
}
|
|
411
|
+
function mountEditor(doc) {
|
|
412
|
+
ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…", aiProxy: "/setup/ai", aiProvider: state().aiProvider || "anthropic", exportCSS: themeCss });
|
|
413
|
+
if (doc && doc.isHtml) ed.setHTML(doc.body || "");
|
|
414
|
+
else ed.setMarkdown((doc && doc.body) || "");
|
|
415
|
+
}
|
|
416
|
+
async function editItem(type, slug) {
|
|
417
|
+
const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
|
|
418
|
+
const doc = parseDoc(d.body || "");
|
|
419
|
+
editing({ type, slug, title: doc.title, isNew: false });
|
|
420
|
+
queueMicrotask(() => mountEditor(doc));
|
|
421
|
+
}
|
|
422
|
+
function newItem(type) {
|
|
423
|
+
editing({ type, slug: "", title: "", isNew: true });
|
|
424
|
+
queueMicrotask(() => mountEditor({ body: "", isHtml: false }));
|
|
425
|
+
}
|
|
426
|
+
// markdown can't round-trip complex layouts (columns, inline styles, merged cells,
|
|
427
|
+
// embeds) — save those as HTML so they aren't flattened.
|
|
428
|
+
function isComplex(h) {
|
|
429
|
+
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);
|
|
430
|
+
}
|
|
431
|
+
async function saveItem() {
|
|
432
|
+
const e = editing();
|
|
433
|
+
const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
|
|
434
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
|
|
435
|
+
const title = (document.querySelector("#mg-title").value || "").trim() || slug;
|
|
436
|
+
const htmlOut = ed ? ed.getHTML() : "";
|
|
437
|
+
const complex = isComplex(htmlOut);
|
|
438
|
+
const front = [`title: ${title}`];
|
|
439
|
+
if (complex) front.push("format: html");
|
|
440
|
+
const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
|
|
441
|
+
const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
|
|
442
|
+
const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
|
|
443
|
+
if (!r.ok) return status("Error: " + (r.error || "?"));
|
|
444
|
+
status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
|
|
445
|
+
ed = null;
|
|
446
|
+
editing(null);
|
|
447
|
+
loadItems();
|
|
448
|
+
}
|
|
449
|
+
async function delItem(type, slug) {
|
|
450
|
+
if (typeof confirm === "function" && !confirm(`Delete ${slug}?`)) return;
|
|
451
|
+
await fetch("/setup/content/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type, slug }) });
|
|
452
|
+
status("Deleted " + slug);
|
|
453
|
+
loadItems();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const itemRow = (it) =>
|
|
457
|
+
html`<li class="list-group-item bg-transparent text-light d-flex justify-content-between align-items-center py-1 px-2">
|
|
458
|
+
<span><a href=${"http://localhost:" + state().port + (it.type === "post" ? "/blog/" : "/") + it.slug} target="_blank" rel="noopener">${it.title}</a> <span class="text-muted small">/${it.type === "post" ? "blog/" : ""}${it.slug}</span></span>
|
|
459
|
+
<span><button class="btn btn-sm btn-link p-0 me-3" onclick=${() => editItem(it.type, it.slug)}>edit</button><button class="btn btn-sm btn-link p-0 text-danger" onclick=${() => delItem(it.type, it.slug)}>delete</button></span>
|
|
460
|
+
</li>`;
|
|
461
|
+
const section = (label, type, key) =>
|
|
462
|
+
html`<div class="mb-3">
|
|
463
|
+
<div class="d-flex justify-content-between align-items-center mb-1"><strong>${label}</strong><button class="btn btn-sm btn-outline-secondary" onclick=${() => newItem(type)}>+ New</button></div>
|
|
464
|
+
${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
|
|
465
|
+
</div>`;
|
|
466
|
+
const editorPanel = () => {
|
|
467
|
+
const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
|
|
468
|
+
return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
|
|
469
|
+
<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>
|
|
470
|
+
<div id="mg-editor"></div>
|
|
471
|
+
<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>
|
|
472
|
+
</div>`;
|
|
473
|
+
};
|
|
474
|
+
const manageView = () =>
|
|
475
|
+
html`<div class="card-x p-4 mb-3">
|
|
476
|
+
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h6 mb-0">Manage content</h2><button class="btn btn-sm btn-outline-secondary" onclick=${() => view("config")}>← Settings</button></div>
|
|
477
|
+
${() => (editing() ? editorPanel() : html`${section("Pages", "page", "pages")}${section("Posts", "post", "posts")}<p class="small text-muted mb-0">Pages → <code>/slug</code>, posts → <code>/blog/slug</code>; <code>index</code> page is your home. All rendered in your theme. Edits hot-reload the running app.</p>`)}
|
|
478
|
+
</div>`;
|
|
479
|
+
|
|
480
|
+
const configView = () =>
|
|
481
|
+
html`${() => {
|
|
482
|
+
const u = upgrade();
|
|
483
|
+
if (!u || !u.current || u.current === "?") return "";
|
|
484
|
+
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>`;
|
|
485
|
+
}}
|
|
486
|
+
${() => (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>` : "")}
|
|
487
|
+
${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>` : ""}
|
|
488
|
+
<div class="card-x p-4 mb-3">
|
|
489
|
+
<h2 class="h6 mb-3">Settings</h2>
|
|
490
|
+
${field("PORT", "port", String(defaultPort))}
|
|
491
|
+
${field("SITE_NAME", "siteName", "My Site")}
|
|
492
|
+
${() => (hasContent() ? themePicker() : null)}
|
|
493
|
+
${() => (hasDb() ? dbSettings() : null)}
|
|
494
|
+
${() => (hasMailer() ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}<div class="mb-2"><button class="btn btn-sm btn-outline-secondary" onclick=${testSmtp}>Test SMTP</button>${() => testResult(smtpTest())}</div>` : null)}
|
|
495
|
+
${() => (hasMedia() ? mediaSettings() : null)}
|
|
496
|
+
${aiSettings()}
|
|
497
|
+
${field("SITE_URL (optional — absolute links, RSS, canonical)", "siteUrl", "https://example.com")}
|
|
498
|
+
${field("CONFIG_PORT (this wizard's own port)", "configPort", String(configDefaultPort))}
|
|
499
|
+
</div>
|
|
500
|
+
<div class="card-x p-4 mb-3">
|
|
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>
|
|
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>
|
|
503
|
+
</div>`;
|
|
504
|
+
|
|
505
|
+
mount(
|
|
506
|
+
"#app",
|
|
507
|
+
() => (view() === "config" ? configView() : view() === "media" ? mediaView() : manageView()),
|
|
508
|
+
() => (status() ? html`<p class="small accent">${status}</p>` : null),
|
|
509
|
+
);
|