mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-11-05 18:22:21 +01:00
Groundwork for translating the interface. No strings are extracted yet — this stage only adds the mechanism, so the UI is byte-identical in English. Translations are one flat JSON file per language. An admin can add a language by dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file overrides a built-in one of the same name. Built-ins: en (source) and pl. How it works: - app/i18n.py loads and merges catalogs over en.json, so per-key English fallback is baked in server-side and the JS runtime needs no fallback logic. Catalogs are fingerprinted with stat() per render, so a dropped-in file is live on the next refresh rather than the next restart — which matters, because restarting drops the device connection for up to 60s. - Template text renders server-side via t()/t_html()/tn() from inject_globals(), so the first paint is already correct. That context processor covers every render_template() in the app, including the six standalone iframe pages, so routes/views.py needed no changes. - JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared by all 8 entry points. The hash is in the path, not a query string, so intermediary caches and the service worker bust reliably. - Language = per-browser mc_lang cookie over a server-wide DB default. The cookie is what makes iframes work: they are same-origin, so they send it automatically and the existing modal-open reload wiring needs no changes. t() deliberately does not escape — Jinja autoescape handles that, and escaping here would double-escape every French apostrophe. t_html()/tHtml() escape their params but trust catalog markup. Also fixed along the way: - save_ui_settings() replaced the whole settings blob instead of merging, so a language-only POST would have wiped the toast settings. It worked before only because the one form always submitted every key. - `const t = document.getElementById(...)` in populateUiSettingsForm shadowed the global translation helper; same for `var t` in six theme IIFEs. scripts/i18n_check.py now fails the build if that pattern comes back. - Removed app/templates/contacts.html, dead since the contacts pages were split (no render_template reference anywhere), and two stale CSS comments. Verified locally: catalog route headers/security, drop-in without restart, malformed catalogs logged and skipped rather than offered in the picker, cookie propagation into iframes, and 360px layout. Python and JS agree on interpolation, escaping and Polish plural categories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
365 lines
10 KiB
HTML
365 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="{{ lang }}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>Console - mc-webui</title>
|
|
|
|
{% include "_head_i18n.html" %}
|
|
|
|
<!-- Favicon -->
|
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
|
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
|
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
|
|
|
<!-- Bootstrap 5 CSS (local) -->
|
|
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
|
<!-- Bootstrap Icons (local) -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
|
|
<!-- Custom CSS -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
|
|
<style>
|
|
html, body {
|
|
height: 100%;
|
|
margin: 0;
|
|
background-color: #1a1a2e;
|
|
}
|
|
|
|
.console-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
}
|
|
|
|
.console-header {
|
|
background-color: #16213e;
|
|
border-bottom: 1px solid #0f3460;
|
|
padding: 0.75rem 1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.device-name {
|
|
color: #4ecdc4;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.messages-wrap {
|
|
flex: 1;
|
|
min-height: 0;
|
|
position: relative;
|
|
display: flex;
|
|
}
|
|
|
|
.console-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
background-color: #1a1a2e;
|
|
min-height: 0;
|
|
}
|
|
|
|
.scroll-bottom-btn {
|
|
position: absolute;
|
|
right: 1rem;
|
|
bottom: 1rem;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
border: 1px solid #0f3460;
|
|
background-color: #16213e;
|
|
color: #4ecdc4;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
|
cursor: pointer;
|
|
z-index: 5;
|
|
}
|
|
|
|
.scroll-bottom-btn.show {
|
|
display: flex;
|
|
}
|
|
|
|
.scroll-bottom-btn:hover {
|
|
background-color: #1a1a4e;
|
|
color: #00ff88;
|
|
}
|
|
|
|
.console-message {
|
|
margin-bottom: 1rem;
|
|
font-family: 'Courier New', Consolas, monospace;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.console-message.command {
|
|
color: #00ff88;
|
|
}
|
|
|
|
.console-message.command::before {
|
|
content: '> ';
|
|
color: #888;
|
|
}
|
|
|
|
.console-message.response {
|
|
color: #e0e0e0;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
background-color: #16213e;
|
|
padding: 0.5rem;
|
|
border-radius: 0.25rem;
|
|
border-left: 3px solid #0f3460;
|
|
}
|
|
|
|
.console-message.error {
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.console-message.system {
|
|
color: #4ecdc4;
|
|
font-style: italic;
|
|
}
|
|
|
|
.console-message.historic {
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.history-divider {
|
|
border: none;
|
|
border-top: 1px dashed #0f3460;
|
|
margin: 0.5rem 0 1rem 0;
|
|
}
|
|
|
|
.console-input-area {
|
|
background-color: #16213e;
|
|
border-top: 1px solid #0f3460;
|
|
padding: 0.75rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.console-input {
|
|
background-color: #0f3460;
|
|
border: 1px solid #1a1a2e;
|
|
color: #00ff88;
|
|
font-family: 'Courier New', Consolas, monospace;
|
|
}
|
|
|
|
.console-input:focus {
|
|
background-color: #0f3460;
|
|
border-color: #00ff88;
|
|
color: #00ff88;
|
|
box-shadow: 0 0 0 0.2rem rgba(0, 255, 136, 0.25);
|
|
}
|
|
|
|
.console-input::placeholder {
|
|
color: #666;
|
|
}
|
|
|
|
.console-input:disabled {
|
|
background-color: #0a1628;
|
|
color: #444;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
}
|
|
|
|
.status-dot.connected {
|
|
background-color: #00ff88;
|
|
}
|
|
|
|
.status-dot.disconnected {
|
|
background-color: #ff6b6b;
|
|
}
|
|
|
|
.status-dot.connecting {
|
|
background-color: #ffd93d;
|
|
animation: pulse 1s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
/* Loading spinner for pending commands */
|
|
.console-message.pending::after {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border: 2px solid #4ecdc4;
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-left: 0.5rem;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* History dropdown */
|
|
.history-dropdown {
|
|
position: relative;
|
|
}
|
|
|
|
.history-btn {
|
|
background-color: #0f3460;
|
|
border: 1px solid #1a1a2e;
|
|
color: #4ecdc4;
|
|
}
|
|
|
|
.history-btn:hover, .history-btn:focus {
|
|
background-color: #1a1a4e;
|
|
border-color: #4ecdc4;
|
|
color: #4ecdc4;
|
|
}
|
|
|
|
.history-btn:disabled {
|
|
background-color: #0a1628;
|
|
color: #444;
|
|
}
|
|
|
|
.history-menu {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
min-width: 250px;
|
|
max-width: 100%;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
background-color: #16213e;
|
|
border: 1px solid #0f3460;
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 0.25rem;
|
|
display: none;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.history-menu.show {
|
|
display: block;
|
|
}
|
|
|
|
.history-item {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
color: #e0e0e0;
|
|
text-decoration: none;
|
|
font-family: 'Courier New', Consolas, monospace;
|
|
font-size: 0.85rem;
|
|
border: none;
|
|
background: none;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.history-item:hover {
|
|
background-color: #0f3460;
|
|
color: #00ff88;
|
|
}
|
|
|
|
.history-empty {
|
|
padding: 0.75rem;
|
|
color: #666;
|
|
text-align: center;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Mobile adjustments */
|
|
@media (max-width: 576px) {
|
|
.console-header {
|
|
padding: 0.5rem 0.75rem;
|
|
}
|
|
|
|
.console-header h6 {
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.console-messages {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.console-message {
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.console-input-area {
|
|
padding: 0.5rem;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="console-container">
|
|
<!-- Header with status indicator -->
|
|
<div class="console-header d-flex justify-content-between align-items-center">
|
|
<small class="device-name">{{ device_name }}</small>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="status-dot" id="statusDot"></span>
|
|
<small class="text-muted" id="statusText">Connecting...</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Messages Area -->
|
|
<div class="messages-wrap">
|
|
<div class="console-messages" id="consoleMessages">
|
|
<div class="console-message system">Type 'help' for available commands.</div>
|
|
</div>
|
|
<button type="button" class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to latest" aria-label="Scroll to latest">
|
|
<i class="bi bi-arrow-down"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Input Area -->
|
|
<div class="console-input-area">
|
|
<form id="consoleForm" class="d-flex gap-2">
|
|
<!-- History dropdown -->
|
|
<div class="history-dropdown">
|
|
<button type="button" class="btn history-btn" id="historyBtn" title="Command history" disabled>
|
|
<i class="bi bi-clock-history"></i>
|
|
</button>
|
|
<div class="history-menu" id="historyMenu">
|
|
<div class="history-empty">No commands in history</div>
|
|
</div>
|
|
</div>
|
|
<!-- Clear output transcript -->
|
|
<button type="button" class="btn history-btn" id="clearOutputBtn" title="Clear output history">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
<input type="text"
|
|
id="commandInput"
|
|
class="form-control console-input"
|
|
placeholder="Enter command..."
|
|
autocomplete="off"
|
|
autocapitalize="off"
|
|
spellcheck="false"
|
|
disabled>
|
|
<button type="submit" class="btn btn-success" id="sendBtn" disabled>
|
|
<i class="bi bi-send"></i>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Socket.IO client -->
|
|
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
|
|
<!-- Bootstrap JS Bundle (local) -->
|
|
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
|
<!-- Console JS -->
|
|
<script src="{{ url_for('static', filename='js/console.js') }}"></script>
|
|
</body>
|
|
</html>
|