create-volt 0.10.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 CHANGED
@@ -4,6 +4,33 @@ 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
+
18
+ ## [0.11.0] - 2026-06-28
19
+
20
+ ### Added
21
+ - **`create-volt studio`** (and `npm run dev -- --studio`) — an ephemeral,
22
+ localhost-only **data browser**, à la Prisma Studio. Browse collections and
23
+ documents across any driver (memory / MongoDB / MySQL / Postgres) and delete
24
+ docs. It's **never a route in the running app** — it exists only while you run
25
+ it, binds `127.0.0.1`, and disappears on Ctrl-C (shell/SSH access is the auth).
26
+ Internal collections (auth tokens/sessions) are hidden.
27
+ - Stores gained `collections()` (enumerate collection names) on every adapter.
28
+
29
+ ### Security
30
+ - Admin/data surfaces are **ephemeral by design** — no standing `/admin` route in
31
+ the running app to attack (verified: the app 404s admin routes). Same model as
32
+ the config editor: on-demand, loopback-only, gone when the app runs.
33
+
7
34
  ## [0.10.0] - 2026-06-28
8
35
 
9
36
  ### Added
@@ -146,6 +173,8 @@ All notable changes to `create-volt` are documented here. The format follows
146
173
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
147
174
  and auto-detects npm / pnpm / yarn / bun for the install step.
148
175
 
176
+ [0.12.0]: https://github.com/MIR-2025/volt/releases/tag/v0.12.0
177
+ [0.11.0]: https://github.com/MIR-2025/volt/releases/tag/v0.11.0
149
178
  [0.10.0]: https://github.com/MIR-2025/volt/releases/tag/v0.10.0
150
179
  [0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
151
180
  [0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.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
- | `guestbook` | A real app: magic-link auth, Socket.io real-time message board, and pluggable **MongoDB / MySQL / Postgres** storage (in-memory by default, so it runs with zero setup). |
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
 
@@ -66,6 +67,19 @@ SSH-tunnel hint on a remote box). Enabling an add-on wires its **backend**
66
67
  automatically — the **frontend** UI (login form, chat) is yours to build, or
67
68
  start from `--template guestbook`, which has it wired end-to-end.
68
69
 
70
+ ## Studio (data browser)
71
+
72
+ Browse your database on demand — ephemeral and localhost-only, like Prisma Studio:
73
+
74
+ ```bash
75
+ npm run dev -- --studio # or: npx create-volt studio
76
+ ```
77
+
78
+ It connects the DB in your `.env`, lets you view/delete documents across any
79
+ driver, and is **never** a route in the running app (no standing `/admin` to
80
+ attack — shell/SSH access is the gate). Needs a persistent driver (MongoDB /
81
+ MySQL / Postgres) to show data; the memory driver is per-process.
82
+
69
83
  ## Updating Volt
70
84
 
71
85
  Volt is vendored as a single file (`public/volt.js`), not an npm dependency.
@@ -29,5 +29,5 @@ export function createMemoryStore() {
29
29
  },
30
30
  });
31
31
 
32
- return { name: "memory", async init() {}, collection };
32
+ return { name: "memory", async init() {}, collection, async collections() { return [...cols.keys()]; } };
33
33
  }
@@ -39,5 +39,12 @@ export async function createMongoStore({ uri, dbName }) {
39
39
  };
40
40
  };
41
41
 
42
- return { name: "mongodb", async init() {}, collection };
42
+ return {
43
+ name: "mongodb",
44
+ async init() {},
45
+ collection,
46
+ async collections() {
47
+ return (await db.listCollections().toArray()).map((c) => c.name);
48
+ },
49
+ };
43
50
  }
@@ -52,6 +52,9 @@ export async function createSqlStore({ dialect, uri }) {
52
52
  )`,
53
53
  );
54
54
  },
55
+ async collections() {
56
+ return (await run(`SELECT DISTINCT coll FROM documents`)).map((r) => r.coll);
57
+ },
55
58
  collection(n) {
56
59
  return {
57
60
  async put(id, doc) {
@@ -0,0 +1,72 @@
1
+ // db-admin-ui.js — a tiny, auth-gated data browser (frontend for the db add-on).
2
+ // Served at /db-admin-ui.js; mounted by public/app.js only when db + auth are on.
3
+ // Works for every driver — it reads through the generic store API on the server.
4
+ // Documents render as escaped JSON text (Volt holes → text nodes), never HTML.
5
+ import { signal, computed, html } from "/volt.js";
6
+
7
+ const j = async (url, opts) => {
8
+ const res = await fetch(url, opts);
9
+ return { status: res.status, body: await res.json().catch(() => ({})) };
10
+ };
11
+
12
+ export function dbAdminPanel() {
13
+ const ready = signal(false); // signed in + loaded
14
+ const denied = signal(false); // 401
15
+ const driver = signal("");
16
+ const collections = signal([]);
17
+ const current = signal("");
18
+ const docs = signal([]);
19
+ const note = signal("");
20
+
21
+ async function loadCollections() {
22
+ const { status, body } = await j("/admin/db/collections");
23
+ if (status === 401) {
24
+ denied(true);
25
+ return;
26
+ }
27
+ denied(false);
28
+ driver(body.driver || "");
29
+ collections(body.collections || []);
30
+ ready(true);
31
+ if (!current() && collections().length) openCollection(collections()[0]);
32
+ }
33
+ async function openCollection(name) {
34
+ current(name);
35
+ note("");
36
+ const { body } = await j(`/admin/db/collection?name=${encodeURIComponent(name)}`);
37
+ docs(body.ok ? body.docs : []);
38
+ }
39
+ async function del(id) {
40
+ if (!id) return;
41
+ await j(`/admin/db/doc?name=${encodeURIComponent(current())}&id=${encodeURIComponent(id)}`, { method: "DELETE" });
42
+ docs(docs().filter((d) => d.id !== id));
43
+ note("Deleted.");
44
+ }
45
+
46
+ loadCollections();
47
+
48
+ const collTab = (name) =>
49
+ html`<button class=${() => "btn btn-sm " + (current() === name ? "btn-primary" : "btn-outline-secondary")} onclick=${() => openCollection(name)}>${name}</button>`;
50
+
51
+ const docRow = (d) =>
52
+ html`<div class="d-flex justify-content-between align-items-start gap-2 py-1" style="border-top:1px solid #232a36">
53
+ <pre class="mb-0 small flex-grow-1" style="white-space:pre-wrap;color:#cfe3ff">${JSON.stringify(d, null, 2)}</pre>
54
+ <button class="btn btn-sm btn-outline-danger" onclick=${() => del(d.id)}>✕</button>
55
+ </div>`;
56
+
57
+ const heading = computed(() => `Data ${driver() ? `— ${driver()}` : ""}`);
58
+
59
+ return html`<div class="card-x p-4 mb-4">
60
+ <h2 class="h6 mb-3">${heading} <span class="text-muted small">— admin</span></h2>
61
+ ${() =>
62
+ denied()
63
+ ? html`<p class="text-muted small mb-0">Sign in to browse your data.</p>`
64
+ : !ready()
65
+ ? html`<p class="text-muted small mb-0">Loading…</p>`
66
+ : html`<div class="d-flex flex-wrap gap-1 mb-2">${() => (collections().length ? collections().map(collTab) : html`<span class="text-muted small">No collections yet.</span>`)}</div>
67
+ <div style="max-height:260px;overflow:auto">
68
+ ${() => (docs().length ? docs().map(docRow) : html`<span class="text-muted small">Empty.</span>`)}
69
+ </div>
70
+ ${() => (note() ? html`<p class="small text-muted mb-0 mt-2">${note}</p>` : null)}`}
71
+ </div>`;
72
+ }
package/index.js CHANGED
@@ -36,9 +36,10 @@ ${bold("Usage")}
36
36
  npx create-volt <project-directory> [options]
37
37
  npx create-volt@latest update # refresh public/volt.js in an existing app
38
38
  npx create-volt@latest config # open the app's setup wizard (edit add-ons + settings)
39
+ npx create-volt@latest studio # browse your data — ephemeral, localhost (like Prisma Studio)
39
40
 
40
41
  ${bold("Options")}
41
- --template <name> Starter template: default | guestbook (default: default)
42
+ --template <name> Template: default | starter | guestbook (default: default)
42
43
  --port <number> Dev port for the app (default: derived from today's date)
43
44
  --skip-install Don't run the package manager install step
44
45
  --no-git Don't initialize a git repository
@@ -131,6 +132,21 @@ if (positionals[0] === "config") {
131
132
  process.exit(res.status ?? 0);
132
133
  }
133
134
 
135
+ // --- `studio` subcommand: ephemeral, localhost-only data browser (server.js --studio) ---
136
+ if (positionals[0] === "studio") {
137
+ const cwd = process.cwd();
138
+ if (!fs.existsSync(path.join(cwd, "server.js"))) {
139
+ die(`No ${cyan("server.js")} here — run ${cyan("create-volt studio")} from inside a Volt app.`);
140
+ }
141
+ if (portArg) process.env.PORT = String(Number(portArg) || "");
142
+ const res = spawnSync(process.execPath, ["server.js", "--studio"], {
143
+ cwd,
144
+ stdio: "inherit",
145
+ env: flags.has("--no-open") ? { ...process.env, VOLT_NO_OPEN: "1" } : process.env,
146
+ });
147
+ process.exit(res.status ?? 0);
148
+ }
149
+
134
150
  // Resolve the dev port: --port wins, else derive it from today's date as
135
151
  // two-digit-year + month (no leading zero) + two-digit-day (house convention),
136
152
  // so apps scaffolded on different days don't collide on the same port.
@@ -229,6 +245,11 @@ const shippedGitignore = path.join(targetDir, "gitignore");
229
245
  if (fs.existsSync(shippedGitignore)) {
230
246
  fs.renameSync(shippedGitignore, path.join(targetDir, ".gitignore"));
231
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
+ }
232
253
 
233
254
  // Bundle the add-on sources so the app's setup wizard can enable them later
234
255
  // (only for templates that ship the wizard, i.e. have a setup/ dir).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -299,9 +299,95 @@ function readEnvFileLines() {
299
299
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
300
300
  }
301
301
 
302
- // --- gate: setup on first run / --edit, otherwise the app ---
302
+ // --- Studio: an ephemeral, localhost-only data browser la Prisma Studio).
303
+ // Not a route in the running app — it only exists while you run `--studio`, on
304
+ // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
305
+ const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
306
+ async function startStudio() {
307
+ loadEnv();
308
+ if (!enabledFrom(process.env).has("db")) {
309
+ console.error("Studio needs the db add-on. Enable it: npm run dev -- --edit");
310
+ process.exit(1);
311
+ }
312
+ let store;
313
+ try {
314
+ store = await (await addonMod("db")).createStore();
315
+ } catch (e) {
316
+ console.error("Studio: couldn't connect the store — " + e.message);
317
+ process.exit(1);
318
+ }
319
+ const PORT = Number(process.env.PORT) || Number(readEnvFile().PORT) || DEFAULT_PORT;
320
+ const visible = (n) => n && !HIDDEN_COLLECTIONS.has(n);
321
+ const assets = {
322
+ "/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
323
+ "/db-admin-ui.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(ADDONS_DIR, "db", "files", "public", "db-admin-ui.js"))],
324
+ };
325
+ const studioHtml = fs.readFileSync(path.join(__dirname, "setup", "studio.html"));
326
+ const json = (res, code, obj) => {
327
+ res.statusCode = code;
328
+ res.setHeader("Content-Type", "application/json");
329
+ res.end(JSON.stringify(obj));
330
+ };
331
+
332
+ const server = http.createServer(async (req, res) => {
333
+ const u = new URL(req.url, "http://localhost");
334
+ const p = u.pathname;
335
+ try {
336
+ if (req.method === "GET" && p === "/") {
337
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
338
+ return res.end(studioHtml);
339
+ }
340
+ if (req.method === "GET" && assets[p]) {
341
+ res.setHeader("Content-Type", assets[p][0]);
342
+ return res.end(assets[p][1]);
343
+ }
344
+ if (req.method === "GET" && p === "/admin/db/collections") {
345
+ const all = (await store.collections()) || [];
346
+ return json(res, 200, { driver: store.name, collections: all.filter(visible) });
347
+ }
348
+ if (req.method === "GET" && p === "/admin/db/collection") {
349
+ const name = u.searchParams.get("name") || "";
350
+ if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
351
+ const docs = (await store.collection(name).all()).slice(0, 500);
352
+ return json(res, 200, { ok: true, name, docs });
353
+ }
354
+ if (req.method === "DELETE" && p === "/admin/db/doc") {
355
+ const name = u.searchParams.get("name") || "";
356
+ const id = u.searchParams.get("id") || "";
357
+ if (!visible(name)) return json(res, 403, { ok: false, error: "hidden" });
358
+ if (!id) return json(res, 400, { ok: false, error: "missing id" });
359
+ await store.collection(name).delete(id);
360
+ return json(res, 200, { ok: true });
361
+ }
362
+ res.statusCode = 404;
363
+ res.end("not found");
364
+ } catch (e) {
365
+ json(res, 500, { ok: false, error: e.message });
366
+ }
367
+ });
368
+
369
+ server.listen(PORT, "127.0.0.1", () => {
370
+ const url = `http://localhost:${PORT}`;
371
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
372
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
373
+ const ssh = process.env.SSH_CONNECTION;
374
+ if (ssh) {
375
+ const host = ssh.split(" ")[2];
376
+ const user = process.env.USER || process.env.USERNAME || "you";
377
+ console.log(" Remote box — from your LOCAL machine:");
378
+ console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
379
+ console.log(` …then open ${url}.`);
380
+ }
381
+ console.log("");
382
+ openBrowser(url);
383
+ });
384
+ }
385
+
386
+ // --- gate: studio / setup (first run, --edit) / the app ---
303
387
  const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
304
- if (editMode || !fs.existsSync(ENV_PATH)) {
388
+ if (process.argv.includes("--studio")) {
389
+ startStudio();
390
+ } else if (editMode || !fs.existsSync(ENV_PATH)) {
305
391
  startSetup();
306
392
  } else {
307
393
  loadEnv();
@@ -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 &amp; 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,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,2 @@
1
+ VOLT_ADDONS=db,mailer,auth,realtime
2
+ DB_DRIVER=memory
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ npm-debug.log*
3
+ .DS_Store
4
+ .env
5
+ *.local
@@ -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));