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:
mwiegand 2026-05-17 14:31:38 +02:00
parent 74fd906cf4
commit c51089df1b
No known key found for this signature in database
16 changed files with 289 additions and 234 deletions

View file

@ -23,15 +23,33 @@ Do not invent architecture outside these plans unless explicitly requested.
- Do not use git worktrees. - 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. - 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. **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:
- **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. - It's a confirmation (delete, overwrite, reset)
- **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). - It's a transient prompt mid-flow (conflict resolution during upload)
- **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). - It's a form whose URL state would be noise (rename, new-folder, new-server)
- **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`. - 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 ### Dev server and filesystem paths

View file

@ -629,7 +629,7 @@
const r = await postJson(`${baseUrl}/files/save`, payload); const r = await postJson(`${baseUrl}/files/save`, payload);
if (r.ok) { if (r.ok) {
if (typeof window.closeModal === "function") window.closeModal(); if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(newPath || relPath)); scheduleRefresh(parentOf(newPath || relPath));
} else if (r.status === 409) { } else if (r.status === 409) {
// Conflict (destination already exists) — show error and keep modal // Conflict (destination already exists) — show error and keep modal
@ -654,7 +654,7 @@
fd.append("csrf_token", csrfToken); fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd); const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) { if (r.ok) {
if (typeof window.closeModal === "function") window.closeModal(); if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(relPath)); scheduleRefresh(parentOf(relPath));
} else { } else {
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`); alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
@ -1071,10 +1071,10 @@
if (editable) { if (editable) {
// Editable text files: open via URL-addressable modal. // Editable text files: open via URL-addressable modal.
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`; const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
if (typeof window.openModal === "function") { if (typeof window.modals?.openRouted === "function") {
window.openModal(editUrl); window.modals.openRouted(editUrl);
} else { } 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. // still hits the same route and renders the standalone editor page.
window.location.href = editUrl; window.location.href = editUrl;
} }

View file

@ -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;
})();

View file

@ -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();
}
});

View 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 };
})();

View file

@ -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 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 app.py). The persistent <dialog id="modal-container"> in base.html provides
top-layer + backdrop + focus-trap + Esc-to-close semantics. Templates that top-layer + backdrop + focus-trap + Esc-to-close semantics. Templates that
extend base_layout MUST NOT wrap their content in a <dialog> — nested 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> collapses to 2px. Use a <div> root and let the outer slot own
dialog semantics. See AGENTS.md "URL-addressable modal templates" for the dialog semantics. See AGENTS.md "Modals: inline vs routed" for the full
full convention. #} convention including hooks and JS API. #}
{% block content %}{% endblock %} {% block content %}{% endblock %}

View file

@ -16,7 +16,7 @@
</form> </form>
{% endif %} {% endif %}
{% if 'reset' in visible_buttons %} {% 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 %} {% endif %}
</div> </div>
{% if drift %} {% if drift %}

View file

@ -39,7 +39,7 @@
<button type="submit" class="button-secondary">Activate</button> <button type="submit" class="button-secondary">Activate</button>
</form> </form>
{% endif %} {% 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 %} {% endif %}
</td> </td>
</tr> </tr>
@ -55,7 +55,7 @@
<dialog id="delete-user-{{ user.id }}-modal" class="modal" aria-labelledby="delete-user-{{ user.id }}-title"> <dialog id="delete-user-{{ user.id }}-modal" class="modal" aria-labelledby="delete-user-{{ user.id }}-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="delete-user-{{ user.id }}-title">Delete user "{{ user.username }}"?</h2> <h2 id="delete-user-{{ user.id }}-title">Delete user "{{ user.username }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>This cannot be undone. Refused if the user owns servers, blueprints, <p>This cannot be undone. Refused if the user owns servers, blueprints,
@ -63,7 +63,7 @@
<p>For a reversible block, prefer Deactivate.</p> <p>For a reversible block, prefer Deactivate.</p>
</div> </div>
<div class="modal-footer"> <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"> <form method="post" action="/admin/users/{{ user.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button> <button class="danger" type="submit">Delete</button>

View file

@ -43,8 +43,7 @@
<script src="{{ url_for('static', filename='vendor/htmx.min.js') }}"></script> <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/csrf.js') }}"></script>
<script src="{{ url_for('static', filename='js/sse.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/modals.js') }}"></script>
<script src="{{ url_for('static', filename='js/modal-router.js') }}"></script>
<script src="{{ url_for('static', filename='js/file-tree.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 src="{{ url_for('static', filename='js/password-reveal.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script> <script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>

View file

@ -59,14 +59,14 @@
</section> </section>
<div class="page-footer-actions"> <div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-blueprint-modal">Delete blueprint</button> <button type="button" class="danger-outline" data-inline-modal-open="delete-blueprint-modal">Delete blueprint</button>
<a href="#" class="link-button" data-modal-open="rename-blueprint-modal">Rename</a> <a href="#" class="link-button" data-inline-modal-open="rename-blueprint-modal">Rename</a>
</div> </div>
<dialog id="rename-blueprint-modal" class="modal" aria-labelledby="rename-blueprint-title"> <dialog id="rename-blueprint-modal" class="modal" aria-labelledby="rename-blueprint-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="rename-blueprint-title">Rename blueprint</h2> <h2 id="rename-blueprint-title">Rename blueprint</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" action="/blueprints/{{ blueprint.id }}/rename" class="inline-save"> <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"> <dialog id="delete-blueprint-modal" class="modal" aria-labelledby="delete-blueprint-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2> <h2 id="delete-blueprint-title">Delete blueprint "{{ blueprint.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>This cannot be undone. Blueprints in use by a server cannot be deleted.</p> <p>This cannot be undone. Blueprints in use by a server cannot be deleted.</p>
</div> </div>
<div class="modal-footer"> <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"> <form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button> <button class="danger" type="submit">Delete</button>

View file

@ -6,7 +6,7 @@
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Blueprints</h1> <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> </div>
<table class="table"> <table class="table">
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead> <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"> <form method="post" action="/blueprints" class="stack">
<div class="modal-header"> <div class="modal-header">
<h2 id="create-blueprint-title">Create blueprint</h2> <h2 id="create-blueprint-title">Create blueprint</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <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> <label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
</div> </div>
<div class="modal-footer"> <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> <button type="submit">Create blueprint</button>
</div> </div>
</form> </form>

View file

@ -125,14 +125,14 @@
{% if can_edit %} {% if can_edit %}
<div class="page-footer-actions"> <div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-overlay-modal">Delete overlay</button> <button type="button" class="danger-outline" data-inline-modal-open="delete-overlay-modal">Delete overlay</button>
<a href="#" class="link-button" data-modal-open="rename-overlay-modal">Rename</a> <a href="#" class="link-button" data-inline-modal-open="rename-overlay-modal">Rename</a>
</div> </div>
<dialog id="rename-overlay-modal" class="modal" aria-labelledby="rename-overlay-title"> <dialog id="rename-overlay-modal" class="modal" aria-labelledby="rename-overlay-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="rename-overlay-title">Rename overlay</h2> <h2 id="rename-overlay-title">Rename overlay</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" action="/overlays/{{ overlay.id }}" class="inline-save"> <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"> <dialog id="delete-overlay-modal" class="modal" aria-labelledby="delete-overlay-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2> <h2 id="delete-overlay-title">Delete overlay "{{ overlay.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>This cannot be undone. Overlays in use by a blueprint cannot be deleted.</p> <p>This cannot be undone. Overlays in use by a blueprint cannot be deleted.</p>
</div> </div>
<div class="modal-footer"> <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"> <form method="post" action="/overlays/{{ overlay.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button> <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"> <dialog id="files-editor-modal" class="modal modal-wide" aria-labelledby="files-editor-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="files-editor-title" class="files-editor-path"><span class="files-editor-title-text"></span></h2> <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">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="files-editor-field"> <label class="files-editor-field">
@ -219,7 +219,7 @@
<button type="button" class="danger-outline files-editor-delete">Delete</button> <button type="button" class="danger-outline files-editor-delete">Delete</button>
<span class="files-editor-footer-spacer"></span> <span class="files-editor-footer-spacer"></span>
<a class="button-secondary files-editor-download" href="#" hidden>⬇ Download</a> <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> <button type="button" class="files-editor-save">Save</button>
</div> </div>
</dialog> </dialog>
@ -227,7 +227,7 @@
<dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title"> <dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target"></code></h2> <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">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="files-editor-field"> <label class="files-editor-field">
@ -238,7 +238,7 @@
<p class="files-new-folder-error" hidden></p> <p class="files-new-folder-error" hidden></p>
</div> </div>
<div class="modal-footer"> <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> <button type="button" class="files-new-folder-create">Create</button>
</div> </div>
</dialog> </dialog>
@ -246,14 +246,14 @@
<dialog id="files-conflict-modal" class="modal" aria-labelledby="files-conflict-title"> <dialog id="files-conflict-modal" class="modal" aria-labelledby="files-conflict-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="files-conflict-title">File already exists</h2> <h2 id="files-conflict-title">File already exists</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>A file already exists at <code class="files-conflict-path"></code>.</p> <p>A file already exists at <code class="files-conflict-path"></code>.</p>
<p class="muted">Choose how to handle this upload.</p> <p class="muted">Choose how to handle this upload.</p>
</div> </div>
<div class="modal-footer"> <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" class="button-secondary" data-files-conflict-action="keep-both">Keep both</button>
<button type="button" data-files-conflict-action="overwrite">Overwrite</button> <button type="button" data-files-conflict-action="overwrite">Overwrite</button>
</div> </div>
@ -262,14 +262,14 @@
<dialog id="files-delete-modal" class="modal" aria-labelledby="files-delete-title"> <dialog id="files-delete-modal" class="modal" aria-labelledby="files-delete-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="files-delete-title">Delete <span class="files-delete-name"></span>?</h2> <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">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>This cannot be undone.</p> <p>This cannot be undone.</p>
<p class="files-delete-error muted" hidden></p> <p class="files-delete-error muted" hidden></p>
</div> </div>
<div class="modal-footer"> <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> <button type="button" class="danger files-delete-confirm">Delete</button>
</div> </div>
</dialog> </dialog>

View file

@ -7,7 +7,7 @@
<h2 id="files-editor-title" class="files-editor-path"> <h2 id="files-editor-title" class="files-editor-path">
<span class="files-editor-title-text">{{ rel_path }}</span> <span class="files-editor-title-text">{{ rel_path }}</span>
</h2> </h2>
<button type="button" class="modal-close" data-modal-dismiss aria-label="Close">&times;</button> <button type="button" class="modal-close" data-routed-modal-dismiss aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="files-editor-field"> <label class="files-editor-field">
@ -40,7 +40,7 @@
<button type="button" class="danger-outline files-editor-delete">Delete</button> <button type="button" class="danger-outline files-editor-delete">Delete</button>
<span class="files-editor-footer-spacer"></span> <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> <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> <button type="button" class="files-editor-save">Save</button>
</div> </div>
</div> </div>

View file

@ -6,7 +6,7 @@
<section class="panel"> <section class="panel">
<div class="page-heading"> <div class="page-heading">
<h1>Overlays</h1> <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> </div>
<table class="table"> <table class="table">
@ -30,7 +30,7 @@
<form method="post" action="/overlays" class="stack"> <form method="post" action="/overlays" class="stack">
<div class="modal-header"> <div class="modal-header">
<h2 id="create-overlay-title">Create overlay</h2> <h2 id="create-overlay-title">Create overlay</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
@ -47,7 +47,7 @@
<p class="muted">The path is generated automatically.</p> <p class="muted">The path is generated automatically.</p>
</div> </div>
<div class="modal-footer"> <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> <button type="submit">Create</button>
</div> </div>
</form> </form>

View file

@ -77,14 +77,14 @@
</section> </section>
<div class="page-footer-actions"> <div class="page-footer-actions">
<button type="button" class="danger-outline" data-modal-open="delete-server-modal">Delete server</button> <button type="button" class="danger-outline" data-inline-modal-open="delete-server-modal">Delete server</button>
<a href="#" class="link-button" data-modal-open="rename-server-modal">Rename</a> <a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a>
</div> </div>
<dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title"> <dialog id="rename-server-modal" class="modal" aria-labelledby="rename-server-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="rename-server-title">Rename server</h2> <h2 id="rename-server-title">Rename server</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" action="/servers/{{ server.id }}" class="inline-save"> <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"> <dialog id="reset-server-modal" class="modal" aria-labelledby="reset-server-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2> <h2 id="reset-server-title">Reset server "{{ server.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <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> <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>
<div class="modal-footer"> <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"> <form method="post" action="/servers/{{ server.id }}/reset" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Reset</button> <button class="danger" type="submit">Reset</button>
@ -115,13 +115,13 @@
<dialog id="delete-server-modal" class="modal" aria-labelledby="delete-server-title"> <dialog id="delete-server-modal" class="modal" aria-labelledby="delete-server-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="delete-server-title">Delete server "{{ server.name }}"?</h2> <h2 id="delete-server-title">Delete server "{{ server.name }}"?</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>This stops the server and tears down its runtime files. This cannot be undone.</p> <p>This stops the server and tears down its runtime files. This cannot be undone.</p>
</div> </div>
<div class="modal-footer"> <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"> <form method="post" action="/servers/{{ server.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button> <button class="danger" type="submit">Delete</button>

View file

@ -7,7 +7,7 @@
<div class="page-heading"> <div class="page-heading">
<h1>Servers</h1> <h1>Servers</h1>
{% if blueprints %} {% 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 %} {% else %}
<a class="button" href="/blueprints">Create a blueprint first &rarr;</a> <a class="button" href="/blueprints">Create a blueprint first &rarr;</a>
{% endif %} {% endif %}
@ -47,7 +47,7 @@
<form method="post" action="/servers" class="stack"> <form method="post" action="/servers" class="stack">
<div class="modal-header"> <div class="modal-header">
<h2 id="create-server-title">Create server</h2> <h2 id="create-server-title">Create server</h2>
<button type="button" class="modal-close" data-modal-close aria-label="Close">&times;</button> <button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
@ -65,7 +65,7 @@
</label> </label>
</div> </div>
<div class="modal-footer"> <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> <button type="submit">Create server</button>
</div> </div>
</form> </form>