create-volt 0.11.0 → 0.12.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 +12 -0
- package/README.md +3 -2
- package/index.js +6 -1
- package/package.json +1 -1
- package/templates/starter/README.md +47 -0
- package/templates/starter/env +2 -0
- package/templates/starter/gitignore +5 -0
- package/templates/starter/package.json +16 -0
- package/templates/starter/public/app.js +159 -0
- package/templates/starter/public/volt.js +265 -0
- package/templates/starter/server.js +421 -0
- package/templates/starter/setup/index.html +27 -0
- package/templates/starter/setup/setup.js +160 -0
- package/templates/starter/setup/studio.html +28 -0
- package/templates/starter/views/index.html +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ 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.12.0] - 2026-06-28
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`--template starter`** — a complete, no-build app shell, fully wired and on
|
|
11
|
+
out of the box: top nav over **Home**, magic-link **Account**, per-user
|
|
12
|
+
**Notes** (auth-gated CRUD, db-backed), and **Chat** (realtime rooms + presence
|
|
13
|
+
+ typing). Ships a default `.env` enabling db+mailer+auth+realtime; includes
|
|
14
|
+
the setup wizard (`--edit`) and Studio (`--studio`). The SaaS-style starting
|
|
15
|
+
point.
|
|
16
|
+
- Templates can now ship a default `.env` (as `env`, renamed on scaffold).
|
|
17
|
+
|
|
7
18
|
## [0.11.0] - 2026-06-28
|
|
8
19
|
|
|
9
20
|
### Added
|
|
@@ -162,6 +173,7 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
162
173
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
163
174
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
164
175
|
|
|
176
|
+
[0.12.0]: https://github.com/MIR-2025/volt/releases/tag/v0.12.0
|
|
165
177
|
[0.11.0]: https://github.com/MIR-2025/volt/releases/tag/v0.11.0
|
|
166
178
|
[0.10.0]: https://github.com/MIR-2025/volt/releases/tag/v0.10.0
|
|
167
179
|
[0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
|
package/README.md
CHANGED
|
@@ -28,8 +28,9 @@ Pick one with `--template` (default: `default`):
|
|
|
28
28
|
|
|
29
29
|
| Template | What you get |
|
|
30
30
|
| ----------- | -------------------------------------------------------------------- |
|
|
31
|
-
| `default` | The Counter + Todos demo on the Volt signal engine. Minimal.
|
|
32
|
-
| `
|
|
31
|
+
| `default` | The Counter + Todos demo on the Volt signal engine. Minimal. Add-ons off; turn them on in the wizard. |
|
|
32
|
+
| `starter` | A full app shell, everything on out of the box: top nav + Home, magic-link **Account**, per-user **Notes** (CRUD), and **Chat** (realtime). The SaaS-style starting point. |
|
|
33
|
+
| `guestbook` | A focused real app: magic-link auth + a Socket.io message board, over pluggable **MongoDB / MySQL / Postgres** storage (in-memory by default). |
|
|
33
34
|
|
|
34
35
|
Then:
|
|
35
36
|
|
package/index.js
CHANGED
|
@@ -39,7 +39,7 @@ ${bold("Usage")}
|
|
|
39
39
|
npx create-volt@latest studio # browse your data — ephemeral, localhost (like Prisma Studio)
|
|
40
40
|
|
|
41
41
|
${bold("Options")}
|
|
42
|
-
--template <name>
|
|
42
|
+
--template <name> Template: default | starter | guestbook (default: default)
|
|
43
43
|
--port <number> Dev port for the app (default: derived from today's date)
|
|
44
44
|
--skip-install Don't run the package manager install step
|
|
45
45
|
--no-git Don't initialize a git repository
|
|
@@ -245,6 +245,11 @@ const shippedGitignore = path.join(targetDir, "gitignore");
|
|
|
245
245
|
if (fs.existsSync(shippedGitignore)) {
|
|
246
246
|
fs.renameSync(shippedGitignore, path.join(targetDir, ".gitignore"));
|
|
247
247
|
}
|
|
248
|
+
// some templates ship a default .env as "env" (a real .env is stripped from npm)
|
|
249
|
+
const shippedEnv = path.join(targetDir, "env");
|
|
250
|
+
if (fs.existsSync(shippedEnv)) {
|
|
251
|
+
fs.renameSync(shippedEnv, path.join(targetDir, ".env"));
|
|
252
|
+
}
|
|
248
253
|
|
|
249
254
|
// Bundle the add-on sources so the app's setup wizard can enable them later
|
|
250
255
|
// (only for templates that ship the wizard, i.e. have a setup/ dir).
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# ⚡ Volt starter
|
|
2
|
+
|
|
3
|
+
A complete, **no-build** app shell — auth, realtime chat, a per-user notes CRUD,
|
|
4
|
+
and a database, all wired and turned on out of the box. Edit files and save;
|
|
5
|
+
the page hot-reloads.
|
|
6
|
+
|
|
7
|
+
## Run
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm run dev # → http://localhost:26628
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
It ships with `VOLT_ADDONS=db,mailer,auth,realtime` in `.env`, so it runs the
|
|
15
|
+
full stack immediately (in-memory by default — magic-link emails print to the
|
|
16
|
+
console). Sign in from the **Account** tab, then use **Notes** and **Chat**.
|
|
17
|
+
|
|
18
|
+
## Sections
|
|
19
|
+
|
|
20
|
+
- **Home** — a simple dashboard.
|
|
21
|
+
- **Account** — magic-link sign-in (no passwords).
|
|
22
|
+
- **Notes** — per-user CRUD, auth-gated, stored in the db.
|
|
23
|
+
- **Chat** — Socket.io rooms with presence + typing.
|
|
24
|
+
|
|
25
|
+
## Configure & inspect
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm run dev -- --edit # ephemeral wizard: toggle add-ons, set DB/SMTP, etc.
|
|
29
|
+
npm run dev -- --studio # ephemeral data browser (like Prisma Studio)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Both are localhost-only and disappear when you close them — no standing admin
|
|
33
|
+
surface. Switch to a real database (MongoDB / MySQL / Postgres) anytime in the
|
|
34
|
+
wizard.
|
|
35
|
+
|
|
36
|
+
## Layout
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
public/app.js the shell — nav + sections (Home/Notes/Chat/Account)
|
|
40
|
+
public/volt.js the Volt library (no build step)
|
|
41
|
+
views/index.html the HTML shell
|
|
42
|
+
server.js dev server + wizard + studio; wires enabled add-ons + notes
|
|
43
|
+
.volt/addons/ bundled add-on sources (enabled via .env)
|
|
44
|
+
.env which add-ons are on, + their settings
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Scaffolded with [`create-volt`](https://www.npmjs.com/package/create-volt) — `--template starter`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "volt-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "A Volt app — no-build, signals-based UI with Socket.io hot reload.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "server.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node server.js",
|
|
10
|
+
"dev": "node server.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"express": "^4.19.2",
|
|
14
|
+
"socket.io": "^4.7.5"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// app.js — the starter app shell: a top nav over sections (Home, Notes, Chat,
|
|
2
|
+
// Account). Built on Volt signals. Auth + realtime + db are wired server-side;
|
|
3
|
+
// this is the UI. All dynamic text renders through Volt holes (text nodes,
|
|
4
|
+
// HTML-escaped), so user content can't inject markup.
|
|
5
|
+
import { signal, computed, el, html, mount } from "/volt.js";
|
|
6
|
+
|
|
7
|
+
let enabled = [];
|
|
8
|
+
try {
|
|
9
|
+
enabled = await (await fetch("/__volt/addons")).json();
|
|
10
|
+
} catch {
|
|
11
|
+
/* ignore */
|
|
12
|
+
}
|
|
13
|
+
const hasAuth = enabled.includes("auth");
|
|
14
|
+
const hasChat = enabled.includes("realtime");
|
|
15
|
+
|
|
16
|
+
const api = async (url, body, method) => {
|
|
17
|
+
const res = await fetch(url, {
|
|
18
|
+
method: method || (body ? "POST" : "GET"),
|
|
19
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
20
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
21
|
+
});
|
|
22
|
+
return res.json().catch(() => ({}));
|
|
23
|
+
};
|
|
24
|
+
const shortName = (e) => String(e || "").split("@")[0];
|
|
25
|
+
|
|
26
|
+
const me = signal(null);
|
|
27
|
+
const section = signal("home");
|
|
28
|
+
api("/api/me").then((r) => me(r.email || null));
|
|
29
|
+
|
|
30
|
+
// --- Account ---
|
|
31
|
+
function accountSection() {
|
|
32
|
+
const email = signal("");
|
|
33
|
+
const notice = signal("");
|
|
34
|
+
async function sendLink(e) {
|
|
35
|
+
e?.preventDefault?.();
|
|
36
|
+
const addr = email().trim();
|
|
37
|
+
if (!addr) return;
|
|
38
|
+
notice("Sending…");
|
|
39
|
+
const r = await api("/api/login", { email: addr });
|
|
40
|
+
notice(r.ok ? (r.dev ? "Magic link printed to the server console — open it to sign in." : `Link sent to ${addr}.`) : r.error || "Failed.");
|
|
41
|
+
}
|
|
42
|
+
async function logout() {
|
|
43
|
+
await api("/api/logout", {});
|
|
44
|
+
me(null);
|
|
45
|
+
}
|
|
46
|
+
return html`<div class="card-x p-4">
|
|
47
|
+
<h2 class="h6 mb-3">Account</h2>
|
|
48
|
+
${() =>
|
|
49
|
+
me()
|
|
50
|
+
? html`<div class="d-flex justify-content-between align-items-center">
|
|
51
|
+
<span class="text-muted small">Signed in as <span class="accent fw-semibold">${() => me()}</span></span>
|
|
52
|
+
<button class="btn btn-sm btn-outline-secondary" onclick=${logout}>Sign out</button>
|
|
53
|
+
</div>`
|
|
54
|
+
: html`<form class="d-flex gap-2" onsubmit=${sendLink}>
|
|
55
|
+
<input class="form-control" type="email" name="email" placeholder="you@example.com" maxlength="320" autocomplete="email" required value=${email} oninput=${(e) => email(e.target.value)} />
|
|
56
|
+
<button class="btn btn-primary" type="submit">Send magic link</button>
|
|
57
|
+
</form>`}
|
|
58
|
+
${() => (notice() ? html`<p class="small text-muted mb-0 mt-2">${notice}</p>` : null)}
|
|
59
|
+
</div>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Notes (per-user CRUD, auth-gated) ---
|
|
63
|
+
function notesSection() {
|
|
64
|
+
const notes = signal([]);
|
|
65
|
+
const draft = signal("");
|
|
66
|
+
const err = signal("");
|
|
67
|
+
async function load() {
|
|
68
|
+
if (!me()) return;
|
|
69
|
+
const r = await api("/api/notes");
|
|
70
|
+
notes(r.notes || []);
|
|
71
|
+
}
|
|
72
|
+
async function add() {
|
|
73
|
+
const text = draft().trim();
|
|
74
|
+
if (!text) return;
|
|
75
|
+
const r = await api("/api/notes", { text });
|
|
76
|
+
if (r.ok) {
|
|
77
|
+
draft("");
|
|
78
|
+
load();
|
|
79
|
+
} else err(r.error || "Failed.");
|
|
80
|
+
}
|
|
81
|
+
async function del(id) {
|
|
82
|
+
await api("/api/notes/" + encodeURIComponent(id), null, "DELETE");
|
|
83
|
+
load();
|
|
84
|
+
}
|
|
85
|
+
// reload whenever auth state flips to signed-in
|
|
86
|
+
let loadedFor = null;
|
|
87
|
+
const sync = computed(() => {
|
|
88
|
+
if (me() && loadedFor !== me()) {
|
|
89
|
+
loadedFor = me();
|
|
90
|
+
load();
|
|
91
|
+
}
|
|
92
|
+
return me();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return html`<div class="card-x p-4">
|
|
96
|
+
<h2 class="h6 mb-3">Notes</h2>
|
|
97
|
+
${() =>
|
|
98
|
+
!sync()
|
|
99
|
+
? html`<p class="text-muted small mb-0">Sign in (Account) to keep notes.</p>`
|
|
100
|
+
: html`<div class="input-group mb-3">
|
|
101
|
+
<input class="form-control" maxlength="2000" placeholder="Write a note…" value=${draft} oninput=${(e) => draft(e.target.value)} onkeydown=${(e) => e.key === "Enter" && add()} />
|
|
102
|
+
<button class="btn btn-primary" onclick=${add}>Add</button>
|
|
103
|
+
</div>
|
|
104
|
+
${() =>
|
|
105
|
+
notes().length
|
|
106
|
+
? notes().map(
|
|
107
|
+
(n) => html`<div class="d-flex justify-content-between align-items-start gap-2 py-2" style="border-top:1px solid #232a36">
|
|
108
|
+
<span>${n.text}</span>
|
|
109
|
+
<button class="btn btn-sm btn-outline-danger" onclick=${() => del(n.id)}>✕</button>
|
|
110
|
+
</div>`,
|
|
111
|
+
)
|
|
112
|
+
: html`<span class="text-muted small">No notes yet.</span>`}
|
|
113
|
+
${() => (err() ? html`<p class="small text-danger mb-0 mt-2">${err}</p>` : null)}`}
|
|
114
|
+
</div>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Home ---
|
|
118
|
+
function homeSection() {
|
|
119
|
+
return html`<div class="card-x p-4">
|
|
120
|
+
<h2 class="h5 mb-2">Welcome ${() => (me() ? html`back, <span class="accent">${() => shortName(me())}</span>` : "to your Volt app")}</h2>
|
|
121
|
+
<p class="text-muted mb-3">A no-build, signals-based app — auth, realtime, and a database, all wired and configured by file. Edit <code>public/app.js</code> and save; it hot-reloads.</p>
|
|
122
|
+
<div class="d-flex flex-wrap gap-2">
|
|
123
|
+
<button class="btn btn-sm btn-outline-secondary" onclick=${() => section("notes")}>📝 Notes</button>
|
|
124
|
+
${hasChat ? html`<button class="btn btn-sm btn-outline-secondary" onclick=${() => section("chat")}>💬 Chat</button>` : ""}
|
|
125
|
+
<button class="btn btn-sm btn-outline-secondary" onclick=${() => section("account")}>${() => (me() ? "👤 Account" : "🔑 Sign in")}</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- build sections once; toggle visibility (keeps state + one chat socket) ---
|
|
131
|
+
const sections = { home: homeSection(), account: accountSection() };
|
|
132
|
+
if (hasAuth) sections.notes = notesSection();
|
|
133
|
+
if (hasChat) {
|
|
134
|
+
try {
|
|
135
|
+
sections.chat = (await import("/chat-ui.js")).chatPanel();
|
|
136
|
+
} catch {
|
|
137
|
+
/* chat UI unavailable */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const TABS = [
|
|
142
|
+
["home", "Home"],
|
|
143
|
+
...(hasAuth ? [["notes", "Notes"]] : []),
|
|
144
|
+
...(sections.chat ? [["chat", "Chat"]] : []),
|
|
145
|
+
["account", "Account"],
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const nav = () =>
|
|
149
|
+
html`<nav class="navx py-2 mb-4">
|
|
150
|
+
<div class="container d-flex align-items-center" style="max-width:760px">
|
|
151
|
+
<span class="brand me-3"><span class="accent">⚡ Volt</span></span>
|
|
152
|
+
${TABS.map(([key, label]) => html`<button class=${() => "btn btn-link btn-sm " + (section() === key ? "active" : "")} onclick=${() => section(key)}>${label}</button>`)}
|
|
153
|
+
<span class="ms-auto small text-muted">${() => (me() ? shortName(me()) : "guest")}</span>
|
|
154
|
+
</div>
|
|
155
|
+
</nav>`;
|
|
156
|
+
|
|
157
|
+
const panel = (key) => el("div", { class: "container", style: () => "max-width:760px;display:" + (section() === key ? "block" : "none") }, sections[key]);
|
|
158
|
+
|
|
159
|
+
mount("#app", nav(), ...Object.keys(sections).map(panel));
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// volt.js — a tiny, no-build, signals-based UI library.
|
|
2
|
+
//
|
|
3
|
+
// Not React: there is no JSX, no virtual DOM, and no "re-render the whole
|
|
4
|
+
// component" step. State lives in *signals*; reading a signal inside a piece of
|
|
5
|
+
// UI subscribes that exact piece; writing the signal re-runs only those
|
|
6
|
+
// subscribers and touches only the precise text node / attribute that changed.
|
|
7
|
+
//
|
|
8
|
+
// Two ways to author UI, same engine underneath:
|
|
9
|
+
// 1. html`` — tagged-template markup with ${signal} holes
|
|
10
|
+
// 2. el(...) — imperative DOM helpers with function-children
|
|
11
|
+
// They interoperate freely (drop an el() node into an html`` template, etc.).
|
|
12
|
+
//
|
|
13
|
+
// Public API: signal, computed, effect, el, html, mount.
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Reactive core (signals + effects with ownership-based disposal)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let activeEffect = null;
|
|
20
|
+
|
|
21
|
+
// A signal is a function: call with no args to read, one arg to write.
|
|
22
|
+
// const n = signal(0); n(); // read → 0
|
|
23
|
+
// n(n()+1); // write → notifies subscribers
|
|
24
|
+
export function signal(value) {
|
|
25
|
+
const subs = new Set();
|
|
26
|
+
return function sig(...args) {
|
|
27
|
+
if (args.length) {
|
|
28
|
+
const next = args[0];
|
|
29
|
+
if (next === value) return value; // no-op on identical value
|
|
30
|
+
value = next;
|
|
31
|
+
for (const eff of [...subs]) eff.run(); // copy: run() mutates subs
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
if (activeEffect) {
|
|
35
|
+
subs.add(activeEffect);
|
|
36
|
+
activeEffect.deps.add(subs);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// effect(fn) runs fn now, tracks every signal it reads, and re-runs it whenever
|
|
43
|
+
// any of those change. Effects created *inside* another effect are owned by it
|
|
44
|
+
// and disposed before each re-run — so dynamic regions clean up after themselves.
|
|
45
|
+
export function effect(fn) {
|
|
46
|
+
const eff = {
|
|
47
|
+
deps: new Set(),
|
|
48
|
+
children: new Set(),
|
|
49
|
+
parent: activeEffect,
|
|
50
|
+
run() {
|
|
51
|
+
disposeChildren(eff);
|
|
52
|
+
cleanupDeps(eff);
|
|
53
|
+
const prev = activeEffect;
|
|
54
|
+
activeEffect = eff;
|
|
55
|
+
try {
|
|
56
|
+
fn();
|
|
57
|
+
} finally {
|
|
58
|
+
activeEffect = prev;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
dispose() {
|
|
62
|
+
disposeChildren(eff);
|
|
63
|
+
cleanupDeps(eff);
|
|
64
|
+
if (eff.parent) eff.parent.children.delete(eff);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
if (activeEffect) activeEffect.children.add(eff);
|
|
68
|
+
eff.run();
|
|
69
|
+
return () => eff.dispose();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// computed(fn) is a read-only derived signal: () => value, auto-updating.
|
|
73
|
+
export function computed(fn) {
|
|
74
|
+
const s = signal(undefined);
|
|
75
|
+
effect(() => s(fn()));
|
|
76
|
+
return () => s();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanupDeps(eff) {
|
|
80
|
+
for (const subs of eff.deps) subs.delete(eff);
|
|
81
|
+
eff.deps.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function disposeChildren(eff) {
|
|
85
|
+
for (const child of [...eff.children]) child.dispose();
|
|
86
|
+
eff.children.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// DOM helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
// el(tag, props?, ...children) → a real DOM element.
|
|
94
|
+
// props: { onClick: fn } → event listener
|
|
95
|
+
// { class: () => ... } → reactive attribute (function = live)
|
|
96
|
+
// { id: 'x' } → static attribute
|
|
97
|
+
// children: strings, numbers, nodes, arrays, or functions (functions = live)
|
|
98
|
+
export function el(tag, props, ...children) {
|
|
99
|
+
const node = document.createElement(tag);
|
|
100
|
+
if (props) {
|
|
101
|
+
for (const [key, val] of Object.entries(props)) {
|
|
102
|
+
if (key.startsWith("on") && typeof val === "function") {
|
|
103
|
+
node.addEventListener(key.slice(2).toLowerCase(), val);
|
|
104
|
+
} else if (typeof val === "function") {
|
|
105
|
+
effect(() => setAttr(node, key, val()));
|
|
106
|
+
} else {
|
|
107
|
+
setAttr(node, key, val);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const child of children) appendChild(node, child);
|
|
112
|
+
return node;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// mount(target, ...children) appends children into target (selector or element).
|
|
116
|
+
// Top-level function-children are reactive too.
|
|
117
|
+
export function mount(target, ...children) {
|
|
118
|
+
const parent = typeof target === "string" ? document.querySelector(target) : target;
|
|
119
|
+
for (const child of children) appendChild(parent, child);
|
|
120
|
+
return parent;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setAttr(node, name, value) {
|
|
124
|
+
if (name === "value") {
|
|
125
|
+
node.value = value ?? "";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (name === "checked" || name === "disabled" || name === "selected") {
|
|
129
|
+
node[name] = !!value && value !== "false";
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (value === false || value == null) {
|
|
133
|
+
node.removeAttribute(name);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
node.setAttribute(name, value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Append a child, making function-children into self-updating dynamic regions
|
|
140
|
+
// bounded by two comment anchors (so they can render text, nodes, or lists).
|
|
141
|
+
function appendChild(parent, child) {
|
|
142
|
+
if (typeof child === "function") {
|
|
143
|
+
const start = document.createComment("");
|
|
144
|
+
const end = document.createComment("");
|
|
145
|
+
parent.appendChild(start);
|
|
146
|
+
parent.appendChild(end);
|
|
147
|
+
effect(() => renderRange(start, end, child()));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const node of toNodes(child)) parent.appendChild(node);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Replace everything between the start/end anchors with `value`'s nodes.
|
|
154
|
+
function renderRange(start, end, value) {
|
|
155
|
+
let n = start.nextSibling;
|
|
156
|
+
while (n && n !== end) {
|
|
157
|
+
const t = n.nextSibling;
|
|
158
|
+
n.remove();
|
|
159
|
+
n = t;
|
|
160
|
+
}
|
|
161
|
+
for (const node of toNodes(value)) end.parentNode.insertBefore(node, end);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Normalize any child value into an array of DOM nodes.
|
|
165
|
+
function toNodes(value) {
|
|
166
|
+
if (value == null || value === false || value === true) return [];
|
|
167
|
+
if (Array.isArray(value)) return value.flatMap(toNodes);
|
|
168
|
+
if (value instanceof Node) return [value];
|
|
169
|
+
return [document.createTextNode(String(value))];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// html`` template layer (parses once, wires holes to the same primitives)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const PH = (i) => `__voltph${i}__`;
|
|
177
|
+
const PH_RE = /__voltph(\d+)__/g;
|
|
178
|
+
|
|
179
|
+
// We're inside an open tag (attribute context) if the last '<' comes after the
|
|
180
|
+
// last '>' in the accumulated string.
|
|
181
|
+
function isAttrContext(str) {
|
|
182
|
+
return str.lastIndexOf("<") > str.lastIndexOf(">");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function html(strings, ...values) {
|
|
186
|
+
let acc = "";
|
|
187
|
+
strings.forEach((str, i) => {
|
|
188
|
+
acc += str;
|
|
189
|
+
if (i < values.length) {
|
|
190
|
+
acc += isAttrContext(acc) ? PH(i) : `<!--${PH(i)}-->`;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const tpl = document.createElement("template");
|
|
195
|
+
tpl.innerHTML = acc.trim();
|
|
196
|
+
|
|
197
|
+
// Bind attribute holes.
|
|
198
|
+
for (const node of tpl.content.querySelectorAll("*")) {
|
|
199
|
+
for (const attr of [...node.attributes]) {
|
|
200
|
+
PH_RE.lastIndex = 0;
|
|
201
|
+
if (PH_RE.test(attr.value)) bindAttr(node, attr, values);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Bind node holes (comment placeholders).
|
|
206
|
+
const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_COMMENT);
|
|
207
|
+
const holes = [];
|
|
208
|
+
let c;
|
|
209
|
+
while ((c = walker.nextNode())) {
|
|
210
|
+
const m = c.data.match(/^__voltph(\d+)__$/);
|
|
211
|
+
if (m) holes.push([c, Number(m[1])]);
|
|
212
|
+
}
|
|
213
|
+
for (const [comment, i] of holes) bindNodeHole(comment, values[i]);
|
|
214
|
+
|
|
215
|
+
const nodes = [...tpl.content.childNodes];
|
|
216
|
+
return nodes.length === 1 ? nodes[0] : nodes;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function bindAttr(node, attr, values) {
|
|
220
|
+
const name = attr.name;
|
|
221
|
+
const raw = attr.value;
|
|
222
|
+
const single = raw.match(/^__voltph(\d+)__$/);
|
|
223
|
+
node.removeAttribute(name);
|
|
224
|
+
|
|
225
|
+
// onX=${fn} → event listener
|
|
226
|
+
if (name.startsWith("on") && single) {
|
|
227
|
+
node.addEventListener(name.slice(2).toLowerCase(), values[Number(single[1])]);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Otherwise a (possibly mixed) attribute value. If any hole is a function it
|
|
232
|
+
// is read inside the effect, so the attribute stays live.
|
|
233
|
+
effect(() => {
|
|
234
|
+
const text = raw.replace(PH_RE, (_, j) => {
|
|
235
|
+
const v = values[Number(j)];
|
|
236
|
+
return String(typeof v === "function" ? v() : v ?? "");
|
|
237
|
+
});
|
|
238
|
+
setAttr(node, name, text);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function bindNodeHole(comment, value) {
|
|
243
|
+
const start = document.createComment("");
|
|
244
|
+
comment.parentNode.insertBefore(start, comment); // `comment` becomes the end anchor
|
|
245
|
+
if (typeof value === "function") {
|
|
246
|
+
effect(() => renderRange(start, comment, value()));
|
|
247
|
+
} else {
|
|
248
|
+
renderRange(start, comment, value);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Hot reload client — listens for the dev server's reload event over Socket.io
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
(function startHotReload() {
|
|
257
|
+
const connect = () => {
|
|
258
|
+
if (!window.io) return false;
|
|
259
|
+
const socket = window.io();
|
|
260
|
+
socket.on("volt:reload", () => location.reload());
|
|
261
|
+
console.log("[volt] hot reload connected");
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
if (!connect()) window.addEventListener("load", connect);
|
|
265
|
+
})();
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// server.js — dev server with a built-in first-run setup wizard.
|
|
2
|
+
//
|
|
3
|
+
// First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
|
|
4
|
+
// config page: tick add-ons, fill settings, Apply. Apply writes .env (a
|
|
5
|
+
// VOLT_ADDONS list + settings) and adds any needed packages to package.json,
|
|
6
|
+
// runs npm install, then starts the app — which wires whatever .env enables.
|
|
7
|
+
// Add-on code is bundled under .volt/addons; nothing is copied into your code.
|
|
8
|
+
//
|
|
9
|
+
// No build step, no env-file flag: .env is auto-loaded below.
|
|
10
|
+
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
import express from "express";
|
|
18
|
+
import { Server as SocketServer } from "socket.io";
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const ENV_PATH = path.join(__dirname, ".env");
|
|
22
|
+
const PKG_PATH = path.join(__dirname, "package.json");
|
|
23
|
+
const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
|
|
24
|
+
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
25
|
+
const PKG_VERSIONS = { mongodb: "^6.8.0", mysql2: "^3.11.0", pg: "^8.12.0", nodemailer: "^6.9.0" };
|
|
26
|
+
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js" };
|
|
27
|
+
|
|
28
|
+
// --- tiny .env loader (no dependency); never overrides an existing env var ---
|
|
29
|
+
function readEnvFile() {
|
|
30
|
+
const out = {};
|
|
31
|
+
if (!fs.existsSync(ENV_PATH)) return out;
|
|
32
|
+
for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
|
|
33
|
+
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
34
|
+
if (m) out[m[1]] = m[2];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function loadEnv() {
|
|
39
|
+
for (const [k, v] of Object.entries(readEnvFile())) if (!(k in process.env)) process.env[k] = v;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Add-ons available to enable (bundled under .volt/addons by create-volt).
|
|
43
|
+
function availableAddons() {
|
|
44
|
+
if (!fs.existsSync(ADDONS_DIR)) return [];
|
|
45
|
+
return fs
|
|
46
|
+
.readdirSync(ADDONS_DIR, { withFileTypes: true })
|
|
47
|
+
.filter((e) => e.isDirectory())
|
|
48
|
+
.map((e) => {
|
|
49
|
+
const m = JSON.parse(fs.readFileSync(path.join(ADDONS_DIR, e.name, "meta.json"), "utf8"));
|
|
50
|
+
return { name: e.name, description: m.description, dependsOn: m.dependsOn || [] };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Which add-ons does VOLT_ADDONS turn on (dependencies expanded)?
|
|
55
|
+
function enabledFrom(env) {
|
|
56
|
+
const metas = Object.fromEntries(availableAddons().map((a) => [a.name, a]));
|
|
57
|
+
const out = new Set();
|
|
58
|
+
const visit = (n) => {
|
|
59
|
+
if (!metas[n] || out.has(n)) return;
|
|
60
|
+
out.add(n);
|
|
61
|
+
for (const d of metas[n].dependsOn) visit(d);
|
|
62
|
+
};
|
|
63
|
+
for (const n of String(env.VOLT_ADDONS || "").split(",").map((s) => s.trim()).filter(Boolean)) visit(n);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
|
|
68
|
+
const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
|
|
69
|
+
|
|
70
|
+
function openBrowser(url) {
|
|
71
|
+
if (process.env.VOLT_NO_OPEN || process.argv.includes("--no-open")) return false;
|
|
72
|
+
const plat = process.platform;
|
|
73
|
+
if (plat === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
74
|
+
const cmd = plat === "darwin" ? "open" : plat === "win32" ? "cmd" : "xdg-open";
|
|
75
|
+
const args = plat === "win32" ? ["/c", "start", "", url] : [url];
|
|
76
|
+
try {
|
|
77
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
78
|
+
child.on("error", () => {}); // launcher missing — emits async, don't crash
|
|
79
|
+
child.unref();
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- the actual app: wires whichever add-ons .env enables ---
|
|
87
|
+
async function startApp() {
|
|
88
|
+
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
89
|
+
const enabled = enabledFrom(process.env);
|
|
90
|
+
const app = express();
|
|
91
|
+
app.disable("x-powered-by");
|
|
92
|
+
app.use((_req, res, next) => {
|
|
93
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
94
|
+
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
95
|
+
res.setHeader("Referrer-Policy", "same-origin");
|
|
96
|
+
next();
|
|
97
|
+
});
|
|
98
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
99
|
+
|
|
100
|
+
let store = null;
|
|
101
|
+
let mailer = null;
|
|
102
|
+
if (enabled.has("db")) store = await (await addonMod("db")).createStore();
|
|
103
|
+
if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
|
|
104
|
+
if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
|
|
105
|
+
|
|
106
|
+
// notes — a per-user CRUD example (auth-gated, owner-scoped, db-backed)
|
|
107
|
+
if (enabled.has("db") && enabled.has("auth") && store) {
|
|
108
|
+
const guard = (await addonMod("auth")).requireAuth(store);
|
|
109
|
+
const notes = store.collection("notes");
|
|
110
|
+
const r = express.Router();
|
|
111
|
+
r.use(express.json());
|
|
112
|
+
r.get("/api/notes", guard, async (req, res) => {
|
|
113
|
+
const list = (await notes.find({ owner: req.user.email })).sort((a, b) => b.createdAt - a.createdAt);
|
|
114
|
+
res.json({ notes: list });
|
|
115
|
+
});
|
|
116
|
+
r.post("/api/notes", guard, async (req, res) => {
|
|
117
|
+
const text = String(req.body?.text || "").trim().slice(0, 2000);
|
|
118
|
+
if (!text) return res.status(400).json({ ok: false, error: "Empty note." });
|
|
119
|
+
const note = { id: crypto.randomBytes(8).toString("hex"), owner: req.user.email, text, createdAt: Date.now() };
|
|
120
|
+
await notes.put(note.id, note);
|
|
121
|
+
res.json({ ok: true, note });
|
|
122
|
+
});
|
|
123
|
+
r.delete("/api/notes/:id", guard, async (req, res) => {
|
|
124
|
+
const n = await notes.get(req.params.id);
|
|
125
|
+
if (n && n.owner === req.user.email) await notes.delete(req.params.id);
|
|
126
|
+
res.json({ ok: true });
|
|
127
|
+
});
|
|
128
|
+
app.use(r);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// expose which add-ons are on, and serve each enabled add-on's frontend assets
|
|
132
|
+
app.get("/__volt/addons", (_req, res) => res.json([...enabled]));
|
|
133
|
+
for (const n of enabled) {
|
|
134
|
+
const pub = path.join(ADDONS_DIR, n, "files", "public");
|
|
135
|
+
if (fs.existsSync(pub)) app.use(express.static(pub));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
|
|
139
|
+
|
|
140
|
+
const server = http.createServer(app);
|
|
141
|
+
const io = new SocketServer(server);
|
|
142
|
+
if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
|
|
143
|
+
|
|
144
|
+
let timer = null;
|
|
145
|
+
const onChange = (file) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
149
|
+
io.emit("volt:reload");
|
|
150
|
+
}, 80);
|
|
151
|
+
};
|
|
152
|
+
const watchRecursive = (dir) => {
|
|
153
|
+
try {
|
|
154
|
+
fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
|
|
155
|
+
return;
|
|
156
|
+
} catch {
|
|
157
|
+
/* per-dir fallback */
|
|
158
|
+
}
|
|
159
|
+
const w = (d) => {
|
|
160
|
+
try {
|
|
161
|
+
fs.watch(d, (_e, f) => onChange(f));
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory()) w(path.join(d, e.name));
|
|
166
|
+
};
|
|
167
|
+
w(dir);
|
|
168
|
+
};
|
|
169
|
+
for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
|
|
170
|
+
|
|
171
|
+
const on = [...enabled];
|
|
172
|
+
server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Packages an .env's selections need, beyond what package.json already has.
|
|
176
|
+
function neededPackages(env) {
|
|
177
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
|
|
178
|
+
const deps = pkg.dependencies || {};
|
|
179
|
+
const want = [];
|
|
180
|
+
const driver = (env.match(/^\s*DB_DRIVER\s*=\s*(\w+)/m) || [])[1];
|
|
181
|
+
if (driver === "mongodb") want.push("mongodb");
|
|
182
|
+
if (driver === "mysql") want.push("mysql2");
|
|
183
|
+
if (driver === "postgres") want.push("pg");
|
|
184
|
+
if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
|
|
185
|
+
return want.filter((p) => !deps[p]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- the disposable setup wizard (localhost only) ---
|
|
189
|
+
function startSetup() {
|
|
190
|
+
const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
|
|
191
|
+
const assets = {
|
|
192
|
+
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
193
|
+
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
194
|
+
};
|
|
195
|
+
const indexHtml = fs.readFileSync(path.join(__dirname, "setup", "index.html"));
|
|
196
|
+
|
|
197
|
+
const server = http.createServer((req, res) => {
|
|
198
|
+
const u = new URL(req.url, "http://localhost");
|
|
199
|
+
const p = u.pathname;
|
|
200
|
+
if (req.method === "GET" && p === "/") {
|
|
201
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
202
|
+
return res.end(indexHtml);
|
|
203
|
+
}
|
|
204
|
+
if (req.method === "GET" && assets[p]) {
|
|
205
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
206
|
+
return res.end(assets[p][1]);
|
|
207
|
+
}
|
|
208
|
+
if (req.method === "GET" && p === "/setup/state") {
|
|
209
|
+
res.setHeader("Content-Type", "application/json");
|
|
210
|
+
return res.end(JSON.stringify({ available: availableAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
|
|
211
|
+
}
|
|
212
|
+
if (req.method === "POST" && p === "/setup/test-db") {
|
|
213
|
+
let body = "";
|
|
214
|
+
req.on("data", (c) => (body += c));
|
|
215
|
+
req.on("end", async () => {
|
|
216
|
+
const keys = ["DB_DRIVER", "MONGODB_URI", "MONGODB_DATABASE", "DATABASE_URL"];
|
|
217
|
+
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
218
|
+
try {
|
|
219
|
+
const { env = {} } = JSON.parse(body);
|
|
220
|
+
for (const k of keys) {
|
|
221
|
+
if (env[k]) process.env[k] = env[k];
|
|
222
|
+
else delete process.env[k];
|
|
223
|
+
}
|
|
224
|
+
const store = await (await addonMod("db")).createStore();
|
|
225
|
+
await store.collection("__voltcheck").all();
|
|
226
|
+
res.setHeader("Content-Type", "application/json");
|
|
227
|
+
res.end(JSON.stringify({ ok: true, driver: store.name }));
|
|
228
|
+
} catch (e) {
|
|
229
|
+
res.setHeader("Content-Type", "application/json");
|
|
230
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
231
|
+
} finally {
|
|
232
|
+
for (const k of keys) {
|
|
233
|
+
if (saved[k] == null) delete process.env[k];
|
|
234
|
+
else process.env[k] = saved[k];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (req.method === "POST" && p === "/setup/apply") {
|
|
241
|
+
let body = "";
|
|
242
|
+
req.on("data", (c) => (body += c));
|
|
243
|
+
req.on("end", () => {
|
|
244
|
+
try {
|
|
245
|
+
const { env } = JSON.parse(body);
|
|
246
|
+
if (typeof env !== "string") throw new Error("missing env");
|
|
247
|
+
|
|
248
|
+
// 1) write .env, preserving any custom keys the form doesn't manage
|
|
249
|
+
let finalEnv = env;
|
|
250
|
+
if (fs.existsSync(ENV_PATH)) {
|
|
251
|
+
const managed = new Set([...env.matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)].map((m) => m[1]));
|
|
252
|
+
const extra = readEnvFileLines().filter((line) => {
|
|
253
|
+
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=/);
|
|
254
|
+
return m && !managed.has(m[1]);
|
|
255
|
+
});
|
|
256
|
+
if (extra.length) finalEnv = env.replace(/\n*$/, "\n") + extra.join("\n") + "\n";
|
|
257
|
+
}
|
|
258
|
+
fs.writeFileSync(ENV_PATH, finalEnv);
|
|
259
|
+
|
|
260
|
+
// 2) declare any needed packages in package.json
|
|
261
|
+
const added = neededPackages(env);
|
|
262
|
+
if (added.length) {
|
|
263
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
|
|
264
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
265
|
+
for (const name of added) pkg.dependencies[name] = PKG_VERSIONS[name] || "latest";
|
|
266
|
+
fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + "\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
|
|
270
|
+
const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
|
|
271
|
+
res.setHeader("Content-Type", "application/json");
|
|
272
|
+
res.end(JSON.stringify({ ok: true, port: newPort, installing: added }));
|
|
273
|
+
|
|
274
|
+
// 3) install (if needed), then hand off to the app
|
|
275
|
+
res.on("finish", () => {
|
|
276
|
+
const handoff = () => {
|
|
277
|
+
server.close(() => {
|
|
278
|
+
loadEnv();
|
|
279
|
+
startApp();
|
|
280
|
+
});
|
|
281
|
+
server.closeIdleConnections?.();
|
|
282
|
+
};
|
|
283
|
+
if (added.length) {
|
|
284
|
+
console.log(`[volt] installing ${added.join(", ")}…`);
|
|
285
|
+
const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
|
|
286
|
+
npm.on("error", () => handoff());
|
|
287
|
+
npm.on("close", () => {
|
|
288
|
+
console.log("[volt] saved .env — starting the app…");
|
|
289
|
+
handoff();
|
|
290
|
+
});
|
|
291
|
+
} else {
|
|
292
|
+
console.log("[volt] saved .env — starting the app…");
|
|
293
|
+
handoff();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
} catch (e) {
|
|
297
|
+
res.statusCode = 400;
|
|
298
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
res.statusCode = 404;
|
|
304
|
+
res.end("not found");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
308
|
+
const url = `http://localhost:${PORT}`;
|
|
309
|
+
console.log(`\n⚡ Volt setup → ${url}`);
|
|
310
|
+
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
311
|
+
const ssh = process.env.SSH_CONNECTION;
|
|
312
|
+
if (ssh) {
|
|
313
|
+
const host = ssh.split(" ")[2];
|
|
314
|
+
const user = process.env.USER || process.env.USERNAME || "you";
|
|
315
|
+
console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
|
|
316
|
+
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
317
|
+
console.log(` …then open ${url} on your machine (the tunnel points it here).`);
|
|
318
|
+
}
|
|
319
|
+
console.log("");
|
|
320
|
+
if (openBrowser(url)) console.log(" (opening your browser…)\n");
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function readEnvFileLines() {
|
|
325
|
+
return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
|
|
329
|
+
// Not a route in the running app — it only exists while you run `--studio`, on
|
|
330
|
+
// loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
|
|
331
|
+
const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
|
|
332
|
+
async function startStudio() {
|
|
333
|
+
loadEnv();
|
|
334
|
+
if (!enabledFrom(process.env).has("db")) {
|
|
335
|
+
console.error("Studio needs the db add-on. Enable it: npm run dev -- --edit");
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
let store;
|
|
339
|
+
try {
|
|
340
|
+
store = await (await addonMod("db")).createStore();
|
|
341
|
+
} catch (e) {
|
|
342
|
+
console.error("Studio: couldn't connect the store — " + e.message);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
|
|
346
|
+
const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
|
|
347
|
+
const assets = {
|
|
348
|
+
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
349
|
+
"/db-admin-ui.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(ADDONS_DIR, "db", "files", "public", "db-admin-ui.js"))],
|
|
350
|
+
};
|
|
351
|
+
const studioHtml = fs.readFileSync(path.join(__dirname, "setup", "studio.html"));
|
|
352
|
+
const json = (res, code, obj) => {
|
|
353
|
+
res.statusCode = code;
|
|
354
|
+
res.setHeader("Content-Type", "application/json");
|
|
355
|
+
res.end(JSON.stringify(obj));
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const server = http.createServer(async (req, res) => {
|
|
359
|
+
const u = new URL(req.url, "http://localhost");
|
|
360
|
+
const p = u.pathname;
|
|
361
|
+
try {
|
|
362
|
+
if (req.method === "GET" && p === "/") {
|
|
363
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
364
|
+
return res.end(studioHtml);
|
|
365
|
+
}
|
|
366
|
+
if (req.method === "GET" && assets[p]) {
|
|
367
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
368
|
+
return res.end(assets[p][1]);
|
|
369
|
+
}
|
|
370
|
+
if (req.method === "GET" && p === "/admin/db/collections") {
|
|
371
|
+
const all = (await store.collections()) || [];
|
|
372
|
+
return json(res, 200, { driver: store.name, collections: all.filter(visible) });
|
|
373
|
+
}
|
|
374
|
+
if (req.method === "GET" && p === "/admin/db/collection") {
|
|
375
|
+
const name = u.searchParams.get("name") || "";
|
|
376
|
+
if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
|
|
377
|
+
const docs = (await store.collection(name).all()).slice(0, 500);
|
|
378
|
+
return json(res, 200, { ok: true, name, docs });
|
|
379
|
+
}
|
|
380
|
+
if (req.method === "DELETE" && p === "/admin/db/doc") {
|
|
381
|
+
const name = u.searchParams.get("name") || "";
|
|
382
|
+
const id = u.searchParams.get("id") || "";
|
|
383
|
+
if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
|
|
384
|
+
if (!id) return json(res, 400, { ok: false, error: "missing id" });
|
|
385
|
+
await store.collection(name).delete(id);
|
|
386
|
+
return json(res, 200, { ok: true });
|
|
387
|
+
}
|
|
388
|
+
res.statusCode = 404;
|
|
389
|
+
res.end("not found");
|
|
390
|
+
} catch (e) {
|
|
391
|
+
json(res, 500, { ok: false, error: e.message });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
396
|
+
const url = `http://localhost:${PORT}`;
|
|
397
|
+
console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
|
|
398
|
+
console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
|
|
399
|
+
const ssh = process.env.SSH_CONNECTION;
|
|
400
|
+
if (ssh) {
|
|
401
|
+
const host = ssh.split(" ")[2];
|
|
402
|
+
const user = process.env.USER || process.env.USERNAME || "you";
|
|
403
|
+
console.log(" Remote box — from your LOCAL machine:");
|
|
404
|
+
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
405
|
+
console.log(` …then open ${url}.`);
|
|
406
|
+
}
|
|
407
|
+
console.log("");
|
|
408
|
+
openBrowser(url);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- gate: studio / setup (first run, --edit) / the app ---
|
|
413
|
+
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
414
|
+
if (process.argv.includes("--studio")) {
|
|
415
|
+
startStudio();
|
|
416
|
+
} else if (editMode || !fs.existsSync(ENV_PATH)) {
|
|
417
|
+
startSetup();
|
|
418
|
+
} else {
|
|
419
|
+
loadEnv();
|
|
420
|
+
startApp();
|
|
421
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Set up your Volt app</title>
|
|
7
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
8
|
+
<style>
|
|
9
|
+
body { background: #0f1115; color: #e7e9ee; }
|
|
10
|
+
.accent { color: #ffd24a; }
|
|
11
|
+
.card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
|
|
12
|
+
.form-control, .form-select { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
|
|
13
|
+
.form-control:focus, .form-select:focus { background: #0f1115; color: #e7e9ee; border-color: #ffd24a; box-shadow: none; }
|
|
14
|
+
code { color: #ffd24a; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<main class="container py-5" style="max-width: 640px;">
|
|
19
|
+
<header class="mb-4">
|
|
20
|
+
<h1 class="h3"><span class="accent">⚡ Set up your Volt app</span></h1>
|
|
21
|
+
<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>
|
|
22
|
+
</header>
|
|
23
|
+
<div id="app"></div>
|
|
24
|
+
</main>
|
|
25
|
+
<script type="module" src="/setup.js"></script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,160 @@
|
|
|
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, html, mount } from "/volt.js";
|
|
6
|
+
|
|
7
|
+
const { available, current, defaultPort } = 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
|
+
port: current.PORT || String(defaultPort),
|
|
21
|
+
});
|
|
22
|
+
const set = (patch) => state({ ...state(), ...patch });
|
|
23
|
+
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
24
|
+
const status = signal("");
|
|
25
|
+
|
|
26
|
+
// selected add-ons, dependencies expanded, in display order
|
|
27
|
+
function effective(s) {
|
|
28
|
+
const want = new Set();
|
|
29
|
+
const visit = (n) => {
|
|
30
|
+
if (want.has(n)) return;
|
|
31
|
+
want.add(n);
|
|
32
|
+
(depsOf[n] || []).forEach(visit);
|
|
33
|
+
};
|
|
34
|
+
for (const n of order) if (s.addons[n]) visit(n);
|
|
35
|
+
return order.filter((n) => want.has(n));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clean = (v) => String(v).replace(/[\r\n]/g, "").trim(); // one value per line; no injection
|
|
39
|
+
function genEnv(s) {
|
|
40
|
+
const eff = effective(s);
|
|
41
|
+
const out = [`VOLT_ADDONS=${eff.join(",")}`, `PORT=${clean(s.port)}`];
|
|
42
|
+
if (eff.includes("db")) {
|
|
43
|
+
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
44
|
+
if (s.dbDriver === "mongodb") {
|
|
45
|
+
out.push(`MONGODB_URI=${clean(s.mongoUri)}`);
|
|
46
|
+
if (s.mongoDb) out.push(`MONGODB_DATABASE=${clean(s.mongoDb)}`);
|
|
47
|
+
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
48
|
+
out.push(`DATABASE_URL=${clean(s.dbUrl)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (eff.includes("mailer")) {
|
|
52
|
+
if (s.smtpUrl) out.push(`SMTP_URL=${clean(s.smtpUrl)}`);
|
|
53
|
+
else out.push("# SMTP_URL= # unset → emails print to the console");
|
|
54
|
+
if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
|
|
55
|
+
}
|
|
56
|
+
return out.join("\n") + "\n";
|
|
57
|
+
}
|
|
58
|
+
const env = computed(() => genEnv(state()));
|
|
59
|
+
const eff = computed(() => effective(state()));
|
|
60
|
+
|
|
61
|
+
async function testDb() {
|
|
62
|
+
const s = state();
|
|
63
|
+
const e = { DB_DRIVER: s.dbDriver };
|
|
64
|
+
if (s.dbDriver === "mongodb") {
|
|
65
|
+
e.MONGODB_URI = s.mongoUri;
|
|
66
|
+
e.MONGODB_DATABASE = s.mongoDb;
|
|
67
|
+
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
68
|
+
e.DATABASE_URL = s.dbUrl;
|
|
69
|
+
}
|
|
70
|
+
status("Testing connection…");
|
|
71
|
+
try {
|
|
72
|
+
const r = await (await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: e }) })).json();
|
|
73
|
+
status(r.ok ? `✓ Connected (${r.driver}).` : `✗ ${r.error}`);
|
|
74
|
+
} catch {
|
|
75
|
+
status("Network error testing connection.");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function apply() {
|
|
80
|
+
status("Saving…");
|
|
81
|
+
let d;
|
|
82
|
+
try {
|
|
83
|
+
d = await (await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ addons: eff(), env: env() }) })).json();
|
|
84
|
+
} catch {
|
|
85
|
+
return status("Network error.");
|
|
86
|
+
}
|
|
87
|
+
if (!d.ok) return status("Error: " + d.error);
|
|
88
|
+
status(d.installing?.length ? `Installing ${d.installing.join(", ")}, then starting…` : "Starting the app…");
|
|
89
|
+
const target = `http://localhost:${d.port}/`;
|
|
90
|
+
const tries = d.installing?.length ? 90 : 20; // npm install can take a while
|
|
91
|
+
const go = async (n) => {
|
|
92
|
+
try {
|
|
93
|
+
await fetch(target, { mode: "no-cors" });
|
|
94
|
+
location.href = target;
|
|
95
|
+
} catch {
|
|
96
|
+
if (n > 0) setTimeout(() => go(n - 1), 500);
|
|
97
|
+
else location.href = target;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
setTimeout(() => go(tries), 600);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- views ---
|
|
104
|
+
const field = (label, key, placeholder = "") =>
|
|
105
|
+
html`<div class="mb-2">
|
|
106
|
+
<label class="form-label small mb-1">${label}</label>
|
|
107
|
+
<input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
|
|
108
|
+
</div>`;
|
|
109
|
+
|
|
110
|
+
const addonRow = (a) =>
|
|
111
|
+
html`<div class="form-check mb-2">
|
|
112
|
+
<input class="form-check-input" type="checkbox" id=${"x-" + a.name} checked=${() => state().addons[a.name]} onchange=${() => toggle(a.name)} />
|
|
113
|
+
<label class="form-check-label" for=${"x-" + a.name}>
|
|
114
|
+
<span class="accent">${a.name}</span>${a.dependsOn?.length ? html` <span class="text-muted small">(needs ${a.dependsOn.join(", ")})</span>` : ""}
|
|
115
|
+
<div class="small text-muted">${a.description}</div>
|
|
116
|
+
</label>
|
|
117
|
+
</div>`;
|
|
118
|
+
|
|
119
|
+
const dbSettings = () =>
|
|
120
|
+
html`<div class="mb-2">
|
|
121
|
+
<label class="form-label small mb-1">Database (DB_DRIVER)</label>
|
|
122
|
+
<select class="form-select" value=${() => state().dbDriver} onchange=${(e) => set({ dbDriver: e.target.value })}>
|
|
123
|
+
<option value="memory">memory (no setup)</option>
|
|
124
|
+
<option value="mongodb">mongodb</option>
|
|
125
|
+
<option value="mysql">mysql</option>
|
|
126
|
+
<option value="postgres">postgres</option>
|
|
127
|
+
</select>
|
|
128
|
+
</div>
|
|
129
|
+
${() =>
|
|
130
|
+
state().dbDriver === "mongodb"
|
|
131
|
+
? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
|
|
132
|
+
: state().dbDriver === "mysql" || state().dbDriver === "postgres"
|
|
133
|
+
? field("DATABASE_URL", "dbUrl", state().dbDriver + "://user:pass@host/db")
|
|
134
|
+
: null}
|
|
135
|
+
${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
|
|
136
|
+
|
|
137
|
+
mount(
|
|
138
|
+
"#app",
|
|
139
|
+
available.length
|
|
140
|
+
? html`<div class="card-x p-4 mb-3">
|
|
141
|
+
<h2 class="h6 mb-3">Features</h2>
|
|
142
|
+
${available.map(addonRow)}
|
|
143
|
+
<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>
|
|
144
|
+
</div>`
|
|
145
|
+
: null,
|
|
146
|
+
html`<div class="card-x p-4 mb-3">
|
|
147
|
+
<h2 class="h6 mb-3">Settings</h2>
|
|
148
|
+
${field("PORT", "port", String(defaultPort))}
|
|
149
|
+
${() => (eff().includes("db") ? dbSettings() : null)}
|
|
150
|
+
${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
151
|
+
</div>`,
|
|
152
|
+
html`<div class="card-x p-4 mb-3">
|
|
153
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
154
|
+
<h2 class="h6 mb-0">.env</h2>
|
|
155
|
+
<button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button>
|
|
156
|
+
</div>
|
|
157
|
+
<pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
|
|
158
|
+
</div>`,
|
|
159
|
+
() => (status() ? html`<p class="small accent">${status}</p>` : null),
|
|
160
|
+
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Volt Studio</title>
|
|
7
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
8
|
+
<style>
|
|
9
|
+
body { background: #0f1115; color: #e7e9ee; }
|
|
10
|
+
.accent { color: #ffd24a; }
|
|
11
|
+
.card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
|
|
12
|
+
</style>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<main class="container py-5" style="max-width: 820px;">
|
|
16
|
+
<header class="mb-4">
|
|
17
|
+
<h1 class="h3"><span class="accent">⚡ Volt Studio</span> <small class="text-muted">data browser</small></h1>
|
|
18
|
+
<p class="text-muted mb-0">Browse the database in your <code>.env</code>. Disposable & localhost-only — close the terminal when done.</p>
|
|
19
|
+
</header>
|
|
20
|
+
<div id="app"></div>
|
|
21
|
+
</main>
|
|
22
|
+
<script type="module">
|
|
23
|
+
import { mount } from "/volt.js";
|
|
24
|
+
import { dbAdminPanel } from "/db-admin-ui.js";
|
|
25
|
+
mount("#app", dbAdminPanel());
|
|
26
|
+
</script>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Volt starter</title>
|
|
7
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
8
|
+
<style>
|
|
9
|
+
body { background: #0f1115; color: #e7e9ee; }
|
|
10
|
+
.brand { font-weight: 800; letter-spacing: -.02em; }
|
|
11
|
+
.accent { color: #ffd24a; }
|
|
12
|
+
.card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
|
|
13
|
+
.form-control, .form-select { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
|
|
14
|
+
.form-control:focus, .form-select:focus { background: #0f1115; color: #e7e9ee; border-color: #ffd24a; box-shadow: none; }
|
|
15
|
+
.navx { background: #11151c; border-bottom: 1px solid #232a36; }
|
|
16
|
+
.navx .btn-link { color: #9aa4b2; text-decoration: none; }
|
|
17
|
+
.navx .btn-link.active { color: #ffd24a; }
|
|
18
|
+
a { color: #ffd24a; }
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<div id="app"></div>
|
|
23
|
+
<script src="/socket.io/socket.io.js"></script>
|
|
24
|
+
<script type="module" src="/app.js"></script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|