create-volt 0.15.0 → 0.16.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,35 @@ 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.16.0] - 2026-06-29
8
+
9
+ ### Added
10
+ - **`media` add-on** — file uploads with a swappable storage driver: `local`
11
+ (disk, served at `/media`) or `s3` (any S3-compatible store: AWS S3,
12
+ DigitalOcean Spaces, …). `POST /api/media` is auth-gated (depends on the auth
13
+ add-on); uploads are size-capped (`MEDIA_MAX_MB`, default 10), restricted to
14
+ raster images + PDF (SVG rejected), stored under a random key, and returned as
15
+ a public URL. Driver + S3 settings are configured in the setup wizard. Pulls in
16
+ `busboy` (and `@aws-sdk/client-s3` when `MEDIA_DRIVER=s3`), both tracked by the
17
+ dependency auto-updater and exercised by the smoke gate.
18
+
19
+ ## [0.15.1] - 2026-06-29
20
+
21
+ ### Fixed
22
+ - `volt.js` no longer touches `window` at import time — the hot-reload client is
23
+ guarded with `typeof window`, so the library is safe to import in Node (SSR,
24
+ tests), not just the browser.
25
+
26
+ ### Changed
27
+ - The `pages` add-on imports `express`/`marked` lazily (only when mounted), so
28
+ its pure helpers load without those packages present.
29
+
30
+ ### Added (repo tooling — not shipped in scaffolded apps)
31
+ - A `node --test` unit suite (reactive core, memory store, pages helpers), a
32
+ `smoke` script (scaffold → install → boot → hit endpoints), a CI workflow, and
33
+ a smoke-test **gate** on the dependency auto-updater: a version bump is
34
+ committed only if unit tests + smoke pass on the bumped versions.
35
+
7
36
  ## [0.15.0] - 2026-06-28
8
37
 
9
38
  ### Added
@@ -213,6 +242,8 @@ All notable changes to `create-volt` are documented here. The format follows
213
242
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
214
243
  and auto-detects npm / pnpm / yarn / bun for the install step.
215
244
 
245
+ [0.16.0]: https://github.com/MIR-2025/volt/releases/tag/v0.16.0
246
+ [0.15.1]: https://github.com/MIR-2025/volt/releases/tag/v0.15.1
216
247
  [0.15.0]: https://github.com/MIR-2025/volt/releases/tag/v0.15.0
217
248
  [0.14.0]: https://github.com/MIR-2025/volt/releases/tag/v0.14.0
218
249
  [0.13.0]: https://github.com/MIR-2025/volt/releases/tag/v0.13.0
@@ -0,0 +1,99 @@
1
+ // media.js — media uploads with a swappable storage driver: `local` (disk,
2
+ // served at /media) or `s3` (any S3-compatible store: AWS S3, DigitalOcean
3
+ // Spaces, etc.). POST /api/media is auth-gated; objects are stored under a
4
+ // random key and a public URL is returned. express/busboy/aws-sdk are imported
5
+ // lazily so the pure helpers below load without those deps installed.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import crypto from "node:crypto";
9
+
10
+ // allowlist — raster images + PDF (no SVG: it can carry script). Extend via code.
11
+ const ALLOWED = { "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "application/pdf": "pdf" };
12
+ export const extFor = (mime) => ALLOWED[mime] || null;
13
+ export const isAllowed = (mime) => !!ALLOWED[mime];
14
+ export const genKey = (mime) => `${crypto.randomBytes(10).toString("hex")}.${extFor(mime)}`;
15
+
16
+ function localDriver({ dir }) {
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ return {
19
+ name: "local",
20
+ async put(key, buf) {
21
+ fs.writeFileSync(path.join(dir, key), buf);
22
+ return `/media/${key}`;
23
+ },
24
+ };
25
+ }
26
+
27
+ async function s3Driver(env) {
28
+ const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
29
+ const endpoint = (env.S3_ENDPOINT || "").replace(/\/$/, "");
30
+ const bucket = env.S3_BUCKET;
31
+ const client = new S3Client({
32
+ endpoint: endpoint || undefined,
33
+ region: env.S3_REGION || "us-east-1",
34
+ credentials: { accessKeyId: env.S3_KEY, secretAccessKey: env.S3_SECRET },
35
+ forcePathStyle: true, // safest across S3-compatible endpoints (e.g. Spaces)
36
+ });
37
+ const base = (env.S3_PUBLIC_BASE || (endpoint ? `${endpoint}/${bucket}` : "")).replace(/\/$/, "");
38
+ return {
39
+ name: "s3",
40
+ async put(key, buf, mime) {
41
+ await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: buf, ContentType: mime, ACL: "public-read" }));
42
+ return `${base}/${key}`;
43
+ },
44
+ };
45
+ }
46
+
47
+ export async function mediaRouter({ requireAuth, dir, env = process.env }) {
48
+ const express = (await import("express")).default;
49
+ const busboy = (await import("busboy")).default;
50
+ const driver = env.MEDIA_DRIVER === "s3" ? await s3Driver(env) : localDriver({ dir });
51
+ const maxMb = Number(env.MEDIA_MAX_MB) || 10;
52
+ const r = express.Router();
53
+
54
+ if (driver.name === "local") r.use("/media", express.static(dir)); // public reads
55
+
56
+ r.post("/api/media", requireAuth, (req, res) => {
57
+ let bb;
58
+ try {
59
+ bb = busboy({ headers: req.headers, limits: { files: 1, fileSize: maxMb * 1024 * 1024 } });
60
+ } catch {
61
+ return res.status(400).json({ ok: false, error: "bad upload" });
62
+ }
63
+ let done = false;
64
+ const finish = (code, body) => {
65
+ if (!done) {
66
+ done = true;
67
+ res.status(code).json(body);
68
+ }
69
+ };
70
+ bb.on("file", (_name, stream, info) => {
71
+ if (!isAllowed(info.mimeType)) {
72
+ stream.resume();
73
+ return finish(415, { ok: false, error: "unsupported file type" });
74
+ }
75
+ const chunks = [];
76
+ let tooBig = false;
77
+ stream.on("data", (c) => chunks.push(c));
78
+ stream.on("limit", () => {
79
+ tooBig = true;
80
+ finish(413, { ok: false, error: `file exceeds ${maxMb}MB` });
81
+ });
82
+ stream.on("end", async () => {
83
+ if (tooBig || done) return;
84
+ try {
85
+ const key = genKey(info.mimeType);
86
+ const url = await driver.put(key, Buffer.concat(chunks), info.mimeType);
87
+ finish(200, { ok: true, url, key, driver: driver.name });
88
+ } catch (e) {
89
+ finish(500, { ok: false, error: e.message });
90
+ }
91
+ });
92
+ });
93
+ bb.on("error", () => finish(500, { ok: false, error: "upload failed" }));
94
+ bb.on("close", () => finish(400, { ok: false, error: "no file uploaded" }));
95
+ req.pipe(bb);
96
+ });
97
+
98
+ return r;
99
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "description": "Media uploads to local disk or any S3-compatible store (AWS S3, DigitalOcean Spaces, etc.). POST /api/media (signed-in only) returns a public URL. Choose the driver in the wizard.",
3
+ "dependsOn": ["auth"],
4
+ "sentinel": "lib/media.js",
5
+ "install": ["busboy"],
6
+ "optional": { "s3": ["@aws-sdk/client-s3"] },
7
+ "wiring": "Set MEDIA_DRIVER=local|s3. For s3 set S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_KEY, S3_SECRET (and optionally S3_PUBLIC_BASE for a CDN URL). Uploads require a signed-in user; uploaded objects are public-read."
8
+ }
@@ -3,8 +3,8 @@
3
3
  // Pages are code-owned files (trusted), so their markdown HTML is served as-is.
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
- import express from "express";
7
- import { marked } from "marked";
6
+ // express + marked are imported lazily in pagesRouter() so this module's pure
7
+ // helpers (parseFrontMatter, isSafeSlug) load without those deps installed.
8
8
 
9
9
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
10
10
 
@@ -36,7 +36,7 @@ function ensure(dir) {
36
36
  }
37
37
 
38
38
  const FM = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
39
- function parse(src) {
39
+ export function parseFrontMatter(src) {
40
40
  const m = src.match(FM);
41
41
  if (!m) return { meta: {}, body: src };
42
42
  const meta = {};
@@ -47,6 +47,9 @@ function parse(src) {
47
47
  return { meta, body: src.slice(m[0].length) };
48
48
  }
49
49
 
50
+ // slugs are restricted to a safe charset — no dots/slashes → no path traversal
51
+ export const isSafeSlug = (s) => /^[a-z0-9][a-z0-9-]*$/i.test(s);
52
+
50
53
  function shell(title, inner) {
51
54
  return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
52
55
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -64,15 +67,17 @@ table { border-collapse: collapse } td, th { border: 1px solid #ccc; padding: .4
64
67
  </style></head><body>${inner}</body></html>`;
65
68
  }
66
69
 
67
- export function pagesRouter({ dir }) {
70
+ export async function pagesRouter({ dir }) {
71
+ const express = (await import("express")).default;
72
+ const { marked } = await import("marked");
68
73
  ensure(dir);
69
74
  const r = express.Router();
70
75
  r.get("/:slug", (req, res, next) => {
71
76
  const slug = req.params.slug;
72
- if (!/^[a-z0-9][a-z0-9-]*$/i.test(slug)) return next(); // safe slug only — no traversal
77
+ if (!isSafeSlug(slug)) return next(); // safe slug only — no traversal
73
78
  const file = path.join(dir, slug + ".md");
74
79
  if (!fs.existsSync(file)) return next();
75
- const { meta, body } = parse(fs.readFileSync(file, "utf8"));
80
+ const { meta, body } = parseFrontMatter(fs.readFileSync(file, "utf8"));
76
81
  res.type("html").send(shell(meta.title || slug, marked.parse(body)));
77
82
  });
78
83
  return r;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.15.0",
3
+ "version": "0.16.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": {
@@ -254,6 +254,7 @@ function bindNodeHole(comment, value) {
254
254
  // ---------------------------------------------------------------------------
255
255
 
256
256
  (function startHotReload() {
257
+ if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
257
258
  const connect = () => {
258
259
  if (!window.io) return false;
259
260
  const socket = window.io();
@@ -21,8 +21,8 @@ const ENV_PATH = path.join(__dirname, ".env");
21
21
  const PKG_PATH = path.join(__dirname, "package.json");
22
22
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
23
23
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
24
- const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5" };
25
- const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js" };
24
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
25
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", media: "media.js" };
26
26
 
27
27
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
28
28
  function readEnvFile() {
@@ -111,8 +111,14 @@ async function startApp() {
111
111
 
112
112
  app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
113
113
 
114
+ // media uploads (POST /api/media, auth-gated; local files served at /media)
115
+ if (enabled.has("media") && store) {
116
+ const requireAuth = (await addonMod("auth")).requireAuth(store);
117
+ app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
118
+ }
119
+
114
120
  // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
115
- if (enabled.has("pages")) app.use((await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
121
+ if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
116
122
 
117
123
  const server = http.createServer(app);
118
124
  const io = new SocketServer(server);
@@ -160,6 +166,8 @@ function neededPackages(env) {
160
166
  if (driver === "postgres") want.push("pg");
161
167
  if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
162
168
  if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
169
+ if (/^\s*VOLT_ADDONS\s*=.*\bmedia\b/m.test(env)) want.push("busboy");
170
+ if (/^\s*MEDIA_DRIVER\s*=\s*s3\b/m.test(env)) want.push("@aws-sdk/client-s3");
163
171
  return want.filter((p) => !deps[p]);
164
172
  }
165
173
 
@@ -17,6 +17,13 @@ const state = signal({
17
17
  dbUrl: current.DATABASE_URL || "",
18
18
  smtpUrl: current.SMTP_URL || "",
19
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 || "",
20
27
  port: current.PORT || String(defaultPort),
21
28
  });
22
29
  const set = (patch) => state({ ...state(), ...patch });
@@ -53,6 +60,17 @@ function genEnv(s) {
53
60
  else out.push("# SMTP_URL= # unset → emails print to the console");
54
61
  if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
55
62
  }
63
+ if (eff.includes("media")) {
64
+ out.push(`MEDIA_DRIVER=${clean(s.mediaDriver)}`);
65
+ if (s.mediaDriver === "s3") {
66
+ out.push(`S3_ENDPOINT=${clean(s.s3Endpoint)}`);
67
+ out.push(`S3_REGION=${clean(s.s3Region)}`);
68
+ out.push(`S3_BUCKET=${clean(s.s3Bucket)}`);
69
+ out.push(`S3_KEY=${clean(s.s3Key)}`);
70
+ out.push(`S3_SECRET=${clean(s.s3Secret)}`);
71
+ if (s.s3PublicBase) out.push(`S3_PUBLIC_BASE=${clean(s.s3PublicBase)}`);
72
+ }
73
+ }
56
74
  return out.join("\n") + "\n";
57
75
  }
58
76
  const env = computed(() => genEnv(state()));
@@ -134,6 +152,19 @@ const dbSettings = () =>
134
152
  : null}
135
153
  ${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
136
154
 
155
+ const mediaSettings = () =>
156
+ html`<div class="mb-2">
157
+ <label class="form-label small mb-1">Media storage (MEDIA_DRIVER)</label>
158
+ <select class="form-select" value=${() => state().mediaDriver} onchange=${(e) => set({ mediaDriver: e.target.value })}>
159
+ <option value="local">local (disk)</option>
160
+ <option value="s3">s3 — AWS S3 / DigitalOcean Spaces</option>
161
+ </select>
162
+ </div>
163
+ ${() =>
164
+ state().mediaDriver === "s3"
165
+ ? 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")}`
166
+ : null}`;
167
+
137
168
  mount(
138
169
  "#app",
139
170
  available.length
@@ -148,6 +179,7 @@ mount(
148
179
  ${field("PORT", "port", String(defaultPort))}
149
180
  ${() => (eff().includes("db") ? dbSettings() : null)}
150
181
  ${() => (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)}
182
+ ${() => (eff().includes("media") ? mediaSettings() : null)}
151
183
  </div>`,
152
184
  html`<div class="card-x p-4 mb-3">
153
185
  <div class="d-flex justify-content-between align-items-center mb-2">
@@ -254,6 +254,7 @@ function bindNodeHole(comment, value) {
254
254
  // ---------------------------------------------------------------------------
255
255
 
256
256
  (function startHotReload() {
257
+ if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
257
258
  const connect = () => {
258
259
  if (!window.io) return false;
259
260
  const socket = window.io();
@@ -254,6 +254,7 @@ function bindNodeHole(comment, value) {
254
254
  // ---------------------------------------------------------------------------
255
255
 
256
256
  (function startHotReload() {
257
+ if (typeof window === "undefined") return; // not a browser (SSR / Node imports / tests)
257
258
  const connect = () => {
258
259
  if (!window.io) return false;
259
260
  const socket = window.io();
@@ -22,8 +22,8 @@ const ENV_PATH = path.join(__dirname, ".env");
22
22
  const PKG_PATH = path.join(__dirname, "package.json");
23
23
  const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
- const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5" };
26
- const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js" };
25
+ const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
26
+ const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", media: "media.js" };
27
27
 
28
28
  // --- tiny .env loader (no dependency); never overrides an existing env var ---
29
29
  function readEnvFile() {
@@ -137,8 +137,14 @@ async function startApp() {
137
137
 
138
138
  app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
139
139
 
140
+ // media uploads (POST /api/media, auth-gated; local files served at /media)
141
+ if (enabled.has("media") && store) {
142
+ const requireAuth = (await addonMod("auth")).requireAuth(store);
143
+ app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
144
+ }
145
+
140
146
  // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
141
- if (enabled.has("pages")) app.use((await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
147
+ if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
142
148
 
143
149
  const server = http.createServer(app);
144
150
  const io = new SocketServer(server);
@@ -186,6 +192,8 @@ function neededPackages(env) {
186
192
  if (driver === "postgres") want.push("pg");
187
193
  if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
188
194
  if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
195
+ if (/^\s*VOLT_ADDONS\s*=.*\bmedia\b/m.test(env)) want.push("busboy");
196
+ if (/^\s*MEDIA_DRIVER\s*=\s*s3\b/m.test(env)) want.push("@aws-sdk/client-s3");
189
197
  return want.filter((p) => !deps[p]);
190
198
  }
191
199
 
@@ -17,6 +17,13 @@ const state = signal({
17
17
  dbUrl: current.DATABASE_URL || "",
18
18
  smtpUrl: current.SMTP_URL || "",
19
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 || "",
20
27
  port: current.PORT || String(defaultPort),
21
28
  });
22
29
  const set = (patch) => state({ ...state(), ...patch });
@@ -53,6 +60,17 @@ function genEnv(s) {
53
60
  else out.push("# SMTP_URL= # unset → emails print to the console");
54
61
  if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
55
62
  }
63
+ if (eff.includes("media")) {
64
+ out.push(`MEDIA_DRIVER=${clean(s.mediaDriver)}`);
65
+ if (s.mediaDriver === "s3") {
66
+ out.push(`S3_ENDPOINT=${clean(s.s3Endpoint)}`);
67
+ out.push(`S3_REGION=${clean(s.s3Region)}`);
68
+ out.push(`S3_BUCKET=${clean(s.s3Bucket)}`);
69
+ out.push(`S3_KEY=${clean(s.s3Key)}`);
70
+ out.push(`S3_SECRET=${clean(s.s3Secret)}`);
71
+ if (s.s3PublicBase) out.push(`S3_PUBLIC_BASE=${clean(s.s3PublicBase)}`);
72
+ }
73
+ }
56
74
  return out.join("\n") + "\n";
57
75
  }
58
76
  const env = computed(() => genEnv(state()));
@@ -134,6 +152,19 @@ const dbSettings = () =>
134
152
  : null}
135
153
  ${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
136
154
 
155
+ const mediaSettings = () =>
156
+ html`<div class="mb-2">
157
+ <label class="form-label small mb-1">Media storage (MEDIA_DRIVER)</label>
158
+ <select class="form-select" value=${() => state().mediaDriver} onchange=${(e) => set({ mediaDriver: e.target.value })}>
159
+ <option value="local">local (disk)</option>
160
+ <option value="s3">s3 — AWS S3 / DigitalOcean Spaces</option>
161
+ </select>
162
+ </div>
163
+ ${() =>
164
+ state().mediaDriver === "s3"
165
+ ? 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")}`
166
+ : null}`;
167
+
137
168
  mount(
138
169
  "#app",
139
170
  available.length
@@ -148,6 +179,7 @@ mount(
148
179
  ${field("PORT", "port", String(defaultPort))}
149
180
  ${() => (eff().includes("db") ? dbSettings() : null)}
150
181
  ${() => (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)}
182
+ ${() => (eff().includes("media") ? mediaSettings() : null)}
151
183
  </div>`,
152
184
  html`<div class="card-x p-4 mb-3">
153
185
  <div class="d-flex justify-content-between align-items-center mb-2">