Compare commits
No commits in common. "c51089df1b734bec9ce2870e1ef147a5a659eb3b" and "2942467cfda344778e2e3a519f1fdee6b7f55acf" have entirely different histories.
c51089df1b
...
2942467cfd
22 changed files with 85 additions and 1858 deletions
35
AGENTS.md
35
AGENTS.md
|
|
@ -23,41 +23,6 @@ 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.
|
||||
|
||||
### Modals: inline vs routed
|
||||
|
||||
Two coexisting modal mechanisms, one module (`l4d2web/l4d2web/static/js/modals.js`). When adding a new modal, decide which pipeline it belongs to:
|
||||
|
||||
**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
|
||||
|
||||
- **Production paths (`/var/lib/left4me`, `/usr/local/lib/systemd/system`, `/usr/local/libexec/left4me`, `/etc/left4me`) exist only on Linux deploy hosts.** Never create or write to these on a developer machine. They are referenced in `l4d2host/l4d2host/paths.py` and the spec only as the production layout.
|
||||
- **For local dev, always use `scripts/dev-server.py`.** It sets `LEFT4ME_ROOT=./.tmp/dev-server`, runs migrations, seeds demo content (admin + blueprint + script overlay + files overlay), and starts Flask on port 5051. Reset state with `rm -rf .tmp/dev-server` then re-run. Never invoke `flask run` directly — that leaves `LEFT4ME_ROOT` unset and the app falls back to the production `/var/lib/left4me`, which on macOS surfaces as "route returns 404 / empty modal / file not found" and can be mistaken for a code bug.
|
||||
- **All ephemeral dev state lives under `.tmp/`** (gitignored). Use `$TMPDIR` only for transient files outside the repo. Do NOT use `/tmp`, `~/Library/Application Support`, or any system path for project state — only `.tmp/` (project-local) or `$TMPDIR` (sandbox-blessed).
|
||||
- **Symptom-to-cause translation:** if a route returns 404 or behaves as if the filesystem is empty, the first diagnosis is "`LEFT4ME_ROOT` is wrong" (defaulted to the production path), not "code bug." Restart via `scripts/dev-server.py`.
|
||||
|
||||
### Planning artifacts
|
||||
|
||||
- Design specs live in `docs/superpowers/specs/` as `YYYY-MM-DD-<topic>-design.md`.
|
||||
|
|
|
|||
|
|
@ -1,973 +0,0 @@
|
|||
# URL-Addressable Modals Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Pilot the swift3-style URL-addressable modal pattern in left4me by migrating the file editor's open/render flow. Same URL renders as a full page or a layoutless fragment based on an `HX-Modal: 1` request header. Save flow stays AJAX.
|
||||
|
||||
**Architecture:** Approach C (Hybrid). Custom ~50-line `modal-router.js` owns click intercept, `?modal=<path>` URL composition, history, and native `<dialog>` open/close. HTMX (already loaded) owns fetch + swap + loading state. Jinja `inject_base_layout` context processor switches between `base.html` and `_modal_partial.html` based on the header.
|
||||
|
||||
**Tech Stack:** Flask 3.x + Jinja2, HTMX 2.0.4, native `<dialog>`, CodeMirror 6 (already bundled as `editor.bundle.js`), pytest for backend tests, Chromium for frontend verification.
|
||||
|
||||
**Spec reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Errata (post-execution)
|
||||
|
||||
The plan shipped via 14 commits between 2026-05-17 and the same day's evening. Three defects in the verbatim plan code were caught by code review during execution; if you re-run this plan, watch for them:
|
||||
|
||||
1. **Task 1, Step 4 — context processor needs a `has_request_context()` guard.** Plan code reads `request.headers.get("HX-Modal")` unconditionally, but `tests/test_timeago.py` renders templates inside `app.app_context()` only (no request context). Without the guard the processor crashes with `RuntimeError: Working outside of request context`. Fix: `is_modal = has_request_context() and request.headers.get("HX-Modal") == "1"` (lazy import `from flask import has_request_context` is fine). Shipped in commit `82c3f04`.
|
||||
|
||||
2. **Task 3, Step 1 — test fixture must respect `LEFT4ME_ROOT`.** Plan code uses `path=str(overlay_root)` (absolute filesystem path) on the `Overlay` model. The codebase resolves `overlay.path` relative to `LEFT4ME_ROOT` via `validate_overlay_ref` and rejects absolute paths. Fix: `monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))`, write files to `tmp_path/overlays/<id>/`, set `overlay.path = str(overlay.id)`. Mirrors `tests/test_overlay_files_routes.py`'s convention. Shipped in commit `60e7968`.
|
||||
|
||||
3. **Task 9, Step 2 — "save flow unchanged" was wrong.** The legacy save/delete handlers in `files-overlay.js` are direct-bound to `editorEls.saveBtn` / `editorEls.deleteBtn` (the inline dialog's specific elements), not document-delegated. The new server-rendered modal's identical-class buttons get no handler. Fix: add document-level event delegation for `.files-editor-save` and `.files-editor-delete` clicks gated on `modalContent.contains(btn)`, read `data-rel-path` from the textarea (NOT from a JS var the now-deleted open path used to set), use `window.__filesEditor.getValue()`, POST + `closeModal()` + `scheduleRefresh(parentOf(path))`. Also support rename: read filename input, compose `payload.new_path = parent/filename` when changed, handle 409 with alert + keep modal open. Shipped across commits `64cf203` and `33a2e52`.
|
||||
|
||||
## Tasks added during execution
|
||||
|
||||
Three tasks were inserted that weren't in the original plan:
|
||||
|
||||
- **Task 8.5 (commit `f6b8ecf`)** — `overlay_file_editor.html`'s `<dialog open>` nested inside `<dialog id="modal-container">` collapses to 2 px tall in browsers. Replaced with `<div role="document">`. Bundled with CM6 `controller.destroy()` on modal close (memory leak fix — every open/close cycle had been orphaning an `EditorView` and a `matchMedia` listener) and a `mountOne` idempotency guard. CSS broadened: `dialog.modal, div.modal`.
|
||||
- **Task 8.5b (commit `7829d1c`)** — the broadened CSS caused double-card painting (outer dialog + inner div both matched the `.modal` styling). Dropped `class="modal modal-wide"` and `role="document"` from the inner div; the outer dialog owns the chrome.
|
||||
- **Task 9b (commit `33a2e52`)** — see defect #3 above for rename-on-save support.
|
||||
|
||||
## Design refinement during execution (Task 6 superseded)
|
||||
|
||||
Task 6's original "every close source updates state directly" code was replaced with a close-event-centric design: every close source (Esc cancel, backdrop click, `[data-modal-dismiss]`, browser back, `htmx:responseError`, programmatic close) just calls `dialog.close()`, and a single `close`-event listener clears `currentModalPath` and removes `?modal=` from the URL. This kills two latent bugs simultaneously: (a) the legacy `modal.js:31-33` backdrop handler closes `dialog.modal` without clearing URL, and (b) HTMX's `htmx.ajax` resolves on 4xx so plain `.then(() => showModal())` would open a modal on error responses. Shipped in commit `6e66375`. The revised design is in that commit's diff.
|
||||
|
||||
## Post-pilot polish (commits 5dc4xx after Task 10)
|
||||
|
||||
- Removed dangling `aria-labelledby="modal-content-title"` from `#modal-container` in `base.html` (referenced an id that never existed).
|
||||
- Renamed the new editor template's outer `<div>` id from `files-editor-modal` to `files-editor-fragment` to resolve a duplicate-id W3C violation with the legacy inline `<dialog id="files-editor-modal">` in `overlay_detail.html`. Updated `editor.js`'s `closest()` to match both selectors so auto-language detection works for both modal pipelines.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Path | New / Modify | Responsibility |
|
||||
|------|--------------|----------------|
|
||||
| `l4d2web/l4d2web/app.py` | Modify (insert ~5 lines after `add_template_filter`) | Register `inject_base_layout` context processor |
|
||||
| `l4d2web/l4d2web/templates/_modal_partial.html` | New (1 line) | Layoutless base template — just `{% block content %}{% endblock %}` |
|
||||
| `l4d2web/l4d2web/templates/overlay_file_editor.html` | New | Editor markup lifted from `overlay_detail.html:165-228`, content pre-filled, extends `base_layout` |
|
||||
| `l4d2web/l4d2web/routes/files_routes.py` | Modify (add one route, ~30 lines) | `GET /overlays/<id>/files/edit?path=<rel>` |
|
||||
| `l4d2web/l4d2web/templates/base.html` | Modify (insert ~3 lines) | Persistent `<dialog id="modal-container">` slot + `modal-router.js` script include |
|
||||
| `l4d2web/l4d2web/static/js/modal-router.js` | New (~60 lines) | Click intercept, URL composition, history, open/close, bootstrap |
|
||||
| `l4d2web/l4d2web/static/js/editor.js` | Modify (expose `initEditors(root)`, add `htmx:afterSwap` listener) | CM6 re-init after HTMX swap |
|
||||
| `l4d2web/l4d2web/static/js/files-overlay.js` | Modify (change one code path) | Replace inline-dialog populate-and-show with `window.openModal(url)` |
|
||||
| `l4d2web/l4d2web/templates/overlay_detail.html` | Modify (remove `<dialog id="files-editor-modal">` block at lines 165-228) | Delete the old inline editor dialog |
|
||||
| `l4d2web/tests/test_url_addressable_modals.py` | New | pytest coverage for context processor + new edit route |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Layout context processor + partial template
|
||||
|
||||
**Files:**
|
||||
- Create: `l4d2web/l4d2web/templates/_modal_partial.html`
|
||||
- Modify: `l4d2web/l4d2web/app.py` (insert after `app.add_template_filter(format_time_html, "timeago")` on line 62)
|
||||
- Test: `l4d2web/tests/test_url_addressable_modals.py` (new)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `l4d2web/tests/test_url_addressable_modals.py`:
|
||||
|
||||
```python
|
||||
from flask import render_template_string
|
||||
|
||||
from l4d2web.app import create_app
|
||||
|
||||
|
||||
def _make_app(tmp_path, monkeypatch, db_name: str):
|
||||
db_url = f"sqlite:///{tmp_path/db_name}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
return create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
|
||||
|
||||
def test_base_layout_is_modal_partial_when_hx_modal_header_set(tmp_path, monkeypatch):
|
||||
app = _make_app(tmp_path, monkeypatch, "layout-modal.db")
|
||||
with app.test_request_context("/", headers={"HX-Modal": "1"}):
|
||||
assert render_template_string("{{ base_layout }}") == "_modal_partial.html"
|
||||
|
||||
|
||||
def test_base_layout_is_base_html_for_normal_request(tmp_path, monkeypatch):
|
||||
app = _make_app(tmp_path, monkeypatch, "layout-default.db")
|
||||
with app.test_request_context("/"):
|
||||
assert render_template_string("{{ base_layout }}") == "base.html"
|
||||
|
||||
|
||||
def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeypatch):
|
||||
# HTMX sets HX-Request on every request including the build-status poll;
|
||||
# only HX-Modal should switch the layout.
|
||||
app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db")
|
||||
with app.test_request_context("/", headers={"HX-Request": "true"}):
|
||||
assert render_template_string("{{ base_layout }}") == "base.html"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v`
|
||||
|
||||
Expected: 3 failures (all asserting that `base_layout` resolves to something — currently undefined, so render fails with `UndefinedError` or returns empty string).
|
||||
|
||||
- [ ] **Step 3: Create the partial template**
|
||||
|
||||
Create `l4d2web/l4d2web/templates/_modal_partial.html` with exactly this content:
|
||||
|
||||
```jinja
|
||||
{% block content %}{% endblock %}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the context processor**
|
||||
|
||||
In `l4d2web/l4d2web/app.py`, insert immediately after line 62 (`app.add_template_filter(format_time_html, "timeago")`):
|
||||
|
||||
```python
|
||||
@app.context_processor
|
||||
def inject_base_layout() -> dict[str, str]:
|
||||
is_modal = request.headers.get("HX-Modal") == "1"
|
||||
return {"base_layout": "_modal_partial.html" if is_modal else "base.html"}
|
||||
```
|
||||
|
||||
`request` is already imported at the top of the file.
|
||||
|
||||
- [ ] **Step 5: Run tests to verify pass**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v`
|
||||
|
||||
Expected: 3 passed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/app.py l4d2web/l4d2web/templates/_modal_partial.html l4d2web/tests/test_url_addressable_modals.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): layout context processor for HX-Modal header
|
||||
|
||||
Switches the Jinja base layout to _modal_partial.html (yield-only) when
|
||||
the HX-Modal:1 request header is set, otherwise base.html. Foundation
|
||||
for URL-addressable modals (spec 2026-05-17-url-addressable-modals).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Editor template (file editor as standalone page)
|
||||
|
||||
**Files:**
|
||||
- Create: `l4d2web/l4d2web/templates/overlay_file_editor.html`
|
||||
- Test: covered by Task 3's route tests (template is unreachable until then)
|
||||
|
||||
This task is a lift-and-shift of the editor markup from `overlay_detail.html:165-228` into its own template with server-side content variables substituted in.
|
||||
|
||||
- [ ] **Step 1: Read the source markup to lift**
|
||||
|
||||
Run: `sed -n '164,228p' l4d2web/l4d2web/templates/overlay_detail.html`
|
||||
|
||||
Note the surrounding `{% if files_can_edit %}` guard — that gating moves to the route (only `files` overlays expose the link). The template itself unconditionally renders the editor.
|
||||
|
||||
- [ ] **Step 2: Create the new template**
|
||||
|
||||
Create `l4d2web/l4d2web/templates/overlay_file_editor.html`:
|
||||
|
||||
```jinja
|
||||
{% extends base_layout %}
|
||||
{% block title %}Edit {{ rel_path }} · {{ overlay.name }}{% endblock %}
|
||||
{% block extra_head %}{% include "_editor_assets.html" %}{% endblock %}
|
||||
{% block content %}
|
||||
<dialog id="files-editor-modal" class="modal modal-wide" open aria-labelledby="files-editor-title">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="files-editor-field">
|
||||
<span class="files-field-label">Filename</span>
|
||||
<input type="text" class="files-editor-filename" data-editor-filename autocomplete="off" spellcheck="false" value="{{ rel_path }}">
|
||||
</label>
|
||||
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code> → <code class="files-rename-to"></code>.</p>
|
||||
|
||||
<div class="files-editor-text">
|
||||
<label class="files-editor-field files-editor-language-field">
|
||||
<span class="files-field-label">Language</span>
|
||||
<select data-editor-language-select aria-label="Editor language">
|
||||
<option value="auto">auto (from filename)</option>
|
||||
<option value="srccfg">srccfg (.cfg)</option>
|
||||
<option value="bash">bash (.sh)</option>
|
||||
<option value="plain">plain</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="files-editor-field">
|
||||
<span class="files-field-label">Content</span>
|
||||
<div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto" data-overlay-id="{{ overlay.id }}" data-rel-path="{{ rel_path }}">{{ content }}</textarea></div>
|
||||
</label>
|
||||
<div class="files-editor-meta muted">
|
||||
<span class="files-editor-byte-count">UTF-8 · {{ byte_count }} bytes</span>
|
||||
<span>Ctrl+S to save</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer files-editor-footer">
|
||||
<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="files-editor-save">Save</button>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Notes baked into the markup:
|
||||
- `{% extends base_layout %}` — picks `_modal_partial.html` or `base.html` based on the request header
|
||||
- `<dialog … open>` for the full-page render — when standalone, the dialog stays open without `showModal()`. When fragment-rendered into the modal slot, `modal-router.js` calls `showModal()` on the *outer* `#modal-container` (not this inner dialog — see Task 4)
|
||||
- `data-modal-dismiss` on close buttons — picked up by modal-router (deferred to Task 6)
|
||||
- `data-overlay-id` + `data-rel-path` on the textarea — so the AJAX save in `files-overlay.js` can find its target without depending on global state
|
||||
- Binary-file replacement UI from `overlay_detail.html:204-219` is **omitted** from this pilot template. Editable-only files reach this route (the route returns 415 for non-editable per Task 3). Binary replace stays inline-modal for now (out of pilot scope)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/templates/overlay_file_editor.html
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): editor template that extends base_layout
|
||||
|
||||
Lifts the file editor markup out of overlay_detail.html into its own
|
||||
template with server-side filename, content, byte count, and download
|
||||
URL pre-filled. Uses {% extends base_layout %} so the same template
|
||||
renders as either a full page or a layoutless modal fragment.
|
||||
|
||||
Binary replace UI deferred — pilot scope is editable text files only.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: New GET `/overlays/<id>/files/edit` route
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/routes/files_routes.py` (add one route, ~35 lines)
|
||||
- Test: `l4d2web/tests/test_url_addressable_modals.py` (extend)
|
||||
|
||||
The route mirrors the existing `overlay_file_content` at `files_routes.py:203-234`: resolves the path, checks editability, reads UTF-8 content. Difference: returns HTML (via `overlay_file_editor.html`) instead of JSON.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `l4d2web/tests/test_url_addressable_modals.py`:
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from l4d2web.auth import hash_password
|
||||
from l4d2web.db import init_db, session_scope
|
||||
from l4d2web.models import Overlay, User
|
||||
|
||||
|
||||
def _auth_client_with_files_overlay(tmp_path, monkeypatch, db_name: str):
|
||||
db_url = f"sqlite:///{tmp_path/db_name}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
|
||||
overlay_root = tmp_path / "overlay_root"
|
||||
overlay_root.mkdir()
|
||||
(overlay_root / "server.cfg").write_text("hostname \"left4me\"\nrcon_password \"hunter2\"\n", encoding="utf-8")
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
overlay = Overlay(name="cfgs", path=str(overlay_root), type="files", user_id=user.id)
|
||||
session.add(overlay)
|
||||
session.flush()
|
||||
user_id = user.id
|
||||
overlay_id = overlay.id
|
||||
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
|
||||
return client, overlay_id
|
||||
|
||||
|
||||
def test_edit_route_renders_full_page_without_modal_header(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-full.db")
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=server.cfg")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "<!doctype html>" in text.lower() # full base.html rendered
|
||||
assert 'href="/dashboard"' in text # nav present
|
||||
assert 'class="files-editor-content"' in text
|
||||
assert 'rcon_password' in text # content pre-filled
|
||||
|
||||
|
||||
def test_edit_route_renders_fragment_with_modal_header(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-fragment.db")
|
||||
response = client.get(
|
||||
f"/overlays/{overlay_id}/files/edit?path=server.cfg",
|
||||
headers={"HX-Modal": "1"},
|
||||
)
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "<html" not in text # layoutless
|
||||
assert 'class="primary-nav"' not in text
|
||||
assert 'class="files-editor-content"' in text
|
||||
assert "hostname" in text # content pre-filled
|
||||
|
||||
|
||||
def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-404.db")
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=nonexistent.cfg")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db")
|
||||
# Forge a non-editable file by writing binary garbage.
|
||||
with session_scope() as s:
|
||||
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
|
||||
from pathlib import Path
|
||||
Path(overlay.path).joinpath("blob.bin").write_bytes(b"\x00\x01\x02\x03" * 1024)
|
||||
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin")
|
||||
assert response.status_code == 415
|
||||
|
||||
|
||||
def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-400.db")
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=../../etc/passwd")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
|
||||
db_url = f"sqlite:///{tmp_path/'edit-script-overlay.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
with session_scope() as s:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
s.add(user)
|
||||
s.flush()
|
||||
overlay = Overlay(name="scripted", path=str(tmp_path), type="script", user_id=user.id)
|
||||
s.add(overlay)
|
||||
s.flush()
|
||||
user_id = user.id
|
||||
overlay_id = overlay.id
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
|
||||
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=anything.cfg")
|
||||
assert response.status_code == 404
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v -k edit_route`
|
||||
|
||||
Expected: 6 failures (route doesn't exist → 404 for all).
|
||||
|
||||
- [ ] **Step 3: Add the route**
|
||||
|
||||
In `l4d2web/l4d2web/routes/files_routes.py`, append immediately after the `overlay_file_content` function (line 234):
|
||||
|
||||
```python
|
||||
@bp.get("/overlays/<int:overlay_id>/files/edit")
|
||||
@require_login
|
||||
def overlay_file_edit_page(overlay_id: int):
|
||||
"""Server-rendered editor page. Renders full-page by default or as a
|
||||
layoutless modal fragment when the HX-Modal header is set (see the
|
||||
inject_base_layout context processor in app.py)."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
sub_path = request.args.get("path", "")
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
target = safe_resolve_for_listing(overlay.path, sub_path)
|
||||
except ValueError:
|
||||
return Response("invalid path", status=400)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
return Response(status=404)
|
||||
if not is_editable(target):
|
||||
return Response("not editable", status=415)
|
||||
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return Response("read failed", status=500)
|
||||
except UnicodeDecodeError:
|
||||
return Response("not editable", status=415)
|
||||
|
||||
return render_template(
|
||||
"overlay_file_editor.html",
|
||||
overlay=overlay,
|
||||
rel_path=sub_path,
|
||||
content=content,
|
||||
byte_count=len(content.encode("utf-8")),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify pass**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py -v`
|
||||
|
||||
Expected: 9 passed (3 from Task 1 + 6 new).
|
||||
|
||||
- [ ] **Step 5: Smoke-test the existing test suite for regressions**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/ -v --tb=short -q`
|
||||
|
||||
Expected: all tests pass. The context processor adds `base_layout` to every template render; existing templates ignore it (they all use `{% extends "base.html" %}` literally), so behavior is unchanged.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/routes/files_routes.py l4d2web/tests/test_url_addressable_modals.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): GET /overlays/<id>/files/edit route
|
||||
|
||||
Server-renders the file editor as a real page. With HX-Modal:1 returns
|
||||
a layoutless fragment for modal embedding; without it returns the full
|
||||
standalone page. Mirrors overlay_file_content's path/editability checks.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Persistent modal slot in base.html
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/templates/base.html`
|
||||
|
||||
The slot is a sibling of `<main>`, sitting at body scope so backdrop renders over everything.
|
||||
|
||||
- [ ] **Step 1: Add the slot and script include**
|
||||
|
||||
In `l4d2web/l4d2web/templates/base.html`, modify the body section. After the closing `</main>` (currently line 39), insert the modal slot. After the `<script src="…/modal.js">` line (currently line 43), add the modal-router include.
|
||||
|
||||
The body section should look like this after the edit:
|
||||
|
||||
```html
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<dialog id="modal-container" class="modal modal-wide" aria-labelledby="modal-content-title">
|
||||
<div id="modal-content"></div>
|
||||
</dialog>
|
||||
<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/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>
|
||||
</body>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create an empty modal-router.js stub**
|
||||
|
||||
So the new `<script src>` doesn't 404. Create `l4d2web/l4d2web/static/js/modal-router.js`:
|
||||
|
||||
```javascript
|
||||
// URL-addressable modal router (see docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md).
|
||||
// Implementation lands in subsequent tasks; this stub keeps base.html's
|
||||
// script include from 404'ing during the staged rollout.
|
||||
(function () {
|
||||
"use strict";
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing tests for regressions**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/test_pages.py -v -q`
|
||||
|
||||
Expected: all pass. The added `<dialog>` is closed by default (no `open` attribute), so it's invisible and inert.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/templates/base.html l4d2web/l4d2web/static/js/modal-router.js
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): persistent modal slot + router script stub in base.html
|
||||
|
||||
Adds <dialog id="modal-container"> with #modal-content slot at body
|
||||
scope. Script stub created so the include doesn't 404; logic follows.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `modal-router.js` — click intercept, open, fetch, show
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/static/js/modal-router.js`
|
||||
|
||||
This task wires the click → URL → fetch → show pipeline. Close/popstate/bootstrap come in Tasks 6 and 7.
|
||||
|
||||
- [ ] **Step 1: Implement click intercept + openModal + fetchAndShow**
|
||||
|
||||
Replace `l4d2web/l4d2web/static/js/modal-router.js` with:
|
||||
|
||||
```javascript
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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;
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Chromium verification**
|
||||
|
||||
Steps to verify manually (or via the user's Chromium tooling). The new editor route is reachable but not yet linked from the file tree — use a temporary `<a>` for the smoke test.
|
||||
|
||||
1. Start the dev server: `cd l4d2web && uv run flask --app l4d2web.app:create_app run --debug --port 5000` (or whatever the project's dev-server idiom is — confirm at implementation time).
|
||||
2. Log in and create a `files` overlay with a `.cfg` file in it (or use an existing one).
|
||||
3. Open dev tools → Console.
|
||||
4. In the console, run: `window.openModal('/overlays/<id>/files/edit?path=server.cfg')` (substitute real id).
|
||||
5. **Expected:** URL gains `?modal=/overlays/<id>/files/edit?path=server.cfg`. Modal opens with the editor markup inside. (CodeMirror not yet mounted — that's Task 8 — so you'll see the raw `<textarea>` with content.)
|
||||
6. Network tab: confirm the request to `/overlays/<id>/files/edit?path=server.cfg` carries the header `HX-Modal: 1`.
|
||||
7. Negative check: confirm the build-status poll (`/overlays/<id>/build-status` every 2s) does **not** carry `HX-Modal: 1`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/static/js/modal-router.js
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): click intercept + openModal + fetchAndShow
|
||||
|
||||
a[data-modal] clicks push ?modal=<path> to URL and trigger htmx.ajax
|
||||
into #modal-content with the HX-Modal header. window.openModal exposed
|
||||
for non-<a> trigger sites (files-overlay row clicks). Race guard via
|
||||
currentModalPath token. Close/popstate/bootstrap follow.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `modal-router.js` — close, popstate, dismiss, Esc
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/static/js/modal-router.js`
|
||||
|
||||
- [ ] **Step 1: Add close, popstate, dismiss-click, dialog cancel handlers**
|
||||
|
||||
In `modal-router.js`, replace the IIFE body with the expanded version. Insert the new function and listeners after `fetchAndShow` and before the `document.addEventListener("click", …)` for opens:
|
||||
|
||||
```javascript
|
||||
function closeModal() {
|
||||
currentModalPath = null;
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg && dlg.open) dlg.close();
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("modal")) {
|
||||
url.searchParams.delete("modal");
|
||||
history.pushState({}, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
const path = new URL(window.location.href).searchParams.get("modal");
|
||||
if (path) {
|
||||
fetchAndShow(path);
|
||||
} else {
|
||||
currentModalPath = null;
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg && dlg.open) dlg.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Dismiss triggers inside modal content
|
||||
document.addEventListener("click", (event) => {
|
||||
if (event.target.closest("[data-modal-dismiss]")) {
|
||||
event.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Esc key fires the dialog's 'cancel' event; sync URL.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg) {
|
||||
dlg.addEventListener("cancel", (event) => {
|
||||
event.preventDefault(); // prevent default close so we control URL sync
|
||||
closeModal();
|
||||
});
|
||||
// Backdrop click — native <dialog> doesn't dismiss on backdrop; replicate.
|
||||
dlg.addEventListener("click", (event) => {
|
||||
if (event.target === dlg) closeModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
window.closeModal = closeModal;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Chromium verification**
|
||||
|
||||
1. From the prior task's setup (modal open via `window.openModal(...)`):
|
||||
2. Click the `[data-modal-dismiss]` Cancel button (or the × in the modal header). **Expected:** modal closes, URL loses `?modal=…`, underlying overlay page intact and still polling build-status.
|
||||
3. Open the modal again. Press Esc. **Expected:** same close behavior.
|
||||
4. Open the modal again. Click on the backdrop outside the dialog content. **Expected:** same close behavior.
|
||||
5. Open the modal again. Click browser back button. **Expected:** modal closes, URL clears.
|
||||
6. Now click forward. **Expected:** modal re-opens with the same file's content.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/static/js/modal-router.js
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): close, popstate, dismiss-click, Esc, backdrop-click
|
||||
|
||||
closeModal pops ?modal= from URL via pushState. popstate handler reacts
|
||||
to back/forward by fetching or closing. [data-modal-dismiss] click,
|
||||
native dialog 'cancel' (Esc), and backdrop click all funnel to
|
||||
closeModal. window.closeModal exposed for callers.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `modal-router.js` — initial-load bootstrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/static/js/modal-router.js`
|
||||
|
||||
This makes refreshing on a `?modal=…` URL reopen the modal automatically — the headline feature.
|
||||
|
||||
- [ ] **Step 1: Add bootstrap on DOMContentLoaded**
|
||||
|
||||
Extend the existing `DOMContentLoaded` listener in `modal-router.js` (added in Task 6) so it also bootstraps from URL. Replace the block:
|
||||
|
||||
```javascript
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dlg = document.getElementById("modal-container");
|
||||
if (dlg) {
|
||||
dlg.addEventListener("cancel", (event) => {
|
||||
event.preventDefault();
|
||||
closeModal();
|
||||
});
|
||||
dlg.addEventListener("click", (event) => {
|
||||
if (event.target === dlg) closeModal();
|
||||
});
|
||||
}
|
||||
const initialPath = new URL(window.location.href).searchParams.get("modal");
|
||||
if (initialPath) {
|
||||
fetchAndShow(initialPath);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Chromium verification**
|
||||
|
||||
1. Open the modal via `window.openModal('/overlays/<id>/files/edit?path=server.cfg')`.
|
||||
2. Hit the browser refresh button. **Expected:** page reloads, modal re-opens automatically with the same file's content. URL retains `?modal=…`.
|
||||
3. Copy the full URL. Open a new incognito window, log in, paste the URL. **Expected:** lands on the overlay page with the modal already open.
|
||||
4. Negative: visit `/overlays/<id>` (no `?modal=`). **Expected:** modal does not open; underlying page renders normally.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/static/js/modal-router.js
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): DOMContentLoaded bootstrap reopens modal from ?modal= URL
|
||||
|
||||
Refresh and share-link flows both work — the modal-state URL is the
|
||||
canonical shareable artifact for "this overlay with this file open."
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: CodeMirror re-init after HTMX swap
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/static/js/editor.js`
|
||||
|
||||
`editor.js` currently mounts CM6 once at `DOMContentLoaded`. After modal swap-in, the new `<textarea>` is unmounted. Fix: expose `initEditors(root)` and call it from an `htmx:afterSwap` listener.
|
||||
|
||||
- [ ] **Step 1: Refactor `init` to accept a root**
|
||||
|
||||
In `l4d2web/l4d2web/static/js/editor.js`, replace the existing `init` function (currently around lines 93-100) and the bootstrap-at-end (lines 101-105) with:
|
||||
|
||||
```javascript
|
||||
function initEditors(root) {
|
||||
const scope = root || document;
|
||||
for (const ta of scope.querySelectorAll("textarea[data-editor-language]")) {
|
||||
mountOne(ta).catch(err => {
|
||||
console.error("[editor] mount failed", err);
|
||||
unhideTextarea(ta);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => initEditors(document));
|
||||
} else {
|
||||
initEditors(document);
|
||||
}
|
||||
|
||||
// Re-init editors that arrive via HTMX swap (modal content, etc.).
|
||||
document.body.addEventListener("htmx:afterSwap", (event) => {
|
||||
if (event.target && event.target.id === "modal-content") {
|
||||
initEditors(event.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for callers that need to re-mount imperatively.
|
||||
if (window.__editor) {
|
||||
window.__editor.initEditors = initEditors;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Chromium verification**
|
||||
|
||||
1. Open the editor modal via the URL flow from Task 7 (`/overlays/<id>?modal=/overlays/<id>/files/edit?path=server.cfg`).
|
||||
2. **Expected:** CM6 renders inside the modal — syntax-highlighted content, NOT a raw textarea. Byte count matches actual content size. Language dropdown reflects auto-detected language (srccfg for .cfg, bash for .sh).
|
||||
3. Type into the editor. **Expected:** edits are reflected; UI is responsive.
|
||||
4. Close the modal, re-open. **Expected:** CM6 re-mounts cleanly each time. No duplicate editor instances visible (only one rendered).
|
||||
5. Open dev tools → Network → confirm no console errors mentioning `mount failed` or duplicate-init warnings.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/static/js/editor.js
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(editor): re-init CM6 on htmx:afterSwap into #modal-content
|
||||
|
||||
editor.js exposes initEditors(root) and listens for htmx:afterSwap so
|
||||
editor textareas that arrive via modal swap get CM6 mounted. The
|
||||
DOMContentLoaded path remains for first-paint mounting.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Wire file-tree edit triggers to use `window.openModal`
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/static/js/files-overlay.js` (specific code path; rest unchanged)
|
||||
|
||||
`files-overlay.js` currently populates the empty inline `#files-editor-modal` dialog when a file row is clicked. Replace that code path with a call to `window.openModal(editUrl)`. The save flow (also in this file) stays untouched.
|
||||
|
||||
- [ ] **Step 1: Locate the "open editor" entry point**
|
||||
|
||||
Run: `grep -n "files-editor-modal\|showModal\|filesEditor\|getValue\|files-editor-content" l4d2web/l4d2web/static/js/files-overlay.js`
|
||||
|
||||
Identify the function that handles a file-row click and currently calls `showModal()` on `#files-editor-modal`, plus the code that stuffs filename + content + language into the empty markup. That whole code path becomes a single `window.openModal(editUrl)` call.
|
||||
|
||||
- [ ] **Step 2: Replace the inline-dialog open path**
|
||||
|
||||
In that function, replace the block that populates and shows the inline dialog with:
|
||||
|
||||
```javascript
|
||||
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(relPath)}`;
|
||||
if (typeof window.openModal === "function") {
|
||||
window.openModal(editUrl);
|
||||
} else {
|
||||
// Graceful fallback if modal-router didn't load — full-page navigation
|
||||
// still hits the same route and renders the standalone editor page.
|
||||
window.location.href = editUrl;
|
||||
}
|
||||
```
|
||||
|
||||
Delete the code that previously read the file via `/files/content` JSON endpoint and set `filenameInput.value`, the language dropdown, byte-count text, `controller.setValue(...)`, and called `showModal()`. The new route delivers all of that as server-rendered HTML.
|
||||
|
||||
Keep untouched:
|
||||
- The **save** handler (POSTs to `/overlays/<id>/files/save` reading `window.__filesEditor.getValue()` — still works inside the modal because CM6 re-init from Task 8 sets `window.__filesEditor` on the new instance)
|
||||
- The **delete** button handler (POSTs to `/overlays/<id>/files/delete`)
|
||||
- The **download** link (now a server-rendered `<a href>` in the template)
|
||||
- The rename hint, replace-file flow, and any other in-modal interactions — these continue to bind on the editor element inside `#modal-content` via the existing event delegation
|
||||
|
||||
- [ ] **Step 3: Chromium verification**
|
||||
|
||||
1. On `/overlays/<id>` (a `files` overlay's page), click an editable file (e.g. `server.cfg`).
|
||||
2. **Expected:** URL updates to `?modal=/overlays/<id>/files/edit?path=server.cfg`. Modal opens with CM6 editor, content pre-filled, language auto-detected.
|
||||
3. Edit content and click **Save**. **Expected:** save succeeds (network request to `/overlays/<id>/files/save` returns 200), file persists.
|
||||
4. Refresh the page (still on the `?modal=` URL). **Expected:** modal reopens with the *saved* (updated) content.
|
||||
5. Click Cancel. **Expected:** modal closes; URL loses `?modal=`.
|
||||
6. Race test: click file A, then immediately click file B before A's swap arrives. **Expected:** modal ends in file B's state, not file A's.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/static/js/files-overlay.js
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(files): file-row click opens editor via URL-addressable modal
|
||||
|
||||
files-overlay.js no longer fetches /files/content JSON and populates
|
||||
the inline <dialog>; it calls window.openModal(<edit-url>) which the
|
||||
modal-router handles end-to-end. Save flow unchanged — CM6 re-init on
|
||||
htmx:afterSwap re-binds window.__filesEditor on the new instance.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Remove the dead inline editor dialog + final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (delete lines 164-228 — the `{% if files_can_edit %} <dialog id="files-editor-modal">…</dialog> {% endif %}` block)
|
||||
- Modify: `l4d2web/tests/test_url_addressable_modals.py` (optional: add a test that the inline dialog is gone)
|
||||
|
||||
- [ ] **Step 1: Write a "dialog removed" assertion test**
|
||||
|
||||
Append to `l4d2web/tests/test_url_addressable_modals.py`:
|
||||
|
||||
```python
|
||||
def test_overlay_detail_no_longer_includes_inline_editor_dialog(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "no-inline-dialog.db")
|
||||
response = client.get(f"/overlays/{overlay_id}")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
# The inline editor dialog is replaced by the URL-addressable route.
|
||||
assert 'id="files-editor-modal"' not in text
|
||||
# The persistent modal-container slot from base.html *is* present.
|
||||
assert 'id="modal-container"' in text
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/test_url_addressable_modals.py::test_overlay_detail_no_longer_includes_inline_editor_dialog -v`
|
||||
|
||||
Expected: FAIL — `id="files-editor-modal"` is still in `overlay_detail.html`.
|
||||
|
||||
- [ ] **Step 3: Remove the inline dialog**
|
||||
|
||||
In `l4d2web/l4d2web/templates/overlay_detail.html`, delete lines 164-228 inclusive — the entire `{% if files_can_edit %} … <dialog id="files-editor-modal"> … </dialog> … {% endif %}` block.
|
||||
|
||||
- [ ] **Step 4: Run all backend tests for regressions**
|
||||
|
||||
Run: `cd l4d2web && uv run pytest tests/ -v --tb=short -q`
|
||||
|
||||
Expected: all tests pass. The new assertion passes; nothing else regresses.
|
||||
|
||||
- [ ] **Step 5: Run the full Chromium verification matrix from the spec**
|
||||
|
||||
Walk through all 10 checks from `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` ## Verification:
|
||||
|
||||
1. Direct link works as full page — paste `/overlays/<id>/files/edit?path=server.cfg` in a new tab, no `?modal=`, full-page editor renders, save works.
|
||||
2. Modal open from overlay — click edit in the file tree, modal opens, URL gets `?modal=`.
|
||||
3. Refresh in modal state — F5 reopens modal on the same overlay with build-status polling resumed.
|
||||
4. Share URL — paste in incognito, lands with modal open.
|
||||
5. Back button — closes modal, URL clears, underlying page intact.
|
||||
6. Forward button — reopens modal with same content.
|
||||
7. Esc to close — URL syncs.
|
||||
8. Race on rapid clicks — final state is the last-clicked file.
|
||||
9. No HTMX poll misclassification — build-status polls don't carry `HX-Modal:1`.
|
||||
10. Existing inline dialogs unaffected — rename, delete, new-folder, conflict-resolution still open from `[data-modal-open]` triggers (these don't use `[data-modal]`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/tests/test_url_addressable_modals.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(modals): remove inline editor dialog, complete pilot migration
|
||||
|
||||
overlay_detail.html no longer carries the empty <dialog
|
||||
id="files-editor-modal"> placeholder — content lives at
|
||||
/overlays/<id>/files/edit?path=… and renders via the URL-addressable
|
||||
modal pipeline. Pilot complete; spec follow-ups (save→hx-post, other
|
||||
modals, server-side URL composition) deferred.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (against the spec)
|
||||
|
||||
- **Architecture (Approach C):** Tasks 5, 6, 7 implement the JS module exactly as specified — ~60 lines including comments and exposed API.
|
||||
- **Layout switch via `HX-Modal: 1` header:** Task 1 implements it as a context processor; Task 1's third test explicitly guards against misclassifying HTMX's built-in `HX-Request` header.
|
||||
- **`<dialog>` for show/hide:** Task 4 adds the persistent slot; Task 5 uses `showModal()`; Tasks 6/7 use `close()` and native `cancel` event.
|
||||
- **Editor as a real page:** Tasks 2 + 3 cover this — separate template, separate route, dual-mode rendering.
|
||||
- **CodeMirror re-init:** Task 8 covers `initEditors(root)` exposure + `htmx:afterSwap` listener.
|
||||
- **Save flow stays AJAX:** Task 9 preserves the save path while replacing the *open* path.
|
||||
- **Race guard, dismiss attrs, Esc, backdrop click, popstate, bootstrap:** all in Tasks 5–7.
|
||||
- **Out of scope items** (binary replace, other modals, save-flow migration, server-side URL composition): not touched by any task — matches the spec's deferral.
|
||||
|
||||
No placeholders, no `TODO`s, no "implement appropriately." Every step has exact paths and exact code.
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
# URL-Addressable Modals — Design (pilot: file editor)
|
||||
|
||||
## Context
|
||||
|
||||
Modals in left4me today are inline `<dialog>` elements pre-rendered into every
|
||||
page that needs them, opened/closed via JS only. There is no URL state, no
|
||||
deep linking, no "share this view" affordance, and content is populated by
|
||||
client-side state — for the file editor specifically,
|
||||
`l4d2web/l4d2web/static/js/files-overlay.js` fills an empty
|
||||
`<dialog id="files-editor-modal">` template (at
|
||||
`l4d2web/l4d2web/templates/overlay_detail.html:165-228`) from data already on the page.
|
||||
|
||||
We want the swift3 (Rails) pattern: clicking a modal trigger appends
|
||||
`?modal=<path>` to the current URL, the modal content is server-rendered from
|
||||
that path with a layoutless template, and the same URL works as a standalone
|
||||
full page on refresh or direct visit. Underlying-page identity is preserved,
|
||||
which matters here because overlay pages have live-updating regions (build
|
||||
status polled every 2 s, console transcripts) we don't want to lose when
|
||||
someone shares a link with a modal open.
|
||||
|
||||
**Pilot scope:** the file editor only. Its open/render flow migrates to
|
||||
URL-addressable; its save flow stays AJAX (no `<form>`, save button is
|
||||
`type="button"`, current code in `files-overlay.js` reads
|
||||
`window.__filesEditor.getValue()` and POSTs directly). Other inline
|
||||
`<dialog>`s (rename, delete, new folder, conflict-resolution) stay as they
|
||||
are. The new system is additive and lives at a different attribute selector
|
||||
(`[data-modal]`) than the existing inline-dialog triggers
|
||||
(`[data-modal-open]` / `[data-modal-close]`), so they coexist without
|
||||
collision.
|
||||
|
||||
## Architecture — Approach C (Hybrid)
|
||||
|
||||
Three layers with clear boundaries:
|
||||
|
||||
1. **Custom JS module (~50 lines)** owns: click intercept on `[data-modal]`,
|
||||
`?modal=<path>` URL composition, `history.pushState`, `popstate` handling,
|
||||
`<dialog>` open/close, initial-load bootstrap (parse `?modal=` from URL on
|
||||
`DOMContentLoaded`).
|
||||
2. **HTMX (already loaded)** owns: the fetch, response swap into
|
||||
`#modal-content`, loading indicator, error display, swap events. The JS
|
||||
module triggers HTMX via `htmx.ajax('GET', path, …)`.
|
||||
3. **Jinja layout switch** owns: rendering the same route as a full page or a
|
||||
layoutless fragment based on a custom `HX-Modal: 1` request header.
|
||||
Deliberately **not** HTMX's built-in `HX-Request`, which fires on every
|
||||
HTMX request including the existing 2-second build-status poll — using it
|
||||
would misclassify polls as modal renders.
|
||||
|
||||
### Why C and not A (HTMX-native) or B (port swift3 verbatim)
|
||||
|
||||
- **A** depends on `HX-Push-Url` response headers and server-side URL
|
||||
composition using `Referer`. That's fragile on POST redirects and on
|
||||
first-load bootstrap — the server doesn't reliably know the "underlying"
|
||||
URL.
|
||||
- **B** reimplements fetch/swap/loading/error machinery HTMX already provides
|
||||
(~150 lines), gaining only HTMX-independence (hypothetical value).
|
||||
- **C** isolates the one thing HTMX *can't* do well (compose a URL whose path
|
||||
stays put while a query param records the modal target) into the smallest
|
||||
possible module, and lets HTMX do the rest. URL composition is local,
|
||||
deterministic, and obviously correct.
|
||||
|
||||
C is also a stepping stone to A if we ever decide server-side URL composition
|
||||
is preferable; going A → C means rewriting URL state.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. `modal-router.js` (new, ~50 lines)
|
||||
|
||||
Two attribute systems coexist deliberately:
|
||||
|
||||
- `[data-modal]` on links — the new URL-addressable system (this module)
|
||||
- `[data-modal-open]` / `[data-modal-close]` on buttons — the existing inline
|
||||
dialog triggers in `l4d2web/l4d2web/static/js/modal.js` (unchanged, keeps
|
||||
working)
|
||||
|
||||
Module responsibilities:
|
||||
|
||||
```text
|
||||
click on a[data-modal] (left-click only, no modifier keys)
|
||||
→ preventDefault
|
||||
→ openModal(href)
|
||||
|
||||
openModal(path)
|
||||
→ url.searchParams.set('modal', path)
|
||||
→ history.pushState({modal: path}, '', url)
|
||||
→ fetchAndShow(path)
|
||||
|
||||
fetchAndShow(path)
|
||||
→ htmx.ajax('GET', path, {target:'#modal-content', headers:{'HX-Modal':'1'}})
|
||||
→ on success: document.getElementById('modal-container').showModal()
|
||||
→ guard against stale responses (track currentModalPath token; discard
|
||||
swaps whose target path no longer matches)
|
||||
|
||||
closeModal()
|
||||
→ dialog.close()
|
||||
→ url.searchParams.delete('modal')
|
||||
→ history.pushState({}, '', url)
|
||||
→ (deferred — pilot doesn't require it) optional refresh of underlying page
|
||||
content via htmx.ajax
|
||||
|
||||
popstate
|
||||
→ re-read ?modal= from URL
|
||||
→ if set: fetchAndShow; if absent: dialog.close()
|
||||
|
||||
DOMContentLoaded
|
||||
→ if ?modal= present: fetchAndShow
|
||||
|
||||
click on [data-modal-dismiss]
|
||||
→ closeModal()
|
||||
|
||||
dialog 'cancel' event (Esc key, native)
|
||||
→ closeModal() (so the URL syncs)
|
||||
```
|
||||
|
||||
Links-only (not buttons): preserves the "works without JS" property — a
|
||||
plain `<a href>` navigates to the full-page version naturally if the script
|
||||
fails to load.
|
||||
|
||||
### 2. Persistent modal slot in `base.html`
|
||||
|
||||
```html
|
||||
<dialog id="modal-container" class="modal modal-wide">
|
||||
<div id="modal-content"></div>
|
||||
</dialog>
|
||||
```
|
||||
|
||||
Native `<dialog>` chosen over swift3's CSS-class approach because it:
|
||||
|
||||
- gets free focus trap, Esc-to-close, backdrop click behavior
|
||||
- matches the existing modal markup pattern in left4me
|
||||
- the existing CSS at `l4d2web/l4d2web/static/css/components.css:114-166`
|
||||
already styles `dialog.modal`
|
||||
|
||||
### 3. Jinja layout switch via context processor
|
||||
|
||||
```python
|
||||
# l4d2web/l4d2web/app.py (or wherever the Flask app is constructed)
|
||||
@app.context_processor
|
||||
def inject_base_layout():
|
||||
is_modal = request.headers.get('HX-Modal') == '1'
|
||||
return {'base_layout': '_modal_partial.html' if is_modal else 'base.html'}
|
||||
```
|
||||
|
||||
```jinja
|
||||
{# templates/_modal_partial.html — single line #}
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{# Any page template (e.g. the new editor route) #}
|
||||
{% extends base_layout %}
|
||||
{% block content %}…the page content…{% endblock %}
|
||||
```
|
||||
|
||||
Same route, two render modes, zero per-route changes after a template is
|
||||
updated to use `{% extends base_layout %}`.
|
||||
|
||||
### 4. New server route: file editor as a real page
|
||||
|
||||
The dominant cost of the pilot. The current editor markup is empty and
|
||||
populated by `files-overlay.js`. For URL deep-linking to mean anything on
|
||||
refresh, the editor route must server-render the markup with content
|
||||
pre-filled.
|
||||
|
||||
```text
|
||||
GET /overlays/<overlay_id>/files/edit?path=<rel_path>
|
||||
→ load file by path from the overlay's filesystem
|
||||
→ render templates/overlay_file_editor.html with values pre-filled:
|
||||
- filename input value
|
||||
- <textarea data-editor-language="auto">{{ content }}</textarea>
|
||||
- byte count, modification time
|
||||
- download href
|
||||
→ if request.headers['HX-Modal'] == '1':
|
||||
extends _modal_partial.html → returns the dialog body fragment only
|
||||
else:
|
||||
extends base.html → returns full standalone page (overlay nav + editor)
|
||||
```
|
||||
|
||||
This template is a **lift-and-shift** of `overlay_detail.html:165-228` into
|
||||
its own template file, with content variables substituted in instead of empty
|
||||
placeholders.
|
||||
|
||||
`files-overlay.js` becomes simpler: the "open editor" code path now becomes
|
||||
just `location.href = <edit-url>` (which the modal-router intercepts and
|
||||
turns into the modal flow). Save flow is unchanged — still calls the
|
||||
existing AJAX POST endpoint reading `window.__filesEditor.getValue()`.
|
||||
|
||||
### 5. CodeMirror re-init after HTMX swap
|
||||
|
||||
`l4d2web/l4d2web/static/js/editor.js` currently mounts CM6 at
|
||||
`DOMContentLoaded` only (see the `if (document.readyState === "loading")`
|
||||
branch at the end of the file). After the editor HTML is swapped in via
|
||||
HTMX, the new `<textarea data-editor-language>` is in the DOM but unmounted.
|
||||
Fix:
|
||||
|
||||
- Refactor `editor.js` to expose `window.__editor.initEditors(root)` covering
|
||||
the existing init logic, parameterized by a DOM root (defaulting to
|
||||
`document`).
|
||||
- Add an `htmx:afterSwap` listener: if `event.target.id === 'modal-content'`,
|
||||
call `window.__editor.initEditors(event.target)`.
|
||||
- Add a cleanup hook: when the modal closes, call `controller.destroy()` on
|
||||
the CM6 instance to free listeners (the editor bundle should already
|
||||
expose this on the controller; verify at implementation time).
|
||||
|
||||
## Critical files to modify
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `l4d2web/l4d2web/static/js/modal-router.js` | new — the ~50-line module |
|
||||
| `l4d2web/l4d2web/static/js/editor.js` | expose `initEditors(root)`, add `htmx:afterSwap` listener |
|
||||
| `l4d2web/l4d2web/static/js/files-overlay.js` | change "open editor" path to set `location.href` (intercepted) instead of populating the inline dialog |
|
||||
| `l4d2web/l4d2web/templates/base.html` | add persistent `#modal-container` slot; include `modal-router.js` |
|
||||
| `l4d2web/l4d2web/templates/_modal_partial.html` | new — single-line layout |
|
||||
| `l4d2web/l4d2web/templates/overlay_file_editor.html` | new — lifted from `overlay_detail.html:165-228` with content variables |
|
||||
| `l4d2web/l4d2web/templates/overlay_detail.html` | remove the inline `<dialog id="files-editor-modal">` (it is now a route) |
|
||||
| `l4d2web/l4d2web/routes/files_routes.py` | add `GET /overlays/<id>/files/edit` |
|
||||
| `l4d2web/l4d2web/app.py` (or app-factory location) | add the `inject_base_layout` context processor |
|
||||
|
||||
## Verification
|
||||
|
||||
End-to-end checks (manual + Chromium):
|
||||
|
||||
1. **Direct link works as full page.** Open `/overlays/<id>/files/edit?path=server.cfg` in a new tab. Editor renders standalone with content. Save works (AJAX).
|
||||
2. **Modal open from overlay.** From `/overlays/<id>`, click a file's edit link. URL becomes `/overlays/<id>?modal=/overlays/<id>/files/edit?path=server.cfg`. Modal opens with content. CM6 mounted, language detected from filename, byte count correct.
|
||||
3. **Refresh in modal state.** With modal open, hit refresh. Same modal opens, same content, on the same underlying overlay page (which has resumed its 2-second build-status polling).
|
||||
4. **Share URL.** Copy the modal-state URL, open in incognito. Lands on the overlay page with modal already open.
|
||||
5. **Back button.** With modal open, click back. URL loses `?modal=`, modal closes, underlying page intact (no flicker, polling continues).
|
||||
6. **Forward button.** After back, click forward. Modal re-opens with the right file.
|
||||
7. **Esc to close.** Native `<dialog>` `cancel` event fires; URL clears.
|
||||
8. **Race on rapid clicks.** Click edit on file A, immediately click edit on file B. Modal ends in file B's state.
|
||||
9. **No HTMX poll misclassification.** Open dev tools network panel during a modal session. Build-status polls do not carry `HX-Modal: 1` and do not trigger modal-router code.
|
||||
10. **Existing inline dialogs unaffected.** Rename, delete, new-folder, conflict-resolution modals still open from their `data-modal-open` triggers.
|
||||
|
||||
## Out of scope (follow-ups)
|
||||
|
||||
- Migrating the editor save flow from AJAX to `hx-post` + `HX-Redirect` (a real `<form>`)
|
||||
- Migrating other modals (rename, delete, new-folder, conflict) to the URL-addressable pattern
|
||||
- Server-side URL composition (Approach A migration) — only if a concrete need emerges
|
||||
- `[data-modal]` on non-`<a>` elements (buttons that trigger modals without an `href`) — kept links-only for now to preserve the "works without JS" property
|
||||
|
|
@ -61,13 +61,6 @@ def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
|||
|
||||
app.add_template_filter(format_time_html, "timeago")
|
||||
|
||||
@app.context_processor
|
||||
def inject_base_layout() -> dict[str, str]:
|
||||
from flask import has_request_context
|
||||
|
||||
is_modal = has_request_context() and request.headers.get("HX-Modal") == "1"
|
||||
return {"base_layout": "_modal_partial.html" if is_modal else "base.html"}
|
||||
|
||||
@app.before_request
|
||||
def csrf_protect() -> Response | None:
|
||||
if "csrf_token" not in session:
|
||||
|
|
|
|||
|
|
@ -234,47 +234,6 @@ def overlay_file_content(overlay_id: int):
|
|||
return jsonify({"path": sub_path, "content": content})
|
||||
|
||||
|
||||
@bp.get("/overlays/<int:overlay_id>/files/edit")
|
||||
@require_login
|
||||
def overlay_file_edit_page(overlay_id: int):
|
||||
"""Server-rendered editor page. Renders full-page by default or as a
|
||||
layoutless modal fragment when the HX-Modal header is set (see the
|
||||
inject_base_layout context processor in app.py)."""
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
sub_path = request.args.get("path", "")
|
||||
|
||||
result = _load_files_overlay(overlay_id, user)
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
overlay = result
|
||||
|
||||
try:
|
||||
target = safe_resolve_for_listing(overlay.path, sub_path)
|
||||
except ValueError:
|
||||
return Response("invalid path", status=400)
|
||||
|
||||
if not target.exists() or not target.is_file():
|
||||
return Response(status=404)
|
||||
if not is_editable(target):
|
||||
return Response("not editable", status=415)
|
||||
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return Response("read failed", status=500)
|
||||
except UnicodeDecodeError:
|
||||
return Response("not editable", status=415)
|
||||
|
||||
return render_template(
|
||||
"overlay_file_editor.html",
|
||||
overlay=overlay,
|
||||
rel_path=sub_path,
|
||||
content=content,
|
||||
byte_count=len(content.encode("utf-8")),
|
||||
)
|
||||
|
||||
|
||||
def _validate_save_content(content: str) -> Response | None:
|
||||
if len(content.encode("utf-8")) > _SAVE_MAX_BYTES:
|
||||
return Response("content exceeds 1 MiB", status=413)
|
||||
|
|
|
|||
|
|
@ -111,8 +111,7 @@ a.button.danger {
|
|||
max-width: 28rem;
|
||||
}
|
||||
|
||||
dialog.modal,
|
||||
div.modal {
|
||||
dialog.modal {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: var(--line);
|
||||
|
|
@ -660,8 +659,7 @@ button.danger-outline:hover {
|
|||
}
|
||||
|
||||
/* Wider modal for the editor (textarea needs the breathing room). */
|
||||
dialog.modal.modal-wide,
|
||||
div.modal.modal-wide {
|
||||
dialog.modal.modal-wide {
|
||||
width: min(48rem, 92vw);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,12 +41,11 @@
|
|||
}
|
||||
|
||||
async function mountOne(textarea) {
|
||||
if (textarea.__editorController) return; // idempotency: don't double-mount
|
||||
let lang = textarea.getAttribute("data-editor-language") || "plain";
|
||||
let filenameInput = null;
|
||||
let dropdown = null;
|
||||
if (lang === "auto") {
|
||||
const modal = textarea.closest("#files-editor-modal, #files-editor-fragment") || document;
|
||||
const modal = textarea.closest("#files-editor-modal") || document;
|
||||
filenameInput = modal.querySelector("[data-editor-filename]");
|
||||
dropdown = modal.querySelector("[data-editor-language-select]");
|
||||
lang = resolveAutoLanguage(filenameInput);
|
||||
|
|
@ -87,31 +86,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
function initEditors(root) {
|
||||
const scope = root || document;
|
||||
for (const ta of scope.querySelectorAll("textarea[data-editor-language]")) {
|
||||
function init() {
|
||||
for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
|
||||
mountOne(ta).catch(err => {
|
||||
console.error("[editor] mount failed", err);
|
||||
unhideTextarea(ta);
|
||||
unhideTextarea(ta); // restore the form-usable raw textarea
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => initEditors(document));
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
initEditors(document);
|
||||
}
|
||||
|
||||
// Re-init editors that arrive via HTMX swap (modal content, etc.).
|
||||
document.body.addEventListener("htmx:afterSwap", (event) => {
|
||||
if (event.target && event.target.id === "modal-content") {
|
||||
initEditors(event.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for callers that need to re-mount imperatively.
|
||||
if (window.__editor) {
|
||||
window.__editor.initEditors = initEditors;
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -89,14 +89,13 @@
|
|||
options.credentials = "same-origin";
|
||||
const response = await fetch(url, options);
|
||||
let body = null;
|
||||
let rawText = "";
|
||||
try {
|
||||
rawText = await response.text();
|
||||
body = rawText ? JSON.parse(rawText) : null;
|
||||
const text = await response.text();
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch (_e) {
|
||||
body = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, body, rawText };
|
||||
return { ok: response.ok, status: response.status, body };
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
|
|
@ -590,79 +589,6 @@
|
|||
}
|
||||
});
|
||||
|
||||
// ---------- modal-content save / delete (URL-addressable modal) ----------
|
||||
// The new server-rendered editor (loaded into #modal-content) has its own
|
||||
// .files-editor-save and .files-editor-delete buttons. Those are not the
|
||||
// same elements as editorEls.saveBtn / editorEls.deleteBtn (which live in
|
||||
// the old inline dialog still present for create-new-file / binary flows).
|
||||
// Use event delegation so the handlers fire on dynamically swapped content.
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const modalContent = document.getElementById("modal-content");
|
||||
if (!modalContent) return;
|
||||
|
||||
const saveBtn = event.target.closest(".files-editor-save");
|
||||
if (saveBtn && modalContent.contains(saveBtn)) {
|
||||
const ta = modalContent.querySelector("textarea[data-rel-path]");
|
||||
if (!ta) return;
|
||||
const relPath = ta.dataset.relPath;
|
||||
if (!relPath) return;
|
||||
const content = (window.__filesEditor && window.__filesEditor.getValue)
|
||||
? window.__filesEditor.getValue()
|
||||
: ta.value;
|
||||
|
||||
// Rename-on-save: if the user edited the filename input, compose the
|
||||
// new path (sibling rename only — joining parent of relPath with the
|
||||
// new filename). Send payload.new_path so the server moves and writes
|
||||
// atomically. Matches the legacy save handler's contract.
|
||||
const filenameInput = modalContent.querySelector("[data-editor-filename]");
|
||||
const editedFilename = filenameInput ? filenameInput.value.trim() : "";
|
||||
const originalLeaf = relPath.split("/").pop() || relPath;
|
||||
const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
|
||||
let newPath = null;
|
||||
if (editedFilename && editedFilename !== originalLeaf) {
|
||||
newPath = parent ? `${parent}/${editedFilename}` : editedFilename;
|
||||
}
|
||||
|
||||
const payload = { path: relPath, content };
|
||||
if (newPath) payload.new_path = newPath;
|
||||
|
||||
const r = await postJson(`${baseUrl}/files/save`, payload);
|
||||
if (r.ok) {
|
||||
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
|
||||
// open so the user can pick a different filename.
|
||||
alert(r.rawText || `Conflict: destination already exists.`);
|
||||
return;
|
||||
} else {
|
||||
alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteBtn = event.target.closest(".files-editor-delete");
|
||||
if (deleteBtn && modalContent.contains(deleteBtn)) {
|
||||
const ta = modalContent.querySelector("textarea[data-rel-path]");
|
||||
if (!ta) return;
|
||||
const relPath = ta.dataset.relPath;
|
||||
if (!relPath) return;
|
||||
if (!confirm(`Delete ${relPath}?`)) return;
|
||||
const fd = new FormData();
|
||||
fd.append("path", relPath);
|
||||
fd.append("csrf_token", csrfToken);
|
||||
const r = await postForm(`${baseUrl}/files/delete`, fd);
|
||||
if (r.ok) {
|
||||
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
||||
scheduleRefresh(parentOf(relPath));
|
||||
} else {
|
||||
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- new-folder modal --------------------------------------------
|
||||
|
||||
function openNewFolder(targetFolder) {
|
||||
|
|
@ -1068,20 +994,7 @@
|
|||
window.location.href = url;
|
||||
} else if (op === "edit") {
|
||||
const editable = action.dataset.editable === "1";
|
||||
if (editable) {
|
||||
// Editable text files: open via URL-addressable modal.
|
||||
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
|
||||
if (typeof window.modals?.openRouted === "function") {
|
||||
window.modals.openRouted(editUrl);
|
||||
} else {
|
||||
// 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;
|
||||
}
|
||||
} else {
|
||||
// Binary files: keep old inline dialog (binary replace deferred from pilot).
|
||||
openEditorForFile(path, false);
|
||||
}
|
||||
openEditorForFile(path, editable);
|
||||
} else if (op === "delete") {
|
||||
const kind = action.dataset.rowKind;
|
||||
const name = action.dataset.rowName || basename(path);
|
||||
|
|
|
|||
34
l4d2web/l4d2web/static/js/modal.js
Normal file
34
l4d2web/l4d2web/static/js/modal.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
// 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 +0,0 @@
|
|||
{# 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 "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-inline-modal-open="reset-server-modal">reset</button>
|
||||
<button type="button" class="danger" data-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-inline-modal-open="delete-user-{{ user.id }}-modal">Delete</button>
|
||||
<button type="button" class="danger-outline" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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>
|
||||
|
|
|
|||
|
|
@ -37,13 +37,10 @@
|
|||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<dialog id="modal-container" class="modal modal-wide">
|
||||
<div id="modal-content"></div>
|
||||
</dialog>
|
||||
<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/modals.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modal.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-inline-modal-open="delete-blueprint-modal">Delete blueprint</button>
|
||||
<a href="#" class="link-button" data-inline-modal-open="rename-blueprint-modal">Rename</a>
|
||||
<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>
|
||||
</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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-open="create-blueprint-modal">+ Create</button>
|
||||
<button type="button" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-open="delete-overlay-modal">Delete overlay</button>
|
||||
<a href="#" class="link-button" data-inline-modal-open="rename-overlay-modal">Rename</a>
|
||||
<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>
|
||||
</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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close data-files-conflict-action="cancel">Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="button" class="danger files-delete-confirm">Delete</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
{% extends base_layout %}
|
||||
{% block title %}Edit {{ rel_path }} · {{ overlay.name }}{% endblock %}
|
||||
{% block extra_head %}{% include "_editor_assets.html" %}{% endblock %}
|
||||
{% block content %}
|
||||
<div id="files-editor-fragment" aria-labelledby="files-editor-title">
|
||||
<div class="modal-header">
|
||||
<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-routed-modal-dismiss aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="files-editor-field">
|
||||
<span class="files-field-label">Filename</span>
|
||||
<input type="text" class="files-editor-filename" data-editor-filename autocomplete="off" spellcheck="false" value="{{ rel_path }}">
|
||||
</label>
|
||||
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code> → <code class="files-rename-to"></code>.</p>
|
||||
|
||||
<div class="files-editor-text">
|
||||
<label class="files-editor-field files-editor-language-field">
|
||||
<span class="files-field-label">Language</span>
|
||||
<select data-editor-language-select aria-label="Editor language">
|
||||
<option value="auto">auto (from filename)</option>
|
||||
<option value="srccfg">srccfg (.cfg)</option>
|
||||
<option value="bash">bash (.sh)</option>
|
||||
<option value="plain">plain</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="files-editor-field">
|
||||
<span class="files-field-label">Content</span>
|
||||
<div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto" data-overlay-id="{{ overlay.id }}" data-rel-path="{{ rel_path }}">{{ content }}</textarea></div>
|
||||
</label>
|
||||
<div class="files-editor-meta muted">
|
||||
<span class="files-editor-byte-count">UTF-8 · {{ byte_count }} bytes</span>
|
||||
<span>Ctrl+S to save</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer files-editor-footer">
|
||||
<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-routed-modal-dismiss>Cancel</button>
|
||||
<button type="button" class="files-editor-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Overlays</h1>
|
||||
<button type="button" data-inline-modal-open="create-overlay-modal">+ Create</button>
|
||||
<button type="button" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-open="delete-server-modal">Delete server</button>
|
||||
<a href="#" class="link-button" data-inline-modal-open="rename-server-modal">Rename</a>
|
||||
<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>
|
||||
</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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-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-inline-modal-open="create-server-modal">+ Create</button>
|
||||
<button type="button" data-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-inline-modal-close aria-label="Close">×</button>
|
||||
<button type="button" class="modal-close" data-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-inline-modal-close>Cancel</button>
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
<button type="submit">Create server</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from flask import render_template_string
|
||||
|
||||
from l4d2web.app import create_app
|
||||
from l4d2web.auth import hash_password
|
||||
from l4d2web.db import init_db, session_scope
|
||||
from l4d2web.models import Overlay, User
|
||||
|
||||
|
||||
def _make_app(tmp_path, monkeypatch, db_name: str):
|
||||
db_url = f"sqlite:///{tmp_path/db_name}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
return create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
|
||||
|
||||
def test_base_layout_is_modal_partial_when_hx_modal_header_set(tmp_path, monkeypatch):
|
||||
app = _make_app(tmp_path, monkeypatch, "layout-modal.db")
|
||||
with app.test_request_context("/", headers={"HX-Modal": "1"}):
|
||||
assert render_template_string("{{ base_layout }}") == "_modal_partial.html"
|
||||
|
||||
|
||||
def test_base_layout_is_base_html_for_normal_request(tmp_path, monkeypatch):
|
||||
app = _make_app(tmp_path, monkeypatch, "layout-default.db")
|
||||
with app.test_request_context("/"):
|
||||
assert render_template_string("{{ base_layout }}") == "base.html"
|
||||
|
||||
|
||||
def test_base_layout_does_not_react_to_plain_hx_request_header(tmp_path, monkeypatch):
|
||||
# HTMX sets HX-Request on every request including the build-status poll;
|
||||
# only HX-Modal should switch the layout.
|
||||
app = _make_app(tmp_path, monkeypatch, "layout-hxreq.db")
|
||||
with app.test_request_context("/", headers={"HX-Request": "true"}):
|
||||
assert render_template_string("{{ base_layout }}") == "base.html"
|
||||
|
||||
|
||||
def _auth_client_with_files_overlay(tmp_path, monkeypatch, db_name: str):
|
||||
db_url = f"sqlite:///{tmp_path/db_name}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
overlay = Overlay(name="cfgs", path="", type="files", user_id=user.id)
|
||||
session.add(overlay)
|
||||
session.flush()
|
||||
overlay.path = str(overlay.id)
|
||||
overlay_root = tmp_path / "overlays" / str(overlay.id)
|
||||
overlay_root.mkdir(parents=True)
|
||||
(overlay_root / "server.cfg").write_text("hostname \"left4me\"\nrcon_password \"hunter2\"\n", encoding="utf-8")
|
||||
user_id = user.id
|
||||
overlay_id = overlay.id
|
||||
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
|
||||
return client, overlay_id
|
||||
|
||||
|
||||
def test_edit_route_renders_full_page_without_modal_header(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-full.db")
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=server.cfg")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "<!doctype html>" in text.lower() # full base.html rendered
|
||||
assert 'href="/dashboard"' in text # nav present
|
||||
assert 'class="files-editor-content"' in text
|
||||
assert 'rcon_password' in text # content pre-filled
|
||||
|
||||
|
||||
def test_edit_route_renders_fragment_with_modal_header(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-fragment.db")
|
||||
response = client.get(
|
||||
f"/overlays/{overlay_id}/files/edit?path=server.cfg",
|
||||
headers={"HX-Modal": "1"},
|
||||
)
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "<html" not in text # layoutless
|
||||
assert 'class="primary-nav"' not in text
|
||||
assert 'class="files-editor-content"' in text
|
||||
assert "hostname" in text # content pre-filled
|
||||
|
||||
|
||||
def test_edit_route_404s_for_missing_file(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-404.db")
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=nonexistent.cfg")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_edit_route_415s_for_non_editable_file(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-415.db")
|
||||
# Forge a non-editable file by writing binary garbage.
|
||||
from pathlib import Path
|
||||
overlay_root = tmp_path / "overlays" / str(overlay_id)
|
||||
Path(overlay_root).joinpath("blob.bin").write_bytes(b"\x00\x01\x02\x03" * 1024)
|
||||
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=blob.bin")
|
||||
assert response.status_code == 415
|
||||
|
||||
|
||||
def test_edit_route_400s_for_path_traversal(tmp_path, monkeypatch):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "edit-400.db")
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=../../etc/passwd")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
|
||||
db_url = f"sqlite:///{tmp_path/'edit-script-overlay.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
with session_scope() as s:
|
||||
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
|
||||
s.add(user)
|
||||
s.flush()
|
||||
overlay = Overlay(name="scripted", path="", type="script", user_id=user.id)
|
||||
s.add(overlay)
|
||||
s.flush()
|
||||
overlay.path = str(overlay.id)
|
||||
(tmp_path / "overlays" / str(overlay.id)).mkdir(parents=True)
|
||||
user_id = user.id
|
||||
overlay_id = overlay.id
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["pw_changed_at"] = datetime.now(UTC).isoformat()
|
||||
|
||||
response = client.get(f"/overlays/{overlay_id}/files/edit?path=anything.cfg")
|
||||
assert response.status_code == 404
|
||||
Loading…
Reference in a new issue