max / makenotwork
58 files changed,
+2279 insertions,
-1732 deletions
| @@ -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/ |
| @@ -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'>&`), '<a href="x" title='y'>&'); | |
| 7 | + | }); | |
| 8 | + | ||
| 9 | + | test('escapeHtml is ampersand-first (no double-escaping of entities it emits)', () => { | |
| 10 | + | // & must be replaced before <,>," so "<" is not re-escaped into "&lt;". | |
| 11 | + | assert.equal(escapeHtml('<'), '<'); | |
| 12 | + | assert.equal(escapeHtml('<'), '&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, '&') | |
| 11 | + | .replace(/</g, '<') | |
| 12 | + | .replace(/>/g, '>') | |
| 13 | + | .replace(/"/g, '"') | |
| 14 | + | .replace(/'/g, '''); | |
| 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">×</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 | + | } |