Skip to main content

max / makenotwork

server: restructure the frontend into a typed core lib + custom-element islands Reverse the no-build-step rule and pull ~7.8k lines of untested, module-less vanilla JS toward a typed, tested architecture. One build-time dependency (typescript); tsc typechecks and emits browser ESM to static/dist, run from build.rs (non-fatal — degrades to the existing tree when Node is absent). Core: port static/mnw.js (the data-action dispatcher + primitives — escaping, CSRF fetch, toasts, storage, clipboard, loading, tabs, keyboard, restart banner, HTMX glue) into frontend/src/core, loaded as one module in place of mnw.js. The dispatcher resolves a typed register() first, then a window bridge so the not-yet-migrated static/*.js keep working. mnw.js deleted. Islands: custom elements that enhance server markup (auto-upgrade on HTMX swap, no manual re-init). <mnw-carousel>, <mnw-media-player> (the 7x-inlined gapless-timeline offset math extracted to one tested module), and <mnw-image-uploader> (unifies the byte-identical wizard image uploaders behind a typed S3 primitive + presignPutConfirm). Heavy islands load per-page via a build.rs-generated _island.html macro; carousel.js / media-player.js and the two wizard uploaders deleted. Gates: tsc + node --test (25 tests) + a frontend_globals seal that ratchets the window.* count down (115 -> 113). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-08 01:46 UTC
Commit: ace9caa63805a8bbcca886bf994b3d77fdf5e9c6
Parent: 446b939
58 files changed, +2279 insertions, -1732 deletions
M .gitignore +6 -1
@@ -2,6 +2,10 @@
2 2 /target
3 3 **/target
4 4
5 + # Frontend (regenerable — tsc output + installed deps; rebuilt by build.rs)
6 + server/static/dist/
7 + server/frontend/node_modules/
8 +
5 9 # Environment files (contain secrets)
6 10 server/.env
7 11 server/.env.local
@@ -31,8 +35,9 @@ Thumbs.db
31 35 # SQLx offline mode cache
32 36 .sqlx/
33 37
34 - # Generated template partial (build.rs output)
38 + # Generated template partials (build.rs output)
35 39 server/templates/_head_assets.html
40 + server/templates/_island.html
36 41
37 42 # Generated rustdoc output
38 43 server/rustdoc-out/
M server/build.rs +93 -6
@@ -18,6 +18,9 @@ fn main() {
18 18 // Only re-run when HEAD changes
19 19 println!("cargo::rerun-if-changed=.git/HEAD");
20 20
21 + // Compile the TypeScript frontend to static/dist/ (best-effort — see fn).
22 + build_frontend();
23 +
21 24 // --- Static asset fingerprinting ---
22 25 // Hash the content of key static files to produce a version suffix.
23 26 // When any watched file changes, URLs in templates get a new ?v= param,
@@ -37,6 +40,10 @@ fn main() {
37 40 content.hash(&mut hasher);
38 41 }
39 42 }
43 + // Fold the emitted frontend bundles into the version so `?v=` busts when
44 + // the TypeScript changes. Read-only: the inputs under frontend/src are the
45 + // watched trigger (in build_frontend); watching the outputs would loop.
46 + hash_dir_js(Path::new("static/dist"), &mut hasher);
40 47 let static_hash = format!("{:016x}", hasher.finish());
41 48 let version = &static_hash[..8];
42 49
@@ -48,16 +55,96 @@ fn main() {
48 55 <link rel="stylesheet" href="/static/style.css?v={v}">
49 56 <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
50 57 <script src="/static/htmx.min.js"></script>
51 - <script src="/static/upload.js?v={v}"></script>"#,
58 + <script src="/static/upload.js?v={v}"></script>
59 + <script type="module" src="/static/dist/core/index.js?v={v}"></script>"#,
52 60 v = version,
53 61 );
54 62
55 - let out_path = Path::new("templates/_head_assets.html");
56 - // Only write if content changed (avoids unnecessary recompilation)
57 - let needs_write = fs::read_to_string(out_path)
58 - .map(|existing| existing != partial)
63 + write_if_changed(Path::new("templates/_head_assets.html"), &partial);
64 +
65 + // Per-page island loader macro. Heavy/page-specific islands (media player,
66 + // uploader, …) load on the pages that use them via
67 + // `{% import "_island.html" as island %}{% call island::island("name") %}`,
68 + // cache-busted by the same content hash as the head assets.
69 + let island_partial = r#"{% macro island(name) -%}
70 + <script type="module" src="/static/dist/islands/{{ name }}.js?v=__VER__"></script>
71 + {%- endmacro %}
72 + "#
73 + .replace("__VER__", version);
74 + write_if_changed(Path::new("templates/_island.html"), &island_partial);
75 + }
76 +
77 + /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
78 + fn write_if_changed(path: &Path, contents: &str) {
79 + let needs_write = fs::read_to_string(path)
80 + .map(|existing| existing != contents)
59 81 .unwrap_or(true);
60 82 if needs_write {
61 - fs::write(out_path, &partial).expect("failed to write _head_assets.html");
83 + fs::write(path, contents).unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
84 + }
85 + }
86 +
87 + /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
88 + /// `static/dist/` via `npm run build` (which runs `tsc`).
89 + ///
90 + /// Best-effort and non-fatal: a missing `node_modules` (deps not installed),
91 + /// an absent Node, or a compile error only emits a `cargo::warning` and leaves
92 + /// the Rust build to succeed against whatever `static/dist/` already holds.
93 + /// Dependency install (`npm install` in `frontend/`) is a manual/deploy step,
94 + /// not build.rs's job. Set `MNW_SKIP_FRONTEND_BUILD=1` to opt out entirely.
95 + ///
96 + /// Prereq for the deploy pipeline once a template references `static/dist/`:
97 + /// the build host must have Node and have run `npm install` in `frontend/`.
98 + fn build_frontend() {
99 + // Re-run the whole build script when the TS sources or its config change.
100 + println!("cargo::rerun-if-changed=frontend/src");
101 + println!("cargo::rerun-if-changed=frontend/package.json");
102 + println!("cargo::rerun-if-changed=frontend/tsconfig.json");
103 +
104 + if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
105 + println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
106 + return;
107 + }
108 + if !Path::new("frontend/node_modules").is_dir() {
109 + println!(
110 + "cargo::warning=frontend/node_modules missing — run `npm install` in server/frontend; \
111 + skipping TypeScript build (serving existing static/dist)"
112 + );
113 + return;
114 + }
115 + match Command::new("npm")
116 + .args(["run", "build"])
117 + .current_dir("frontend")
118 + .status()
119 + {
120 + Ok(s) if s.success() => {}
121 + Ok(s) => println!(
122 + "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
123 + s.code()
124 + ),
125 + Err(e) => println!(
126 + "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
127 + skipping (serving existing static/dist)"
128 + ),
129 + }
130 + }
131 +
132 + /// Recursively hash every `.js` file under `dir` into `hasher`, in a
133 + /// deterministic order. Missing directory is a no-op (first build before the
134 + /// frontend has been compiled).
135 + fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
136 + let Ok(entries) = fs::read_dir(dir) else {
137 + return;
138 + };
139 + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
140 + paths.sort();
141 + for path in paths {
142 + if path.is_dir() {
143 + hash_dir_js(&path, hasher);
144 + } else if path.extension().and_then(|e| e.to_str()) == Some("js") {
145 + if let Ok(content) = fs::read(&path) {
146 + content.hash(hasher);
147 + }
148 + }
62 149 }
63 150 }
@@ -0,0 +1 @@
1 + node_modules/
@@ -0,0 +1,29 @@
1 + {
2 + "name": "mnw-frontend",
3 + "version": "0.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "mnw-frontend",
9 + "version": "0.0.0",
10 + "devDependencies": {
11 + "typescript": "^6.0.3"
12 + }
13 + },
14 + "node_modules/typescript": {
15 + "version": "6.0.3",
16 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
17 + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
18 + "dev": true,
19 + "license": "Apache-2.0",
20 + "bin": {
21 + "tsc": "bin/tsc",
22 + "tsserver": "bin/tsserver"
23 + },
24 + "engines": {
25 + "node": ">=14.17"
26 + }
27 + }
28 + }
29 + }
@@ -0,0 +1,15 @@
1 + {
2 + "name": "mnw-frontend",
3 + "version": "0.0.0",
4 + "private": true,
5 + "type": "module",
6 + "description": "MNW server frontend — TypeScript core lib + custom-element islands, compiled by tsc into ../static/dist/ as browser ESM and loaded by Askama templates. One build-time dependency (typescript); no bundler, no runtime deps. Architecture: _private/docs/mnw/frontend/.",
7 + "scripts": {
8 + "build": "tsc",
9 + "typecheck": "tsc --noEmit",
10 + "test": "node --test"
11 + },
12 + "devDependencies": {
13 + "typescript": "^6.0.3"
14 + }
15 + }
@@ -0,0 +1,40 @@
1 + // Clipboard helper — consolidates the ~6 duplicated copy-then-flash-label
2 + // snippets and the delegated [data-copy-link] handler from mnw.js:670. Falls
3 + // back to a prompt in non-secure contexts (plain HTTP, some iframes), where the
4 + // old inline snippets silently no-op'd.
5 +
6 + /** Copy `text`, flashing `copiedLabel` on `el`, then restoring the label. */
7 + export function copyWithFeedback(el: HTMLElement, text: string, copiedLabel = 'Copied!', resetMs = 1500): void {
8 + const defaultLabel = el.textContent ?? '';
9 + const restore = () => {
10 + el.textContent = defaultLabel;
11 + };
12 + if (navigator.clipboard?.writeText) {
13 + navigator.clipboard
14 + .writeText(text)
15 + .then(() => {
16 + el.textContent = copiedLabel;
17 + setTimeout(restore, resetMs);
18 + })
19 + .catch(() => {
20 + window.prompt('Copy this link:', text);
21 + });
22 + } else {
23 + window.prompt('Copy this link:', text);
24 + }
25 + }
26 +
27 + /** Install the delegated `[data-copy-link]` handler. `href` stays the real
28 + * destination (middle-click / no-JS / share still work); left-click copies.
29 + * Ported from mnw.js:670. */
30 + export function initCopyLink(): void {
31 + document.addEventListener('click', (evt) => {
32 + const el = (evt.target as Element | null)?.closest<HTMLElement>('[data-copy-link]');
33 + if (!el) return;
34 + evt.preventDefault();
35 + let url = el.dataset.url || el.getAttribute('href') || window.location.href;
36 + if (url.charAt(0) === '/') url = window.location.origin + url;
37 + const copiedLabel = el.dataset.copiedLabel || 'Copied!';
38 + copyWithFeedback(el, url, copiedLabel);
39 + });
40 + }
@@ -0,0 +1,23 @@
1 + import { test } from 'node:test';
2 + import assert from 'node:assert/strict';
3 + import { collectArgs } from './dispatch.ts';
4 +
5 + // A minimal Element stub exposing just getAttribute — enough for collectArgs.
6 + const el = (attrs: Record<string, string>) =>
7 + ({ getAttribute: (k: string) => (k in attrs ? attrs[k] : null) }) as unknown as Element;
8 +
9 + test('collectArgs reads data-arg then data-arg2 in order', () => {
10 + assert.deepEqual(collectArgs(el({ 'data-arg': 'x', 'data-arg2': 'y' })), ['x', 'y']);
11 + });
12 +
13 + test('collectArgs stops at the first absent arg slot but keeps a present arg2? no — arg only', () => {
14 + assert.deepEqual(collectArgs(el({ 'data-arg': 'only' })), ['only']);
15 + });
16 +
17 + test('collectArgs returns empty when neither is present', () => {
18 + assert.deepEqual(collectArgs(el({})), []);
19 + });
20 +
21 + test('collectArgs preserves empty-string args (attribute present but blank)', () => {
22 + assert.deepEqual(collectArgs(el({ 'data-arg': '', 'data-arg2': 'b' })), ['', 'b']);
23 + });
@@ -0,0 +1,163 @@
1 + // The action dispatcher + HTMX callback dispatcher, ported from mnw.js:724-871.
2 + //
3 + // These let templates drop inline `on*` / `hx-on::` handlers so the CSP keeps
4 + // `script-src 'self'` (no 'unsafe-inline'). The one behavioral change from the
5 + // port: `data-action` verbs resolve against a typed `register()` registry
6 + // FIRST, falling back to a `window.<verb>` global for handlers not yet migrated
7 + // off the legacy `static/*.js` files. New code should `register()`, never add a
8 + // global — the `frontend_globals` seal enforces the ratchet.
9 +
10 + import { byId, targets } from './dom.ts';
11 + import { showToast } from './toast.ts';
12 +
13 + export type ActionHandler = (this: Element, ...args: string[]) => void;
14 +
15 + const registry = new Map<string, ActionHandler>();
16 +
17 + /** Register a named `data-action` handler. Preferred over a `window.<name>`
18 + * global; the dispatcher checks the registry before the global fallback. */
19 + export function register(name: string, fn: ActionHandler): void {
20 + registry.set(name, fn);
21 + }
22 +
23 + type BuiltinFn = (el: Element) => void;
24 +
25 + /** Built-in verbs operating on `data-target` element ids. */
26 + const BUILTINS: Record<string, BuiltinFn> = {
27 + show: (el) => targets(el).forEach((t) => t.classList.remove('hidden')),
28 + hide: (el) => targets(el).forEach((t) => t.classList.add('hidden')),
29 + toggle: (el) => targets(el).forEach((t) => t.classList.toggle('hidden')),
30 + click: (el) => targets(el).forEach((t) => t.click()),
31 + remove: (el) => targets(el).forEach((t) => t.remove()),
32 + 'remove-self': (el) => el.remove(),
33 + };
34 +
35 + /** Collect positional args from `data-arg` / `data-arg2`. Exported for tests. */
36 + export function collectArgs(el: Element): string[] {
37 + const args: string[] = [];
38 + const a1 = el.getAttribute('data-arg');
39 + if (a1 !== null) args.push(a1);
40 + const a2 = el.getAttribute('data-arg2');
41 + if (a2 !== null) args.push(a2);
42 + return args;
43 + }
44 +
45 + function run(el: Element, verb: string, evt: Event): void {
46 + if (el.hasAttribute('data-prevent')) evt.preventDefault();
47 + if (el.hasAttribute('data-stop')) evt.stopPropagation();
48 + if (verb === 'nav') {
49 + const href = el.getAttribute('data-href');
50 + if (href) window.location.href = href;
51 + return;
52 + }
53 + const builtin = BUILTINS[verb];
54 + if (builtin) {
55 + builtin(el);
56 + return;
57 + }
58 + // Registry first, then the legacy `window.<verb>` global (strangler bridge).
59 + const handler = registry.get(verb) ?? (window as unknown as Record<string, unknown>)[verb];
60 + if (typeof handler !== 'function') {
61 + console.warn('data-action: no handler for', verb);
62 + return;
63 + }
64 + (handler as ActionHandler).apply(el, collectArgs(el));
65 + }
66 +
67 + function listen(eventName: string, attr: string, autoPrevent: boolean): void {
68 + document.addEventListener(eventName, (evt) => {
69 + const el = (evt.target as Element | null)?.closest('[' + attr + ']');
70 + if (!el) return;
71 + if (autoPrevent) evt.preventDefault();
72 + const verb = el.getAttribute(attr);
73 + if (verb) run(el, verb, evt);
74 + });
75 + }
76 +
77 + /** Install the `data-action`/`-change`/`-input`/`-submit` dispatcher. */
78 + export function initActionDispatcher(): void {
79 + listen('click', 'data-action', false);
80 + listen('change', 'data-change', false);
81 + listen('input', 'data-input', false);
82 + listen('submit', 'data-submit', true);
83 + }
84 +
85 + // ---- HTMX callback dispatcher (hx-on:: replacement), ported from mnw.js:799 ----
86 +
87 + interface HxDetail {
88 + elt: HTMLElement;
89 + successful?: boolean;
90 + xhr?: XMLHttpRequest;
91 + parameters?: Record<string, unknown>;
92 + }
93 +
94 + function refreshProfileTab(): void {
95 + const t = document.getElementById('settings-body');
96 + if (t) {
97 + htmx.ajax('GET', '/dashboard/tabs/profile', { target: t, swap: 'innerHTML' });
98 + } else {
99 + byId('tab-profile')?.click();
100 + }
101 + }
102 +
103 + type BehaviorFn = (el: HTMLElement, evt: CustomEvent<HxDetail>) => void;
104 +
105 + const BEHAVIORS: Record<string, BehaviorFn> = {
106 + 'mark-added': (el) => {
107 + el.textContent = 'Added';
108 + (el as HTMLButtonElement).disabled = true;
109 + },
110 + // Faithful to the inline original: the row fades only on success, but the
111 + // label flips to 'Added' unconditionally (registered under data-hx-always).
112 + 'fade-row-added': (el, evt) => {
113 + if (evt.detail.successful) el.closest('tr')?.classList.add('is-faded');
114 + el.textContent = 'Added';
115 + },
116 + 'refresh-profile': () => refreshProfileTab(),
117 + 'refresh-profile-if-verified': (_el, evt) => {
118 + if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) {
119 + refreshProfileTab();
120 + }
121 + },
122 + };
123 +
124 + /** Install the `data-hx-*` afterRequest/configRequest dispatcher. */
125 + export function initHxCallbackDispatcher(): void {
126 + document.body.addEventListener('htmx:afterRequest', (e) => {
127 + const evt = e as CustomEvent<HxDetail>;
128 + const el = evt.detail.elt;
129 + if (!el || !el.getAttribute) return;
130 +
131 + const always = el.getAttribute('data-hx-always');
132 + if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt);
133 + if (el.hasAttribute('data-hx-always-click')) byId(el.getAttribute('data-hx-always-click'))?.click();
134 +
135 + if (!evt.detail.successful) return;
136 +
137 + const form = el as HTMLFormElement;
138 + if (el.hasAttribute('data-hx-reset') && typeof form.reset === 'function') form.reset();
139 + if (el.hasAttribute('data-hx-click')) byId(el.getAttribute('data-hx-click'))?.click();
140 + if (el.hasAttribute('data-hx-get')) {
141 + const url = el.getAttribute('data-hx-get');
142 + const target = el.getAttribute('data-hx-target');
143 + if (url && target) htmx.ajax('GET', url, target);
144 + }
145 + if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav') ?? '';
146 + if (el.hasAttribute('data-hx-reload')) window.location.reload();
147 + if (el.hasAttribute('data-hx-toast')) {
148 + showToast(el.getAttribute('data-hx-toast') ?? '', el.getAttribute('data-hx-toast-type') || 'info');
149 + }
150 + const beh = el.getAttribute('data-hx-behavior');
151 + if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt);
152 + });
153 +
154 + document.body.addEventListener('htmx:configRequest', (e) => {
155 + const evt = e as CustomEvent<HxDetail>;
156 + const el = evt.detail.elt;
157 + if (el?.getAttribute?.('data-hx-config') === 'publish-at-iso') {
158 + const params = evt.detail.parameters;
159 + const v = params?.publish_at;
160 + if (v && params) params.publish_at = new Date(v as string).toISOString();
161 + }
162 + });
163 + }
@@ -0,0 +1,22 @@
1 + import { test } from 'node:test';
2 + import assert from 'node:assert/strict';
3 + import { escapeHtml, escapeAttr } from './dom.ts';
4 +
5 + test('escapeHtml escapes all five HTML-special characters', () => {
6 + assert.equal(escapeHtml(`<a href="x" title='y'>&`), '&lt;a href=&quot;x&quot; title=&#39;y&#39;&gt;&amp;');
7 + });
8 +
9 + test('escapeHtml is ampersand-first (no double-escaping of entities it emits)', () => {
10 + // & must be replaced before <,>," so "&lt;" is not re-escaped into "&amp;lt;".
11 + assert.equal(escapeHtml('<'), '&lt;');
12 + assert.equal(escapeHtml('&lt;'), '&amp;lt;');
13 + });
14 +
15 + test('escapeHtml coerces non-strings', () => {
16 + assert.equal(escapeHtml(42), '42');
17 + assert.equal(escapeHtml(null), 'null');
18 + });
19 +
20 + test('escapeAttr is the same escaper', () => {
21 + assert.equal(escapeAttr, escapeHtml);
22 + });
@@ -0,0 +1,36 @@
1 + // DOM primitives ported from static/mnw.js.
2 +
3 + /**
4 + * Escape all five HTML-special characters. Safe for both element-content and
5 + * attribute interpolation. Ported from mnw.js:19 (the Run 20 single escaper);
6 + * the one canonical escaper — feature code must not ship its own.
7 + */
8 + export function escapeHtml(s: unknown): string {
9 + return String(s)
10 + .replace(/&/g, '&amp;')
11 + .replace(/</g, '&lt;')
12 + .replace(/>/g, '&gt;')
13 + .replace(/"/g, '&quot;')
14 + .replace(/'/g, '&#39;');
15 + }
16 +
17 + /** Attribute-context alias — identical five-char escape (escapeHtml is already
18 + * attribute-safe); named separately so call sites read intentfully. */
19 + export const escapeAttr = escapeHtml;
20 +
21 + /** Resolve `data-target` (space-separated element ids) to the elements that
22 + * currently exist. Ported from mnw.js:725. */
23 + export function targets(el: Element): HTMLElement[] {
24 + const raw = el.getAttribute('data-target');
25 + if (!raw) return [];
26 + return raw
27 + .split(/\s+/)
28 + .filter(Boolean)
29 + .map((id) => document.getElementById(id))
30 + .filter((e): e is HTMLElement => e !== null);
31 + }
32 +
33 + /** `document.getElementById` guarded against null/empty ids. */
34 + export function byId(id: string | null | undefined): HTMLElement | null {
35 + return id ? document.getElementById(id) : null;
36 + }
@@ -0,0 +1,18 @@
1 + // Ambient declarations for the browser globals the core lib leans on.
2 +
3 + export {};
4 +
5 + /** The subset of the HTMX JS API the dispatcher uses. HTMX is loaded as a
6 + * classic vendored script (`static/htmx.min.js`), so it's a global. */
7 + interface HtmxApi {
8 + ajax(method: string, url: string, target: string | { target: Element; swap?: string }): Promise<void>;
9 + trigger(el: Element, name: string, detail?: unknown): void;
10 + closest(el: Element, selector: string): Element | null;
11 + }
12 +
13 + declare global {
14 + const htmx: HtmxApi;
15 + interface Window {
16 + htmx: HtmxApi;
17 + }
18 + }
@@ -0,0 +1,162 @@
1 + // HTMX lifecycle glue, ported from mnw.js: CSRF header injection (25),
2 + // response-error toast (281), loading buttons (350), success flash (471), and
3 + // the plain (non-HTMX) form-submit loading + bfcache reset (431).
4 +
5 + import { resolveHtmxLoadingButton } from './loading.ts';
6 + import { showToast } from './toast.ts';
7 +
8 + interface HxDetail {
9 + elt: HTMLElement;
10 + successful?: boolean;
11 + xhr?: XMLHttpRequest;
12 + }
13 +
14 + function restoreLoadingButton(e: Event): void {
15 + const btn = resolveHtmxLoadingButton((e as CustomEvent<HxDetail>).detail.elt);
16 + if (btn && btn.dataset.origText) {
17 + btn.textContent = btn.dataset.origText;
18 + btn.disabled = false;
19 + delete btn.dataset.origText;
20 + }
21 + }
22 +
23 + /** Install all delegated HTMX lifecycle listeners. Call once at startup. */
24 + export function initHtmxGlue(): void {
25 + const body = document.body;
26 +
27 + // Attach the CSRF token live on every request — it rotates mid-session, so a
28 + // snapshot would go stale and 403 every mutation.
29 + body.addEventListener('htmx:configRequest', (e) => {
30 + const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
31 + if (token) (e as CustomEvent<{ headers: Record<string, string> }>).detail.headers['X-CSRF-Token'] = token;
32 + });
33 +
34 + // Error toast: prefer the HX-Error header (present on page routes too), then
35 + // a JSON {error}, then a generic string. Never render raw JSON.
36 + body.addEventListener('htmx:responseError', (e) => {
37 + const evt = e as CustomEvent<HxDetail>;
38 + const container = document.getElementById('notifications');
39 + if (!container) return;
40 + const toast = document.createElement('div');
41 + toast.className = 'toast toast-error';
42 + const msg = document.createElement('span');
43 + let text = 'An error occurred.';
44 + const xhr = evt.detail && evt.detail.xhr;
45 + const headerMsg = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Error');
46 + if (headerMsg) {
47 + text = headerMsg;
48 + } else {
49 + const bodyText = xhr && xhr.responseText;
50 + if (bodyText) {
51 + try {
52 + const parsed = JSON.parse(bodyText) as { error?: unknown };
53 + if (parsed && typeof parsed.error === 'string' && parsed.error) text = parsed.error;
54 + } catch {
55 + /* non-JSON body (e.g. an HTML error page); keep the fallback */
56 + }
57 + }
58 + }
59 + msg.textContent = text;
60 + toast.appendChild(msg);
61 +
62 + const retryBtn = document.createElement('button');
63 + retryBtn.textContent = 'Retry';
64 + retryBtn.className = 'toast-retry-btn';
65 + retryBtn.onclick = () => {
66 + toast.remove();
67 + const elt = evt.detail.elt;
68 + if (elt) htmx.trigger(elt, htmx.closest(elt, '[hx-trigger]') ? 'htmx:trigger' : 'click');
69 + };
70 + toast.appendChild(retryBtn);
71 +
72 + const closeBtn = document.createElement('button');
73 + closeBtn.className = 'toast-dismiss';
74 + closeBtn.textContent = '×';
75 + closeBtn.setAttribute('aria-label', 'Dismiss');
76 + closeBtn.onclick = () => toast.remove();
77 + toast.appendChild(closeBtn);
78 +
79 + container.appendChild(toast);
80 + setTimeout(() => {
81 + toast.classList.add('fade-out');
82 + setTimeout(() => toast.remove(), 300);
83 + }, 6000);
84 + });
85 +
86 + // Loading button on request start; restore on every terminal event.
87 + body.addEventListener('htmx:beforeRequest', (e) => {
88 + const btn = resolveHtmxLoadingButton((e as CustomEvent<HxDetail>).detail.elt);
89 + if (btn && !btn.dataset.origText) {
90 + btn.dataset.origText = btn.textContent ?? '';
91 + btn.textContent = btn.dataset.loadingText || 'Saving...';
92 + btn.disabled = true;
93 + }
94 + });
95 + body.addEventListener('htmx:afterRequest', restoreLoadingButton);
96 + body.addEventListener('htmx:responseError', restoreLoadingButton);
97 + body.addEventListener('htmx:sendError', restoreLoadingButton);
98 + body.addEventListener('htmx:timeout', restoreLoadingButton);
99 +
100 + // Success flash: data-success-toast (for hx-swap="none") + data-success-text.
101 + body.addEventListener('htmx:afterRequest', (e) => {
102 + const evt = e as CustomEvent<HxDetail>;
103 + if (!evt.detail.successful) return;
104 + const elt = evt.detail.elt;
105 +
106 + const toastEl = elt && elt.closest && elt.closest<HTMLElement>('[data-success-toast]');
107 + if (toastEl) showToast(toastEl.dataset.successToast ?? '', 'info');
108 +
109 + const btn = resolveHtmxLoadingButton(elt);
110 + if (!btn || !btn.dataset.successText) return;
111 + const successText = btn.dataset.successText;
112 + const restoreTo = btn.dataset.origText || btn.textContent || '';
113 + btn.textContent = successText;
114 + btn.disabled = true;
115 + delete btn.dataset.origText;
116 + setTimeout(() => {
117 + if (btn.textContent === successText) {
118 + btn.textContent = restoreTo;
119 + btn.disabled = false;
120 + }
121 + }, 1200);
122 + });
123 +
124 + // Plain (non-HTMX) form submit: swap the label while the browser round-trips
125 + // (e.g. Stripe checkout). Opt in via data-loading-text on the submit button.
126 + body.addEventListener(
127 + 'submit',
128 + (evt) => {
129 + if (evt.defaultPrevented) return;
130 + const form = evt.target as HTMLFormElement | null;
131 + if (!form || form.tagName !== 'FORM') return;
132 + if (
133 + form.hasAttribute('hx-post') ||
134 + form.hasAttribute('hx-get') ||
135 + form.hasAttribute('hx-put') ||
136 + form.hasAttribute('hx-delete') ||
137 + form.hasAttribute('hx-patch')
138 + )
139 + return;
140 + const btn = form.querySelector<HTMLButtonElement>('[data-loading-text]');
141 + if (!btn || btn.dataset.origText) return;
142 + btn.dataset.origText = btn.textContent ?? '';
143 + btn.textContent = btn.dataset.loadingText ?? '';
144 + // Defer disabling so the button's name/value still enters the form body.
145 + setTimeout(() => {
146 + btn.disabled = true;
147 + }, 0);
148 + },
149 + true,
150 + );
151 +
152 + // bfcache restore: a back-nav can restore a button stuck in its "Redirecting…"
153 + // state; reset it so the page is usable again.
154 + window.addEventListener('pageshow', (evt) => {
155 + if (!(evt as PageTransitionEvent).persisted) return;
156 + document.querySelectorAll<HTMLButtonElement>('button[data-orig-text]').forEach((btn) => {
157 + btn.textContent = btn.dataset.origText ?? '';
158 + btn.disabled = false;
159 + delete btn.dataset.origText;
160 + });
161 + });
162 + }
@@ -0,0 +1,78 @@
1 + // MNW frontend core — entry point.
2 + //
3 + // Loaded once, globally, as a `<script type="module">` in place of the retired
4 + // `static/mnw.js`. It installs every delegated listener the old god-file did
5 + // (toasts, HTMX glue, the data-action + hx-callback dispatchers, copy-link,
6 + // keyboard, tabs, restart banner) and bridges the shared primitives onto
7 + // `window` so the not-yet-migrated `static/*.js` files keep resolving them.
8 + //
9 + // As feature files migrate into frontend/src, drop their entries from the
10 + // bridge and register their handlers via `dispatch.register()` instead of a
11 + // global; the `frontend_globals` seal ratchets the legacy count down.
12 +
13 + import { escapeHtml, escapeAttr } from './dom.ts';
14 + import { csrfHeaders, apiError } from './net.ts';
15 + import { showToast, initToasts } from './toast.ts';
16 + import { safeGet, safeSet } from './storage.ts';
17 + import { copyWithFeedback, initCopyLink } from './clipboard.ts';
18 + import { resolveHtmxLoadingButton, withLoadingState } from './loading.ts';
19 + import { initActionDispatcher, initHxCallbackDispatcher } from './dispatch.ts';
20 + import { setActiveTab, initTabs } from './tabs.ts';
21 + import { toggleShortcutsHelp, initKeyboard } from './keyboard.ts';
22 + import { initRestartBanner } from './restart-banner.ts';
23 + import { initHtmxGlue } from './htmx-glue.ts';
24 +
25 + // Common, lightweight islands are registered globally here (side-effect import
26 + // defines the custom element). Heavy or page-specific islands (media player,
27 + // uploader) load per-page instead. Custom elements auto-upgrade on connect, so
28 + // carousels in HTMX-swapped content wire themselves with no extra glue.
29 + import '../islands/carousel.ts';
30 + // Image uploader appears in HTMX-swapped wizard steps, so it's registered
31 + // globally (auto-upgrades on swap). The heavier upload flows (item audio/version
32 + // queue, gallery, bundle) still use the legacy static/upload.js until migrated.
33 + import '../islands/uploader/image-uploader.ts';
34 +
35 + /**
36 + * Legacy bridge — the not-yet-migrated `static/*.js` files call these by their
37 + * historical global names. `mnw.js` exposed them as classic-script globals
38 + * (function declarations / `window.*`); the module re-exposes them so the
39 + * cutover is behavior-preserving. Retire each entry as its consumers move into
40 + * frontend/src.
41 + */
42 + function installLegacyBridge(): void {
43 + const w = window as unknown as Record<string, unknown>;
44 + w.escapeHtml = escapeHtml;
45 + w.escapeAttr = escapeAttr;
46 + w.csrfHeaders = csrfHeaders;
47 + w.showToast = showToast;
48 + w.safeStorageGet = safeGet;
49 + w.safeStorageSet = safeSet;
50 + w.setActiveTab = setActiveTab;
51 + w.toggleShortcutsHelp = toggleShortcutsHelp;
52 + w.copyWithFeedback = copyWithFeedback;
53 + w.resolveHtmxLoadingButton = resolveHtmxLoadingButton;
54 + w.withLoadingState = withLoadingState;
55 + // Historical name; wraps apiError so the (response, fallback) signature and
56 + // the Promise<string> return match the old global exactly.
57 + w.apiErrorMessage = (response: Response | null | undefined, fallback?: string) => apiError(response, fallback);
58 + }
59 +
60 + function initCore(): void {
61 + installLegacyBridge();
62 + initToasts();
63 + initHtmxGlue();
64 + initActionDispatcher();
65 + initHxCallbackDispatcher();
66 + initCopyLink();
67 + initKeyboard();
68 + initTabs();
69 + initRestartBanner();
70 + }
71 +
72 + // A module script is deferred, so the DOM is already parsed by the time this
73 + // runs; the readyState guard is belt-and-suspenders.
74 + if (document.readyState === 'loading') {
75 + document.addEventListener('DOMContentLoaded', initCore);
76 + } else {
77 + initCore();
78 + }
@@ -0,0 +1,75 @@
1 + // Keyboard shortcuts, the shortcuts-help modal, and the mobile nav toggle,
2 + // ported from mnw.js:504-572.
3 +
4 + /** Toggle the keyboard-shortcuts help overlay. Ported from mnw.js:531. */
5 + export function toggleShortcutsHelp(): void {
6 + const existing = document.getElementById('shortcuts-help');
7 + if (existing) {
8 + existing.remove();
9 + return;
10 + }
11 +
12 + const overlay = document.createElement('div');
13 + overlay.id = 'shortcuts-help';
14 + overlay.className = 'modal-overlay';
15 + overlay.style.display = 'flex';
16 + overlay.onclick = (e) => {
17 + if (e.target === overlay) overlay.remove();
18 + };
19 + overlay.innerHTML =
20 + '<div class="modal-content" style="max-width: 420px; padding: 2rem;">' +
21 + '<div class="modal-header" style="margin-bottom: 1rem;">' +
22 + '<h2>Keyboard Shortcuts</h2>' +
23 + '<button type="button" class="modal-close" aria-label="Close">&times;</button>' +
24 + '</div>' +
25 + '<table style="width: 100%; font-size: 0.9rem;">' +
26 + '<tr><td style="padding: 0.3rem 0;"><kbd>Cmd+K</kbd></td><td>Search</td></tr>' +
27 + '<tr><td style="padding: 0.3rem 0;"><kbd>?</kbd></td><td>Show this help</td></tr>' +
28 + '<tr><td style="padding: 0.3rem 0;"><kbd>Esc</kbd></td><td>Close modal / overlay</td></tr>' +
29 + '<tr><td style="padding: 0.3rem 0;"><kbd>Cmd+S</kbd></td><td>Save current form</td></tr>' +
30 + '</table>' +
31 + '</div>';
32 +
33 + // Wire the close button via JS, not inline onclick — the CSP has no
34 + // script-src 'unsafe-inline' so a generated inline handler is dead.
35 + const closeBtn = overlay.querySelector<HTMLElement>('.modal-close');
36 + if (closeBtn) closeBtn.onclick = () => overlay.remove();
37 +
38 + document.body.appendChild(overlay);
39 + }
40 +
41 + /** Install global keyboard shortcuts + the nav toggle. Ported from mnw.js:504. */
42 + export function initKeyboard(): void {
43 + document.addEventListener('keydown', (e) => {
44 + const tag = document.activeElement?.tagName;
45 + const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
46 +
47 + if (e.key === 'Escape') {
48 + document.querySelector('.modal-overlay')?.remove();
49 + }
50 + if ((e.metaKey || e.ctrlKey) && e.key === 's') {
51 + e.preventDefault();
52 + const form = document.activeElement?.closest('form');
53 + form?.querySelector<HTMLElement>('button[type="submit"]')?.click();
54 + }
55 + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
56 + e.preventDefault();
57 + const searchInput = document.getElementById('header-search-input') as HTMLInputElement | null;
58 + if (searchInput) {
59 + searchInput.focus();
60 + searchInput.select();
61 + }
62 + }
63 + if (e.key === '?' && !inInput && !e.metaKey && !e.ctrlKey) {
64 + e.preventDefault();
65 + toggleShortcutsHelp();
66 + }
67 + });
68 +
69 + document.addEventListener('click', (e) => {
70 + const toggle = document.getElementById('nav-toggle') as HTMLInputElement | null;
71 + if (toggle && toggle.checked && (e.target as Element | null)?.closest('.nav-links a, .nav-links .btn--link')) {
72 + toggle.checked = false;
73 + }
74 + });
75 + }
@@ -0,0 +1,37 @@
1 + // Button loading-state helpers ported from mnw.js:343,407. The delegated HTMX
2 + // loading listeners (beforeRequest/afterRequest/etc.) live in htmx-glue (Phase-1
3 + // cutover); these two are the reusable pieces feature code calls directly.
4 +
5 + /**
6 + * Resolve the button that should reflect loading state for an interaction.
7 + * Preference: the element itself if it's a `<button>`, else the submit/primary
8 + * button in the closest form. Ported from mnw.js:343.
9 + */
10 + export function resolveHtmxLoadingButton(elt: Element | null | undefined): HTMLButtonElement | null {
11 + if (elt && elt.tagName === 'BUTTON') return elt as HTMLButtonElement;
12 + const form = elt?.closest?.('form');
13 + return form ? form.querySelector<HTMLButtonElement>('button[type="submit"], .primary') : null;
14 + }
15 +
16 + /**
17 + * Wrap an async operation with button loading state for plain `fetch()` flows
18 + * (non-HTMX), restoring text + enabled state on completion regardless of
19 + * outcome. Ported from mnw.js:407.
20 + */
21 + export function withLoadingState<T>(
22 + btn: HTMLButtonElement | null,
23 + loadingText: string,
24 + fn: () => Promise<T> | T,
25 + ): Promise<T> {
26 + if (!btn) return Promise.resolve().then(fn);
27 + const origText = btn.textContent;
28 + const origDisabled = btn.disabled;
29 + btn.textContent = loadingText;
30 + btn.disabled = true;
31 + return Promise.resolve()
32 + .then(fn)
33 + .finally(() => {
34 + btn.textContent = origText;
35 + btn.disabled = origDisabled;
36 + });
37 + }
@@ -0,0 +1,27 @@
1 + import { test } from 'node:test';
2 + import assert from 'node:assert/strict';
3 + import { apiError } from './net.ts';
4 +
5 + // Minimal Response-shaped stubs; apiError only touches `.json`.
6 + const resp = (json: () => Promise<unknown>) => ({ json }) as unknown as Response;
7 +
8 + test('apiError returns the server error string when present', async () => {
9 + assert.equal(await apiError(resp(async () => ({ error: 'This promo code has expired' }))), 'This promo code has expired');
10 + });
11 +
12 + test('apiError falls back when the body has no error field', async () => {
13 + assert.equal(await apiError(resp(async () => ({ ok: true })), 'nope'), 'nope');
14 + });
15 +
16 + test('apiError falls back on a non-JSON / throwing body', async () => {
17 + assert.equal(await apiError(resp(async () => { throw new Error('not json'); })), 'Request failed');
18 + });
19 +
20 + test('apiError falls back on a null/!response', async () => {
21 + assert.equal(await apiError(null), 'Request failed');
22 + assert.equal(await apiError(undefined, 'x'), 'x');
23 + });
24 +
25 + test('apiError ignores a non-string error field', async () => {
26 + assert.equal(await apiError(resp(async () => ({ error: { nested: true } })), 'fb'), 'fb');
27 + });
@@ -0,0 +1,52 @@
1 + // Fetch + CSRF primitives ported from mnw.js:8,399. `postJson`/`getJson` are new
2 + // consolidations that replace the ~20 hand-rolled fetch+CSRF+error blocks across
3 + // the legacy feature files.
4 +
5 + /**
6 + * CSRF header for a manual `fetch()`. Read live on every call — the token
7 + * rotates mid-session (e.g. the 2FA cycle), so a snapshot would go stale and
8 + * 403. Ported from mnw.js:8.
9 + */
10 + export function csrfHeaders(): Record<string, string> {
11 + const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
12 + return token ? { 'X-CSRF-Token': token } : {};
13 + }
14 +
15 + /**
16 + * Read the server-supplied error message from a non-2xx fetch `Response`. API
17 + * routes return `{"error": "..."}` via the json-error middleware; use this so
18 + * users see the real reason instead of a generic "Failed". Ported from
19 + * mnw.js:399 (`apiErrorMessage`).
20 + */
21 + export async function apiError(
22 + response: Response | null | undefined,
23 + fallback = 'Request failed',
24 + ): Promise<string> {
25 + if (!response || typeof response.json !== 'function') return fallback;
26 + try {
27 + const d = (await response.json()) as { error?: unknown } | null;
28 + return d && typeof d.error === 'string' && d.error ? d.error : fallback;
29 + } catch {
30 + return fallback;
31 + }
32 + }
33 +
34 + /** POST JSON with CSRF; resolve the parsed body or reject with the server's
35 + * error message. Replaces the hand-rolled fetch+CSRF+error idiom. */
36 + export async function postJson<T>(url: string, body: unknown): Promise<T> {
37 + const res = await fetch(url, {
38 + method: 'POST',
39 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
40 + body: JSON.stringify(body),
41 + credentials: 'same-origin',
42 + });
43 + if (!res.ok) throw new Error(await apiError(res));
44 + return (await res.json()) as T;
45 + }
46 +
47 + /** GET JSON; resolve the parsed body or reject with the server's error message. */
48 + export async function getJson<T>(url: string): Promise<T> {
49 + const res = await fetch(url, { credentials: 'same-origin' });
50 + if (!res.ok) throw new Error(await apiError(res));
51 + return (await res.json()) as T;
52 + }
@@ -0,0 +1,77 @@
1 + // Deploy/restart warning banner, ported from mnw.js:578-652. Polls
2 + // /api/restart-status; shows a countdown banner when a restart is scheduled.
3 +
4 + /** Start polling for a scheduled restart and render the countdown banner. */
5 + export function initRestartBanner(): void {
6 + let banner: HTMLElement | null = null;
7 + let countdownInterval: ReturnType<typeof setInterval> | null = null;
8 + let restartAt: number | null = null;
9 +
10 + function createBanner(): void {
11 + if (banner) return;
12 + banner = document.createElement('div');
13 + banner.id = 'restart-banner';
14 + banner.className = 'banner banner--warning';
15 + banner.setAttribute('role', 'alert');
16 + document.body.prepend(banner);
17 + }
18 +
19 + function removeBanner(): void {
20 + if (banner) {
21 + banner.remove();
22 + banner = null;
23 + }
24 + if (countdownInterval) {
25 + clearInterval(countdownInterval);
26 + countdownInterval = null;
27 + }
28 + restartAt = null;
29 + }
30 +
31 + function updateCountdown(): void {
32 + if (!banner || restartAt === null) return;
33 + const remaining = Math.max(0, Math.round(restartAt - Date.now() / 1000));
34 + if (remaining > 0) {
35 + banner.textContent = 'Update deploying — restarting in ' + remaining + 's';
36 + } else {
37 + banner.textContent = 'Restarting now...';
38 + if (countdownInterval) {
39 + clearInterval(countdownInterval);
40 + countdownInterval = null;
41 + }
42 + }
43 + }
44 +
45 + function startCountdown(ts: number): void {
46 + restartAt = ts;
47 + createBanner();
48 + updateCountdown();
49 + if (countdownInterval) clearInterval(countdownInterval);
50 + countdownInterval = setInterval(updateCountdown, 1000);
51 + }
52 +
53 + function poll(): void {
54 + fetch('/api/restart-status')
55 + .then((r) => r.json())
56 + .then((data: { restart_at?: number }) => {
57 + if (data.restart_at) {
58 + if (restartAt === null || restartAt !== data.restart_at) startCountdown(data.restart_at);
59 + } else {
60 + removeBanner();
61 + }
62 + })
63 + .catch(() => {
64 + // Already counting down and the poll failed: assume it's happening now.
65 + if (restartAt !== null) {
66 + if (banner) banner.textContent = 'Restarting now...';
67 + if (countdownInterval) {
68 + clearInterval(countdownInterval);
69 + countdownInterval = null;
70 + }
71 + }
72 + });
73 + }
74 +
75 + setTimeout(poll, 2000);
76 + setInterval(poll, 10000);
77 + }
@@ -0,0 +1,14 @@
1 + import { test } from 'node:test';
2 + import assert from 'node:assert/strict';
3 + import { safeGet, safeSet } from './storage.ts';
4 +
5 + // In Node (no `localStorage` global) the wrappers must swallow the access error
6 + // rather than throw — the same guarantee they give in a storage-disabled
7 + // browser context.
8 + test('safeGet returns null when storage is unavailable', () => {
9 + assert.equal(safeGet('anything'), null);
10 + });
11 +
12 + test('safeSet does not throw when storage is unavailable', () => {
13 + assert.doesNotThrow(() => safeSet('k', 'v'));
14 + });
@@ -0,0 +1,19 @@
1 + // Safe localStorage wrappers ported from mnw.js:74. Storage access throws in
2 + // private-mode / disabled-storage contexts; these swallow it so callers never
3 + // have to guard.
4 +
5 + export function safeGet(key: string): string | null {
6 + try {
7 + return localStorage.getItem(key);
8 + } catch {
9 + return null;
10 + }
11 + }
12 +
13 + export function safeSet(key: string, value: string): void {
14 + try {
15 + localStorage.setItem(key, value);
16 + } catch {
17 + /* storage unavailable — ignore */
18 + }
19 + }