refactor(modals): consolidate modal.js + modal-router.js as inline/routed
Two modal pipelines coexisted after the URL-addressable pilot — modal.js
(inline, ~30 lines) and modal-router.js (routed, ~150 lines) — operating
on different attribute namespaces and exposing different APIs. Future
modal authors had two systems to learn with no naming convention to
help them pick the right one for a given use case.
Consolidates both into static/js/modals.js with two clearly-named
pipelines and a single window.modals.* API:
Inline modal — content pre-rendered in the page.
Hooks: data-inline-modal-open="<dialog-id>"
data-inline-modal-close
API: window.modals.openInline(idOrEl)
window.modals.closeInline(idOrEl)
Use: confirmations, transient prompts, in-page forms without
URL value.
Routed modal — content fetched from a URL, ?modal=<path> in URL,
with history + share-link + refresh-survival.
Hooks: <a data-routed-modal href="<path>">
data-routed-modal-dismiss
API: window.modals.openRouted(path)
window.modals.closeRouted()
Use: content with standalone-page meaning.
Single document-level click delegation handles all four attribute
hooks; one DOMContentLoaded handler binds dialog 'close' / 'cancel' /
backdrop on the routed slot; shared popstate and htmx:responseError
listeners. Behaviour unchanged — pure rename + colocation.
Renamed across 11 templates and files-overlay.js. Old data-modal-*
attributes and window.openModal/closeModal globals are gone — clean
break (no back-compat shims). AGENTS.md "Modals: inline vs routed"
section documents the decision guide for new modals.
Verified: 573 backend tests pass. 5/5 Chromium smoke checks pass
(inline open/close, Esc, backdrop, routed open+save, routed Esc).
Console clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
74fd906cf4
commit
c51089df1b
16 changed files with 289 additions and 234 deletions
32
AGENTS.md
32
AGENTS.md
|
|
@ -23,15 +23,33 @@ Do not invent architecture outside these plans unless explicitly requested.
|
|||
- Do not use git worktrees.
|
||||
- Repo is a uv workspace; Python is pinned to 3.13 via `.python-version`. After fresh checkout: install `uv` (`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh`), then `direnv allow` (or `uv sync` directly). See README **Local development** for details.
|
||||
|
||||
### URL-addressable modal templates
|
||||
### Modals: inline vs routed
|
||||
|
||||
A template that renders **both** as a full standalone page AND as a modal fragment (i.e. `{% extends base_layout %}`, where `base_layout` resolves to `_modal_partial.html` for modal-mode requests and `base.html` otherwise — driven by the `HX-Modal: 1` header in `app.py:inject_base_layout`) MUST follow these conventions:
|
||||
Two coexisting modal mechanisms, one module (`l4d2web/l4d2web/static/js/modals.js`). When adding a new modal, decide which pipeline it belongs to:
|
||||
|
||||
- **The outermost element of `{% block content %}` is a `<div>`, NOT a `<dialog>`.** The persistent `<dialog id="modal-container">` slot in `base.html` provides top-layer + backdrop + focus-trap + Esc-to-close semantics. Nested `<dialog>` elements collapse to 2 px in every browser — Task 8.5 of the modals pilot fixed this the hard way; do not re-introduce it. In standalone mode, the content renders flat under `<main>`, which is the intended "this URL is a real page, not a modal-over-nothing" UX.
|
||||
- **Close buttons use `data-modal-dismiss`** (NOT `data-modal-close` — that's the legacy inline-dialog system). `modal-router.js` listens at document level for this attribute and calls `dialog.close()` on the outer slot.
|
||||
- **Form-bearing content needs document-level event delegation** for submit/save/delete actions, gated on `event.target.closest("#modal-content")`. Direct binding to elements in the swapped-in fragment only works in standalone mode — HTMX-swapped content arrives as fresh DOM nodes with no listeners attached. See `files-overlay.js` lines ~599-641 for the canonical pattern (read `data-*` attributes from the textarea, NOT from JS state set during open).
|
||||
- **CSS classes targeting modal chrome are scoped to the outer slot** — `dialog.modal, div.modal` in `components.css`. The inner content div should NOT carry `class="modal modal-wide"` (that's what painted card-in-a-card during Task 8.5b; the outer dialog owns chrome).
|
||||
- **Reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` (design + verification matrix) and the plan errata at the top of `docs/superpowers/plans/2026-05-17-url-addressable-modals.md`.
|
||||
**Inline modal** — the dialog markup is pre-rendered into the page HTML. Content is whatever's already there; the JS just calls `showModal()` / `close()` on a specific `<dialog>` by id. Use when:
|
||||
- It's a confirmation (delete, overwrite, reset)
|
||||
- It's a transient prompt mid-flow (conflict resolution during upload)
|
||||
- It's a form whose URL state would be noise (rename, new-folder, new-server)
|
||||
- The content has no standalone-page equivalent
|
||||
|
||||
Hooks: `<button data-inline-modal-open="<dialog-id>">` opens; `<button data-inline-modal-close>` inside the dialog closes; Esc and backdrop click also close. Programmatic: `window.modals.openInline(idOrEl)` / `window.modals.closeInline(idOrEl)`.
|
||||
|
||||
**Routed modal** — content is server-rendered from a URL and lands in the persistent `<dialog id="modal-container">` slot. URL gains `?modal=<path>`, refresh + share + back/forward all work. Use when:
|
||||
- The content has standalone-page meaning (editor, detail view, settings panel)
|
||||
- "Share this view" or "refresh-stays-here" matters
|
||||
- The URL state earns its keep
|
||||
|
||||
Hooks: `<a data-routed-modal href="<path>">` opens (full-page nav fallback if JS fails); `<button data-routed-modal-dismiss>` inside the swapped content closes. Programmatic: `window.modals.openRouted(path)` / `window.modals.closeRouted()`.
|
||||
|
||||
**Conventions for routed-modal templates** (templates that `{% extends base_layout %}`, where `base_layout` resolves to `_modal_partial.html` for `HX-Modal: 1` requests and `base.html` otherwise — see `app.py:inject_base_layout`):
|
||||
|
||||
- **The outermost element of `{% block content %}` is a `<div>`, NOT a `<dialog>`.** The persistent slot in `base.html` already provides top-layer + backdrop + focus-trap + Esc-to-close semantics. Nested `<dialog>` collapses to 2 px in every browser.
|
||||
- **Close buttons use `data-routed-modal-dismiss`** (NOT the inline-modal attribute). `modals.js` delegates at document level.
|
||||
- **Form-bearing content needs document-level event delegation** for submit/save/delete, gated on `event.target.closest("#modal-content")`. Direct binding to elements in the swapped-in fragment only works in standalone mode — HTMX-swapped content arrives as fresh DOM nodes with no listeners attached. See `files-overlay.js` lines ~599-664 for the canonical pattern (read `data-*` attributes from the swapped DOM, NOT from JS state set during open).
|
||||
- **CSS classes targeting modal chrome are scoped to the outer slot** — `dialog.modal, div.modal` in `components.css`. The inner content div should NOT carry `class="modal modal-wide"` (the outer dialog owns chrome; otherwise both paint card-in-a-card).
|
||||
|
||||
**Reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` (design + verification matrix) and the plan errata at the top of `docs/superpowers/plans/2026-05-17-url-addressable-modals.md`.
|
||||
|
||||
### Dev server and filesystem paths
|
||||
|
||||
|
|
|
|||
|
|
@ -629,7 +629,7 @@
|
|||
|
||||
const r = await postJson(`${baseUrl}/files/save`, payload);
|
||||
if (r.ok) {
|
||||
if (typeof window.closeModal === "function") window.closeModal();
|
||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
||||
scheduleRefresh(parentOf(newPath || relPath));
|
||||
} else if (r.status === 409) {
|
||||
// Conflict (destination already exists) — show error and keep modal
|
||||
|
|
@ -654,7 +654,7 @@
|
|||
fd.append("csrf_token", csrfToken);
|
||||
const r = await postForm(`${baseUrl}/files/delete`, fd);
|
||||
if (r.ok) {
|
||||
if (typeof window.closeModal === "function") window.closeModal();
|
||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
||||
scheduleRefresh(parentOf(relPath));
|
||||
} else {
|
||||
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
|
||||
|
|
@ -1071,10 +1071,10 @@
|
|||
if (editable) {
|
||||
// Editable text files: open via URL-addressable modal.
|
||||
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
|
||||
if (typeof window.openModal === "function") {
|
||||
window.openModal(editUrl);
|
||||
if (typeof window.modals?.openRouted === "function") {
|
||||
window.modals.openRouted(editUrl);
|
||||
} else {
|
||||
// Graceful fallback if modal-router didn't load — full-page navigation
|
||||
// Graceful fallback if modals.js didn't load — full-page navigation
|
||||
// still hits the same route and renders the standalone editor page.
|
||||
window.location.href = editUrl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
// URL-addressable modal router (see spec 2026-05-17-url-addressable-modals).
|
||||
// Click intercept on a[data-modal] → ?modal=<path> in URL → htmx swap into
|
||||
// #modal-content → showModal(). Close/popstate/bootstrap in later tasks.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
let currentModalPath = null; // race-guard against stale swaps
|
||||
|
||||
function openModal(path) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", path);
|
||||
history.pushState({ modal: path }, "", url.toString());
|
||||
fetchAndShow(path);
|
||||
}
|
||||
|
||||
function fetchAndShow(path) {
|
||||
currentModalPath = path;
|
||||
if (typeof window.htmx === "undefined") {
|
||||
console.error("[modal-router] htmx not loaded; cannot fetch modal");
|
||||
return;
|
||||
}
|
||||
window.htmx.ajax("GET", path, {
|
||||
target: "#modal-content",
|
||||
swap: "innerHTML",
|
||||
headers: { "HX-Modal": "1" },
|
||||
}).then(() => {
|
||||
// Race guard: if the user clicked again during the fetch, abandon
|
||||
// this swap; the newer click will win.
|
||||
if (currentModalPath !== path) return;
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg && !dlg.open) dlg.showModal();
|
||||
}).catch((err) => {
|
||||
console.error("[modal-router] fetch failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg && dlg.open) dlg.close();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (!dlg) return;
|
||||
|
||||
// Single source of truth: every close path funnels through the dialog's
|
||||
// native 'close' event, so URL/state cleanup runs exactly once no matter
|
||||
// who triggered the close (Esc, backdrop, dismiss button, response error,
|
||||
// or the legacy modal.js backdrop handler).
|
||||
dlg.addEventListener("close", () => {
|
||||
currentModalPath = null;
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("modal")) {
|
||||
url.searchParams.delete("modal");
|
||||
history.pushState({}, "", url.toString());
|
||||
}
|
||||
// Tear down any CM6 controllers attached to swapped-in editor textareas
|
||||
// so close/reopen cycles don't leak EditorView instances and matchMedia
|
||||
// listeners. The bundle exposes controller.destroy() on the controller
|
||||
// stored at textarea.__editorController.
|
||||
const slot = document.getElementById("modal-content");
|
||||
if (slot) {
|
||||
for (const ta of slot.querySelectorAll("textarea[data-editor-language]")) {
|
||||
const ctrl = ta.__editorController;
|
||||
if (ctrl && typeof ctrl.destroy === "function") {
|
||||
ctrl.destroy();
|
||||
}
|
||||
ta.__editorController = null;
|
||||
}
|
||||
slot.innerHTML = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Esc key fires 'cancel' on a <dialog>; preventDefault so we control the
|
||||
// close path (the default action would close() too, but we want one path).
|
||||
dlg.addEventListener("cancel", (event) => {
|
||||
event.preventDefault();
|
||||
dlg.close();
|
||||
});
|
||||
|
||||
// Backdrop click on our own slot. The legacy modal.js handler also
|
||||
// matches dialog.modal and closes; this listener is harmless when the
|
||||
// legacy one wins because dialog.close() is idempotent.
|
||||
dlg.addEventListener("click", (event) => {
|
||||
if (event.target === dlg) dlg.close();
|
||||
});
|
||||
|
||||
// Bootstrap: if the page loaded with ?modal=<path> already in the URL
|
||||
// (refresh, share-link, or browser-history forward), open that modal.
|
||||
const initialPath = new URL(window.location.href).searchParams.get("modal");
|
||||
if (initialPath) {
|
||||
fetchAndShow(initialPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Browser back/forward: re-evaluate URL state and either fetch+show a new
|
||||
// modal or close the current one. Closing routes through dialog.close() so
|
||||
// the close-event cleanup fires (no separate state update here).
|
||||
window.addEventListener("popstate", () => {
|
||||
const path = new URL(window.location.href).searchParams.get("modal");
|
||||
if (path) {
|
||||
fetchAndShow(path);
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("a[data-modal]");
|
||||
if (!link) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
if (event.button !== 0) return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) return;
|
||||
event.preventDefault();
|
||||
openModal(href);
|
||||
});
|
||||
|
||||
// Dismiss triggers inside modal content (e.g. the editor's × and Cancel
|
||||
// buttons render with data-modal-dismiss). Document-level delegation so
|
||||
// HTMX-swapped content gets the behavior without re-binding.
|
||||
document.addEventListener("click", (event) => {
|
||||
if (event.target.closest("[data-modal-dismiss]")) {
|
||||
event.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// HTMX response error (4xx/5xx from the modal fetch): close the modal so
|
||||
// the user isn't left with an error fragment open and a stale ?modal= URL.
|
||||
document.body.addEventListener("htmx:responseError", (event) => {
|
||||
const target = event.detail && event.detail.target;
|
||||
if (target && target.id === "modal-content") {
|
||||
console.warn("[modal-router] server returned error, closing modal", event.detail.xhr && event.detail.xhr.status);
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Public API — used by files-overlay.js to open the editor from row clicks
|
||||
// that aren't a literal <a data-modal> (existing event delegation).
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
})();
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
// Event delegation on document so partials swapped in via HTMX (or any
|
||||
// later DOM mutation) still get modal behaviour without re-binding. The
|
||||
// previous per-element wiring on DOMContentLoaded silently broke for
|
||||
// buttons that didn't exist at page load — e.g., the server-detail
|
||||
// Actions partial reloads its reset/delete triggers on every state
|
||||
// change, and only the very first ones were ever wired up.
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const opener = event.target.closest("[data-modal-open]");
|
||||
if (opener) {
|
||||
const dialog = document.getElementById(opener.getAttribute("data-modal-open"));
|
||||
if (dialog && typeof dialog.showModal === "function") {
|
||||
event.preventDefault();
|
||||
dialog.showModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const closer = event.target.closest("[data-modal-close]");
|
||||
if (closer) {
|
||||
const dialog = closer.closest("dialog.modal");
|
||||
if (dialog) {
|
||||
event.preventDefault();
|
||||
dialog.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backdrop click: target IS the dialog (clicks on inner content
|
||||
// don't bubble up as the dialog itself).
|
||||
if (event.target.matches && event.target.matches("dialog.modal")) {
|
||||
event.target.close();
|
||||
}
|
||||
});
|
||||
215
l4d2web/l4d2web/static/js/modals.js
Normal file
215
l4d2web/l4d2web/static/js/modals.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// Two coexisting modal mechanisms, one module. See AGENTS.md
|
||||
// "URL-addressable modal templates" for the convention that decides which
|
||||
// one a new modal should use.
|
||||
//
|
||||
// Inline modal — content is pre-rendered into the page HTML.
|
||||
// Open via [data-inline-modal-open="<dialog-id>"] or
|
||||
// window.modals.openInline(<id-or-element>).
|
||||
// Close via [data-inline-modal-close] inside the dialog, Esc, or
|
||||
// a backdrop click.
|
||||
//
|
||||
// Routed modal — content is fetched on demand from a URL and lands in
|
||||
// the persistent <dialog id="modal-container"> slot. URL state via
|
||||
// ?modal=<path>, with browser history integration so refresh and
|
||||
// share-link both reopen the modal.
|
||||
// Open via <a data-routed-modal href="<path>"> or
|
||||
// window.modals.openRouted(<path>).
|
||||
// Close via [data-routed-modal-dismiss], Esc, backdrop, browser
|
||||
// back, programmatic window.modals.closeRouted(), or a 4xx/5xx
|
||||
// response from the modal fetch.
|
||||
//
|
||||
// Both pipelines share the same close-event semantics: dialog.close()
|
||||
// is the single mutation point. All sources of "close" funnel through
|
||||
// it so URL/state cleanup and CM6 controller teardown run exactly once.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ---------------------------------------------------------------- inline
|
||||
// Event delegation at document level so partials swapped in via HTMX (or
|
||||
// any later DOM mutation) still get modal behaviour without re-binding.
|
||||
|
||||
function openInline(idOrEl) {
|
||||
const dialog = typeof idOrEl === "string" ? document.getElementById(idOrEl) : idOrEl;
|
||||
if (dialog && typeof dialog.showModal === "function" && !dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
function closeInline(dialogOrEl) {
|
||||
const dialog = (dialogOrEl && dialogOrEl.tagName === "DIALOG")
|
||||
? dialogOrEl
|
||||
: (dialogOrEl && dialogOrEl.closest && dialogOrEl.closest("dialog.modal")) || null;
|
||||
if (dialog && dialog.open) dialog.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- routed
|
||||
// Click intercept on a[data-routed-modal] → ?modal=<path> in URL → htmx
|
||||
// swap into #modal-content → showModal() on the outer slot.
|
||||
|
||||
let currentRoutedPath = null; // race-guard against stale swaps
|
||||
|
||||
function openRouted(path) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", path);
|
||||
history.pushState({ modal: path }, "", url.toString());
|
||||
fetchAndShowRouted(path);
|
||||
}
|
||||
|
||||
function fetchAndShowRouted(path) {
|
||||
currentRoutedPath = path;
|
||||
if (typeof window.htmx === "undefined") {
|
||||
console.error("[modals] htmx not loaded; cannot fetch routed modal");
|
||||
return;
|
||||
}
|
||||
window.htmx.ajax("GET", path, {
|
||||
target: "#modal-content",
|
||||
swap: "innerHTML",
|
||||
headers: { "HX-Modal": "1" },
|
||||
}).then(() => {
|
||||
// Race guard: if the user clicked again during the fetch, abandon
|
||||
// this swap; the newer click will win.
|
||||
if (currentRoutedPath !== path) return;
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg && !dlg.open) dlg.showModal();
|
||||
}).catch((err) => {
|
||||
console.error("[modals] routed fetch failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function closeRouted() {
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg && dlg.open) dlg.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shared
|
||||
// Document-level click delegation handles all four attribute hooks in one
|
||||
// pass. Order matters: dismiss/close checks first (more specific), then
|
||||
// open triggers, then the generic backdrop-on-any-modal fallback.
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
// Inline open
|
||||
const inlineOpener = event.target.closest("[data-inline-modal-open]");
|
||||
if (inlineOpener) {
|
||||
const id = inlineOpener.getAttribute("data-inline-modal-open");
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog) {
|
||||
event.preventDefault();
|
||||
openInline(dialog);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Inline close
|
||||
const inlineCloser = event.target.closest("[data-inline-modal-close]");
|
||||
if (inlineCloser) {
|
||||
event.preventDefault();
|
||||
closeInline(inlineCloser);
|
||||
return;
|
||||
}
|
||||
|
||||
// Routed open — links only (preserves Cmd/Ctrl-click for new-tab and
|
||||
// works without JS as a full-page navigation fallback).
|
||||
const routedLink = event.target.closest("a[data-routed-modal]");
|
||||
if (routedLink && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && event.button === 0) {
|
||||
const href = routedLink.getAttribute("href");
|
||||
if (href) {
|
||||
event.preventDefault();
|
||||
openRouted(href);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Routed dismiss
|
||||
if (event.target.closest("[data-routed-modal-dismiss]")) {
|
||||
event.preventDefault();
|
||||
closeRouted();
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic backdrop close — catches inline dialogs whose backdrop is
|
||||
// clicked. The routed slot binds its own backdrop listener (below)
|
||||
// because it also needs to coordinate with its 'close' state cleanup.
|
||||
if (event.target.matches && event.target.matches("dialog.modal") && event.target.id !== "modal-container") {
|
||||
event.target.close();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const slot = document.getElementById("modal-container");
|
||||
if (!slot) return;
|
||||
|
||||
// Single source of truth for the routed modal: every close path funnels
|
||||
// through the dialog's native 'close' event, so URL/state cleanup and
|
||||
// CM6 controller teardown run exactly once regardless of who triggered
|
||||
// the close (Esc, backdrop, dismiss button, response error, programmatic).
|
||||
slot.addEventListener("close", () => {
|
||||
currentRoutedPath = null;
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("modal")) {
|
||||
url.searchParams.delete("modal");
|
||||
history.pushState({}, "", url.toString());
|
||||
}
|
||||
// Tear down any CM6 controllers attached to swapped-in editor textareas
|
||||
// so close/reopen cycles don't leak EditorView instances and matchMedia
|
||||
// listeners. The bundle exposes controller.destroy() on the controller
|
||||
// stored at textarea.__editorController.
|
||||
const content = document.getElementById("modal-content");
|
||||
if (content) {
|
||||
for (const ta of content.querySelectorAll("textarea[data-editor-language]")) {
|
||||
const ctrl = ta.__editorController;
|
||||
if (ctrl && typeof ctrl.destroy === "function") {
|
||||
ctrl.destroy();
|
||||
}
|
||||
ta.__editorController = null;
|
||||
}
|
||||
content.innerHTML = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Esc fires 'cancel' on a <dialog>. preventDefault, then close() so a
|
||||
// single path drives the URL sync.
|
||||
slot.addEventListener("cancel", (event) => {
|
||||
event.preventDefault();
|
||||
slot.close();
|
||||
});
|
||||
|
||||
// Backdrop click on the routed slot specifically.
|
||||
slot.addEventListener("click", (event) => {
|
||||
if (event.target === slot) slot.close();
|
||||
});
|
||||
|
||||
// Bootstrap: if the page loaded with ?modal=<path> already in the URL
|
||||
// (refresh or share-link landing), open that modal. Back/forward within
|
||||
// an already-loaded session is handled by the popstate listener below.
|
||||
const initialPath = new URL(window.location.href).searchParams.get("modal");
|
||||
if (initialPath) {
|
||||
fetchAndShowRouted(initialPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Browser back/forward: re-evaluate URL state and either re-fetch a new
|
||||
// modal or close the current one. The close path routes through close()
|
||||
// so the 'close'-event cleanup fires (no separate state mutation here).
|
||||
window.addEventListener("popstate", () => {
|
||||
const path = new URL(window.location.href).searchParams.get("modal");
|
||||
if (path) {
|
||||
fetchAndShowRouted(path);
|
||||
} else {
|
||||
closeRouted();
|
||||
}
|
||||
});
|
||||
|
||||
// HTMX response error (4xx/5xx on the modal fetch): close the routed modal
|
||||
// so the user isn't stranded with an error fragment and a stale ?modal= URL.
|
||||
document.body.addEventListener("htmx:responseError", (event) => {
|
||||
const target = event.detail && event.detail.target;
|
||||
if (target && target.id === "modal-content") {
|
||||
const status = event.detail.xhr && event.detail.xhr.status;
|
||||
console.warn("[modals] server returned error, closing routed modal", status);
|
||||
closeRouted();
|
||||
}
|
||||
});
|
||||
|
||||
// Public API
|
||||
window.modals = { openInline, closeInline, openRouted, closeRouted };
|
||||
})();
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{# Modal-fragment layout. Templates that extend `base_layout` render through
|
||||
{# Routed-modal layout. Templates that extend `base_layout` render through
|
||||
this when the request carries `HX-Modal: 1` (see `inject_base_layout` in
|
||||
app.py). The persistent <dialog id="modal-container"> in base.html provides
|
||||
top-layer + backdrop + focus-trap + Esc-to-close semantics. Templates that
|
||||
extend base_layout MUST NOT wrap their content in a <dialog> — nested
|
||||
<dialog> collapses to 2px. Use a <div> root and let the outer slot own
|
||||
dialog semantics. See AGENTS.md "URL-addressable modal templates" for the
|
||||
full convention. #}
|
||||
dialog semantics. See AGENTS.md "Modals: inline vs routed" for the full
|
||||
convention including hooks and JS API. #}
|
||||
{% block content %}{% endblock %}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
</form>
|
||||
{% endif %}
|
||||
{% if 'reset' in visible_buttons %}
|
||||
<button type="button" class="danger" data-modal-open="reset-server-modal">reset</button>
|
||||
<button type="button" class="danger" data-inline-modal-open="reset-server-modal">reset</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if drift %}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
<button type="submit" class="button-secondary">Activate</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="danger-outline" data-modal-open="delete-user-{{ user.id }}-modal">Delete</button>
|
||||
<button type="button" class="danger-outline" data-inline-modal-open="delete-user-{{ user.id }}-modal">Delete</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
<dialog id="delete-user-{{ user.id }}-modal" class="modal" aria-labelledby="delete-user-{{ user.id }}-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="delete-user-{{ user.id }}-title">Delete user "{{ user.username }}"?</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This cannot be undone. Refused if the user owns servers, blueprints,
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
<p>For a reversible block, prefer Deactivate.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<form method="post" action="/admin/users/{{ user.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@
|
|||
<script src="{{ url_for('static', filename='vendor/htmx.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/csrf.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/sse.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modal.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modal-router.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modals.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/password-reveal.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
|
||||
|
|
|
|||
|
|
@ -59,14 +59,14 @@
|
|||
</section>
|
||||
|
||||
<div class="page-footer-actions">
|
||||
<button type="button" class="danger-outline" data-modal-open="delete-blueprint-modal">Delete blueprint</button>
|
||||
<a href="#" class="link-button" data-modal-open="rename-blueprint-modal">Rename</a>
|
||||
<button type="button" class="danger-outline" data-inline-modal-open="delete-blueprint-modal">Delete blueprint</button>
|
||||
<a href="#" class="link-button" data-inline-modal-open="rename-blueprint-modal">Rename</a>
|
||||
</div>
|
||||
|
||||
<dialog id="rename-blueprint-modal" class="modal" aria-labelledby="rename-blueprint-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="rename-blueprint-title">Rename blueprint</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="/blueprints/{{ blueprint.id }}/rename" class="inline-save">
|
||||
|
|
@ -80,13 +80,13 @@
|
|||
<dialog id="delete-blueprint-modal" class="modal" aria-labelledby="delete-blueprint-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This cannot be undone. Blueprints in use by a server cannot be deleted.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Blueprints</h1>
|
||||
<button type="button" data-modal-open="create-blueprint-modal">+ Create</button>
|
||||
<button type="button" data-inline-modal-open="create-blueprint-modal">+ Create</button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<form method="post" action="/blueprints" class="stack">
|
||||
<div class="modal-header">
|
||||
<h2 id="create-blueprint-title">Create blueprint</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
<label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<button type="submit">Create blueprint</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -125,14 +125,14 @@
|
|||
|
||||
{% if can_edit %}
|
||||
<div class="page-footer-actions">
|
||||
<button type="button" class="danger-outline" data-modal-open="delete-overlay-modal">Delete overlay</button>
|
||||
<a href="#" class="link-button" data-modal-open="rename-overlay-modal">Rename</a>
|
||||
<button type="button" class="danger-outline" data-inline-modal-open="delete-overlay-modal">Delete overlay</button>
|
||||
<a href="#" class="link-button" data-inline-modal-open="rename-overlay-modal">Rename</a>
|
||||
</div>
|
||||
|
||||
<dialog id="rename-overlay-modal" class="modal" aria-labelledby="rename-overlay-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="rename-overlay-title">Rename overlay</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-save">
|
||||
|
|
@ -146,13 +146,13 @@
|
|||
<dialog id="delete-overlay-modal" class="modal" aria-labelledby="delete-overlay-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This cannot be undone. Overlays in use by a blueprint cannot be deleted.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
|
|
@ -165,7 +165,7 @@
|
|||
<dialog id="files-editor-modal" class="modal modal-wide" aria-labelledby="files-editor-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="files-editor-title" class="files-editor-path"><span class="files-editor-title-text">…</span></h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="files-editor-field">
|
||||
|
|
@ -219,7 +219,7 @@
|
|||
<button type="button" class="danger-outline files-editor-delete">Delete</button>
|
||||
<span class="files-editor-footer-spacer"></span>
|
||||
<a class="button-secondary files-editor-download" href="#" hidden>⬇ Download</a>
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="files-editor-save">Save</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
@ -227,7 +227,7 @@
|
|||
<dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target">…</code></h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="files-editor-field">
|
||||
|
|
@ -238,7 +238,7 @@
|
|||
<p class="files-new-folder-error" hidden></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="files-new-folder-create">Create</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
@ -246,14 +246,14 @@
|
|||
<dialog id="files-conflict-modal" class="modal" aria-labelledby="files-conflict-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="files-conflict-title">File already exists</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>A file already exists at <code class="files-conflict-path">…</code>.</p>
|
||||
<p class="muted">Choose how to handle this upload.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close data-files-conflict-action="cancel">Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close data-files-conflict-action="cancel">Cancel</button>
|
||||
<button type="button" class="button-secondary" data-files-conflict-action="keep-both">Keep both</button>
|
||||
<button type="button" data-files-conflict-action="overwrite">Overwrite</button>
|
||||
</div>
|
||||
|
|
@ -262,14 +262,14 @@
|
|||
<dialog id="files-delete-modal" class="modal" aria-labelledby="files-delete-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="files-delete-title">Delete <span class="files-delete-name">…</span>?</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This cannot be undone.</p>
|
||||
<p class="files-delete-error muted" hidden></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="danger files-delete-confirm">Delete</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<h2 id="files-editor-title" class="files-editor-path">
|
||||
<span class="files-editor-title-text">{{ rel_path }}</span>
|
||||
</h2>
|
||||
<button type="button" class="modal-close" data-modal-dismiss aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-routed-modal-dismiss aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="files-editor-field">
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
<button type="button" class="danger-outline files-editor-delete">Delete</button>
|
||||
<span class="files-editor-footer-spacer"></span>
|
||||
<a class="button-secondary files-editor-download" href="/overlays/{{ overlay.id }}/files/download?path={{ rel_path|urlencode }}">⬇ Download</a>
|
||||
<button type="button" class="button-secondary" data-modal-dismiss>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-routed-modal-dismiss>Cancel</button>
|
||||
<button type="button" class="files-editor-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Overlays</h1>
|
||||
<button type="button" data-modal-open="create-overlay-modal">+ Create</button>
|
||||
<button type="button" data-inline-modal-open="create-overlay-modal">+ Create</button>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<form method="post" action="/overlays" class="stack">
|
||||
<div class="modal-header">
|
||||
<h2 id="create-overlay-title">Create overlay</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
<p class="muted">The path is generated automatically.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<button type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -77,14 +77,14 @@
|
|||
</section>
|
||||
|
||||
<div class="page-footer-actions">
|
||||
<button type="button" class="danger-outline" data-modal-open="delete-server-modal">Delete server</button>
|
||||
<a href="#" class="link-button" data-modal-open="rename-server-modal">Rename</a>
|
||||
<button type="button" class="danger-outline" data-inline-modal-open="delete-server-modal">Delete server</button>
|
||||
<a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a>
|
||||
</div>
|
||||
|
||||
<dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="rename-server-title">Rename server</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
|
||||
|
|
@ -98,13 +98,13 @@
|
|||
<dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This stops the server and wipes its runtime state (logs, caches, accumulated game state). The blueprint association is preserved; the next start rebuilds from the current blueprint.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<form method="post" action="/servers/{{ server.id }}/reset" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">Reset</button>
|
||||
|
|
@ -115,13 +115,13 @@
|
|||
<dialog id="delete-server-modal" class="modal" aria-labelledby="delete-server-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="delete-server-title">Delete server "{{ server.name }}"?</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This stops the server and tears down its runtime files. This cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button class="danger" type="submit">Delete</button>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="page-heading">
|
||||
<h1>Servers</h1>
|
||||
{% if blueprints %}
|
||||
<button type="button" data-modal-open="create-server-modal">+ Create</button>
|
||||
<button type="button" data-inline-modal-open="create-server-modal">+ Create</button>
|
||||
{% else %}
|
||||
<a class="button" href="/blueprints">Create a blueprint first →</a>
|
||||
{% endif %}
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
<form method="post" action="/servers" class="stack">
|
||||
<div class="modal-header">
|
||||
<h2 id="create-server-title">Create server</h2>
|
||||
<button type="button" class="modal-close" data-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
|
||||
<button type="submit">Create server</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
Loading…
Reference in a new issue