Two modal pipelines coexisted after the URL-addressable pilot — modal.js
(inline, ~30 lines) and modal-router.js (routed, ~150 lines) — operating
on different attribute namespaces and exposing different APIs. Future
modal authors had two systems to learn with no naming convention to
help them pick the right one for a given use case.
Consolidates both into static/js/modals.js with two clearly-named
pipelines and a single window.modals.* API:
Inline modal — content pre-rendered in the page.
Hooks: data-inline-modal-open="<dialog-id>"
data-inline-modal-close
API: window.modals.openInline(idOrEl)
window.modals.closeInline(idOrEl)
Use: confirmations, transient prompts, in-page forms without
URL value.
Routed modal — content fetched from a URL, ?modal=<path> in URL,
with history + share-link + refresh-survival.
Hooks: <a data-routed-modal href="<path>">
data-routed-modal-dismiss
API: window.modals.openRouted(path)
window.modals.closeRouted()
Use: content with standalone-page meaning.
Single document-level click delegation handles all four attribute
hooks; one DOMContentLoaded handler binds dialog 'close' / 'cancel' /
backdrop on the routed slot; shared popstate and htmx:responseError
listeners. Behaviour unchanged — pure rename + colocation.
Renamed across 11 templates and files-overlay.js. Old data-modal-*
attributes and window.openModal/closeModal globals are gone — clean
break (no back-compat shims). AGENTS.md "Modals: inline vs routed"
section documents the decision guide for new modals.
Verified: 573 backend tests pass. 5/5 Chromium smoke checks pass
(inline open/close, Esc, backdrop, routed open+save, routed Esc).
Console clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
6 KiB
HTML
131 lines
6 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Server {{ server.name }} | left4me{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="panel">
|
|
<div class="page-heading">
|
|
<h1>Server: {{ server.name }}</h1>
|
|
</div>
|
|
|
|
<dl class="server-info">
|
|
<div><dt>Port</dt><dd><a href="steam://run/550//+connect%20{{ connect_host }}:{{ server.port }}">{{ server.port }}</a></dd></div>
|
|
<div><dt>Blueprint</dt><dd>{% if blueprint %}<a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a>{% endif %}</dd></div>
|
|
<div><dt>RCON Password</dt><dd><span class="password-mask" data-password-field="{{ server.id }}">••••••••••••</span><span class="password-value" data-password-field="{{ server.id }}" hidden>{{ server.rcon_password }}</span> <button class="link-button" data-password-toggle="{{ server.id }}" aria-label="Show RCON password">show</button></dd></div>
|
|
<div><dt>Hostname</dt>
|
|
<dd>
|
|
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
|
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
|
<input name="hostname" value="{{ server.hostname }}" placeholder="{{ g.user.username }} {{ server.name }}" maxlength="128">
|
|
<button type="submit">Save</button>
|
|
<span class="field-hint">Leave empty for auto: "{{ g.user.username }} {{ server.name }}"</span>
|
|
</form>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<h2 class="section-title">Actions</h2>
|
|
{% include "_server_actions.html" %}
|
|
|
|
<h2 class="section-title">Server Log</h2>
|
|
<pre class="log-stream" data-sse-url="/servers/{{ server.id }}/logs/stream"></pre>
|
|
|
|
<section class="panel live-state"
|
|
hx-get="/servers/{{ server.id }}/live-state"
|
|
hx-trigger="load, every 5s"
|
|
hx-swap="innerHTML">
|
|
</section>
|
|
|
|
<h2 class="section-title">Console</h2>
|
|
<section class="panel console-panel">
|
|
<div id="console-transcript-{{ server.id }}"
|
|
class="console-transcript"
|
|
data-autoscroll>
|
|
{% for h in console_history %}
|
|
{% with command=h.command, reply=h.reply, is_error=h.is_error %}
|
|
{% include "_console_line.html" %}
|
|
{% endwith %}
|
|
{% endfor %}
|
|
</div>
|
|
<form hx-post="/servers/{{ server.id }}/console"
|
|
hx-target="#console-transcript-{{ server.id }}"
|
|
hx-swap="beforeend"
|
|
hx-indicator=".console-spinner"
|
|
hx-on::after-request="this.command.value=''; this.command.focus(); this.closest('section').querySelector('[data-autoscroll]').scrollTop = 1e9"
|
|
class="console-input-form"
|
|
data-console-form data-server-id="{{ server.id }}">
|
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
|
<span class="console-prompt-glyph">></span>
|
|
<input name="command" autocomplete="off" spellcheck="false" maxlength="1000"
|
|
placeholder="status, changelevel c1m1_hotel, sm_kick …">
|
|
<span class="console-spinner" aria-hidden="true">…</span>
|
|
<button type="submit">Send</button>
|
|
</form>
|
|
</section>
|
|
|
|
<h2 class="section-title">Files</h2>
|
|
{% if not file_tree_root_entries %}
|
|
<p class="muted">No files yet — start the server to mount its runtime.</p>
|
|
{% else %}
|
|
{% set entries = file_tree_root_entries %}
|
|
{% set truncated = file_tree_truncated %}
|
|
{% set truncated_count = file_tree_truncated_count %}
|
|
{% set files_base_url = "/servers/" ~ server.id %}
|
|
{% set download_supported = True %}
|
|
{% include "_overlay_file_tree.html" %}
|
|
{% endif %}
|
|
</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>
|
|
</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>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form method="post" action="/servers/{{ server.id }}" class="inline-save">
|
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
|
<input name="name" value="{{ server.name }}" required autofocus>
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</div>
|
|
</dialog>
|
|
|
|
<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>
|
|
</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>
|
|
<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>
|
|
</form>
|
|
</div>
|
|
</dialog>
|
|
|
|
<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>
|
|
</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>
|
|
<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>
|
|
</form>
|
|
</div>
|
|
</dialog>
|
|
{% endblock %}
|