mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-11-08 11:42:21 +01:00
MeshCore clients share contacts, channels and positions as plain text in ordinary chat messages. Until now those arrived as unreadable raw tokens. They now render as interactive cards in both the channel chat and DMs. Three wire formats, none of them ours to choose — they are what the official app emits, so share-tokens.js stays byte-compatible with it: contact <64-hex-pubkey:type:name> (compact, not a URI) channel meshcore://channel/add?name=&secret=®ion_scope= location bare "lat,lon" The contact URI form is parsed too (QR codes carry it) but never emitted: with a name like "MarWoj 💡📱 mobile" it costs 153 bytes and cannot fit a 135-byte channel packet, where the compact token needs 91. Parsing runs BEFORE HTML escaping, leaving private-use-area placeholders that the later passes cannot match. Tokens do not survive the existing pipeline otherwise: escaping breaks a URI's '&', processChannelLinks eats a '#channel' name, processMentions an '@' in a contact name, and the token's closing '>' collides with quote syntax. Cards resolve their action before rendering, so the button states what will actually happen rather than surprising the user afterwards: contact Add / Update name to X / Already in contacts / Push to device channel Add / Already joined / Name used by a different key The public key is the identity — a known key under a new name is a rename, never a second contact. Shared contacts land in the local cache rather than device memory: they arrive unsolicited and contact slots are limited, so promotion stays deliberate. The card offers it inline once cached, so it costs one click rather than a trip to the Contacts panel. New: manual-add accepts target=cache, which needs no device connection. Location cards add distance and bearing from our own advertised position, plus map/copy/external-map actions. A shared channel's region scope is carried over on join, since transmitting under the firmware default would not match the sender. Coordinate detection is deliberately narrow — both numbers need 3+ decimals, ranges are checked, and matches inside URLs are skipped — so prose like "1,5 osoby" or "wersja 2,10" and Google Maps links are left alone. Verified against real payloads received from another device on #mcwebui, covering all three formats plus a contact URI and emoji names: 65 parser assertions, 57 render assertions, i18n check clean at pl 100%, and both themes screenshotted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1658 lines
115 KiB
HTML
1658 lines
115 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>{% block title %}mc-webui{% endblock %}</title>
|
|
|
|
<!-- 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') }}">
|
|
|
|
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
|
<script>
|
|
(function() {
|
|
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
})();
|
|
</script>
|
|
|
|
<!-- Sidebar breakpoint: apply saved preference before CSS loads to prevent layout flash -->
|
|
<script>
|
|
(function() {
|
|
try {
|
|
var stored = parseInt(localStorage.getItem('mc-webui-sidebar-breakpoint'), 10);
|
|
var bp = (isNaN(stored) || stored < 600 || stored > 2000) ? 992 : stored;
|
|
if (window.innerWidth >= bp) {
|
|
document.documentElement.classList.add('layout-wide');
|
|
}
|
|
} catch (e) {}
|
|
})();
|
|
</script>
|
|
|
|
{% include "_head_i18n.html" %}
|
|
|
|
<!-- 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') }}">
|
|
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin="" />
|
|
|
|
<!-- Custom CSS -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
<!-- Theme CSS (light/dark mode) -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
|
|
|
{% block extra_head %}{% endblock %}
|
|
</head>
|
|
<body>
|
|
<!-- Navbar -->
|
|
<nav class="navbar navbar-dark bg-primary">
|
|
<div class="container-fluid">
|
|
<span class="navbar-brand mb-0 h1">
|
|
<i class="bi bi-broadcast"></i> mc-webui
|
|
{% if device_name %}
|
|
<small class="text-white-50 d-none d-sm-inline">- {{ device_name }}</small>
|
|
{% endif %}
|
|
{% if transport_type == 'ble' %}
|
|
<span class="badge bg-info ms-1 d-none d-sm-inline" title="{{ t('nav.ble_title') }}">BLE</span>
|
|
{% elif transport_type == 'tcp' %}
|
|
<span class="badge bg-warning text-dark ms-1 d-none d-sm-inline" title="{{ t('nav.tcp_title') }}">TCP</span>
|
|
{% endif %}
|
|
</span>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<div id="notificationBell" class="btn btn-outline-light position-relative navbar-touch-btn" style="cursor: pointer;" onclick="markAllChannelsRead()" title="{{ t('nav.mark_all_read_title') }}">
|
|
<i class="bi bi-bell"></i>
|
|
</div>
|
|
<div class="position-relative channel-mobile-selector" id="channelSelectorWrapper" style="min-width: 80px; max-width: 140px;">
|
|
{# A button, not a text input: a text field pops the on-screen
|
|
keyboard open on every channel switch, and the firmware's
|
|
channel limit makes searching them pointless anyway. #}
|
|
<button type="button"
|
|
id="channelSelectorButton"
|
|
class="form-select navbar-touch-select text-start text-truncate"
|
|
title="{{ t('nav.select_channel_title') }}"
|
|
aria-haspopup="listbox"
|
|
aria-expanded="false">Public</button>
|
|
<div id="channelSelectorDropdown" class="channel-selector-dropdown" style="display: none;"></div>
|
|
</div>
|
|
<button class="btn btn-outline-light navbar-touch-btn" data-bs-toggle="offcanvas" data-bs-target="#mainMenu" title="{{ t('menu.title') }}">
|
|
<i class="bi bi-list"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Offcanvas Menu -->
|
|
<div class="offcanvas offcanvas-end" tabindex="-1" id="mainMenu">
|
|
<div class="offcanvas-header">
|
|
<h5 class="offcanvas-title"><i class="bi bi-menu-button-wide"></i> {{ t('menu.title') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
|
|
</div>
|
|
<div class="px-3 pb-2 text-muted small border-bottom">
|
|
<div class="d-flex align-items-center justify-content-between">
|
|
<span id="versionDisplay">
|
|
<i class="bi bi-tag"></i> <span id="releaseText" class="fw-semibold">{{ release }}</span>
|
|
<span class="badge bg-secondary ms-1" id="branchBadge">{{ git_branch }}</span>
|
|
<!-- Exact build: what pins a deployment down, and what the
|
|
update poller compares - keep it as its own element -->
|
|
<span id="versionText" class="d-block opacity-75" title="{{ t('menu.exact_build_title') }}">{{ version }}</span>
|
|
</span>
|
|
<button id="checkUpdateBtn" class="btn btn-sm btn-outline-secondary py-0 px-1" title="{{ t('menu.check_updates_title') }}">
|
|
<i class="bi bi-arrow-repeat" id="checkUpdateIcon"></i>
|
|
</button>
|
|
</div>
|
|
<div id="updateLinkContainer" class="d-none mt-1"></div>
|
|
</div>
|
|
<div class="offcanvas-body">
|
|
<div class="list-group list-group-flush">
|
|
<!-- Messages -->
|
|
<!-- The Android wrapper has no pull-to-refresh of its own, so
|
|
this is the manual way out of a list that fell behind -->
|
|
<button id="refreshBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" type="button"
|
|
data-bs-dismiss="offcanvas" title="{{ t('menu.refresh_title') }}">
|
|
<i class="bi bi-arrow-clockwise" style="font-size: 1.5rem;"></i>
|
|
<div class="flex-grow-1">
|
|
<div>{{ t('common.refresh') }}</div>
|
|
<small class="d-block text-muted">{{ t('menu.refresh_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="menu-filter" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" type="button">
|
|
<i class="bi bi-funnel" style="font-size: 1.5rem;"></i>
|
|
<div class="flex-grow-1">
|
|
<div>{{ t('menu.filter') }}</div>
|
|
<small class="d-block text-muted">{{ t('menu.filter_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="menu-search" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" type="button"
|
|
data-bs-toggle="modal" data-bs-target="#searchModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-search" style="font-size: 1.5rem;"></i>
|
|
<div class="flex-grow-1">
|
|
<div>{{ t('menu.search') }}</div>
|
|
<small class="d-block text-muted">{{ t('menu.search_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="menu-dm" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" type="button"
|
|
data-bs-toggle="modal" data-bs-target="#dmModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-envelope-fill" style="font-size: 1.5rem;"></i>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span>{{ t('dm.title') }}</span>
|
|
<span class="badge rounded-pill bg-danger d-none menu-badge-dm">0</span>
|
|
</div>
|
|
<small class="d-block text-muted">{{ t('menu.dm_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="menu-contacts" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" type="button"
|
|
data-bs-toggle="modal" data-bs-target="#contactsModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-person-lines-fill" style="font-size: 1.5rem;"></i>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span>{{ t('contacts.title') }}</span>
|
|
<span class="badge rounded-pill bg-warning d-none menu-badge-contacts">0</span>
|
|
</div>
|
|
<small class="d-block text-muted">{{ t('menu.contacts_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#channelsModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-broadcast-pin" style="font-size: 1.5rem;"></i>
|
|
<span>{{ t('menu.channels') }}</span>
|
|
</button>
|
|
<div class="list-group-item">
|
|
<div class="d-flex align-items-center gap-3 mb-2">
|
|
<i class="bi bi-calendar3" style="font-size: 1.5rem;"></i>
|
|
<label for="dateSelector" class="form-label mb-0">{{ t('menu.history') }}</label>
|
|
</div>
|
|
<select id="dateSelector" class="form-select" title="{{ t('menu.select_date_title') }}">
|
|
<option value="">{{ t('menu.today_live') }}</option>
|
|
<!-- Archive dates loaded dynamically via JavaScript -->
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Network -->
|
|
<div class="list-group-item py-2 mt-2">
|
|
<small class="text-muted fw-bold text-uppercase">{{ t('menu.section.network') }}</small>
|
|
</div>
|
|
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" id="advertBtn" title="{{ t('menu.advert_title') }}">
|
|
<i class="bi bi-megaphone" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('menu.advert') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.advert_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3 text-warning" id="floodadvBtn" title="{{ t('menu.flood_advert_title') }}">
|
|
<i class="bi bi-broadcast" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('menu.flood_advert') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.flood_advert_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Tools -->
|
|
<div class="list-group-item py-2 mt-2">
|
|
<small class="text-muted fw-bold text-uppercase">{{ t('menu.section.tools') }}</small>
|
|
</div>
|
|
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3"
|
|
id="mapBtn" title="{{ t('menu.map_title') }}">
|
|
<i class="bi bi-map" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('menu.map') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.map_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="consoleBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#consoleModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-terminal" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('console.title') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.console_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="repeatersBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#repeatersModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-diagram-3" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('repeaters.title') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.repeaters_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="pathAnalyzerBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#pathAnalyzerModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-signpost-split" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('pa.title') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.pa_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- System -->
|
|
<div class="list-group-item py-2 mt-2">
|
|
<small class="text-muted fw-bold text-uppercase">{{ t('menu.section.system') }}</small>
|
|
</div>
|
|
<button id="deviceInfoBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-cpu" style="font-size: 1.5rem;"></i>
|
|
<span>{{ t('menu.device_info') }}</span>
|
|
</button>
|
|
<button id="logsBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#logsModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-journal-text" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('logs.title') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.logs_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button id="menu-settings" class="list-group-item list-group-item-action d-flex align-items-center gap-3 d-none" data-bs-toggle="modal" data-bs-target="#settingsModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-gear" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('common.settings') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.settings_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#backupModal" data-bs-dismiss="offcanvas">
|
|
<i class="bi bi-database-down" style="font-size: 1.5rem;"></i>
|
|
<div>
|
|
<span>{{ t('menu.backup') }}</span>
|
|
<small class="d-block text-muted">{{ t('menu.backup_desc') }}</small>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<main>
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
|
|
<!-- Channels Management Modal -->
|
|
<div class="modal fade" id="channelsModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-broadcast-pin"></i> {{ t('menu.channels') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<!-- Channel List -->
|
|
<h6>{{ t('channels.your') }}</h6>
|
|
<div id="channelsList" class="list-group mb-3">
|
|
<div class="text-center text-muted py-3">
|
|
<div class="spinner-border spinner-border-sm"></div> {{ t('common.loading') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="btn-group w-100 mb-3" role="group">
|
|
<button type="button" class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#addChannelForm">
|
|
<i class="bi bi-plus-circle"></i> {{ t('channels.add_new') }}
|
|
</button>
|
|
<button type="button" class="btn btn-success" data-bs-toggle="collapse" data-bs-target="#joinChannelForm">
|
|
<i class="bi bi-box-arrow-in-right"></i> {{ t('channels.join_existing') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Add Channel Form (collapsed) -->
|
|
<div class="collapse" id="addChannelForm">
|
|
<div class="card card-body mb-3">
|
|
<h6>{{ t('channels.create_title') }}</h6>
|
|
<form id="createChannelForm">
|
|
<div class="mb-2">
|
|
<label for="newChannelName" class="form-label">{{ t('channels.name') }}</label>
|
|
<input type="text" class="form-control" id="newChannelName"
|
|
placeholder="{{ t('channels.name_ph') }}"
|
|
pattern="[a-zA-Z0-9_\-]+"
|
|
required>
|
|
<small class="text-muted">{{ t('channels.name_hint') }}</small>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-plus-circle"></i> {{ t('channels.create_btn') }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Join Channel Form (collapsed) -->
|
|
<div class="collapse" id="joinChannelForm">
|
|
<div class="card card-body mb-3">
|
|
<h6>{{ t('channels.join_title') }}</h6>
|
|
<form id="joinChannelFormSubmit">
|
|
<div class="mb-2">
|
|
<label for="joinChannelName" class="form-label">{{ t('channels.name') }}</label>
|
|
<input type="text" class="form-control" id="joinChannelName" required>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label for="joinChannelKey" class="form-label">{{ t('channels.key_label') }} <small class="text-muted">{{ t('channels.key_optional') }}</small></label>
|
|
<input type="text" class="form-control" id="joinChannelKey"
|
|
placeholder="{{ t('channels.key_ph') }}"
|
|
pattern="[a-fA-F0-9]{32}"
|
|
maxlength="32">
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-success">
|
|
<i class="bi bi-box-arrow-in-right"></i> {{ t('channels.join_btn') }}
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" id="scanQRBtn">
|
|
<i class="bi bi-qr-code-scan"></i> {{ t('channels.scan_qr') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Set Channel Region Scope Modal -->
|
|
<div class="modal fade" id="regionPickerModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="bi bi-pin-map"></i>
|
|
{{ t('channels.region_scope_for') }} <span id="regionPickerChannelName" class="fw-bold"></span>
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-info py-2 small mb-3">
|
|
<i class="bi bi-info-circle"></i> {{ t('channels.region_hint') }}
|
|
</div>
|
|
<div id="regionPickerList" class="list-group mb-0"></div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
|
|
<button type="button" class="btn btn-primary btn-sm" id="regionPickerSaveBtn">{{ t('common.save') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Share Channel Modal -->
|
|
<div class="modal fade" id="shareChannelModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-share"></i> {{ t('channels.share_title') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<h6 id="shareChannelName"></h6>
|
|
|
|
<!-- QR Code -->
|
|
<div class="text-center mb-3">
|
|
<img id="shareChannelQR" src="" alt="{{ t('channels.qr_alt') }}" class="img-fluid" style="max-width: 300px;">
|
|
</div>
|
|
|
|
<!-- Channel Details -->
|
|
<div class="mb-3">
|
|
<label class="form-label fw-bold">{{ t('channels.key') }}</label>
|
|
<div class="input-group">
|
|
<input type="text" class="form-control font-monospace" id="shareChannelKey" readonly>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyChannelKey()">
|
|
<i class="bi bi-clipboard"></i> {{ t('common.copy') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert alert-info small">
|
|
<i class="bi bi-info-circle"></i>
|
|
{{ t('channels.share_hint') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Device Info Modal -->
|
|
<div class="modal fade" id="deviceInfoModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-cpu"></i> {{ t('device.title') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabDeviceInfo" type="button">{{ t('device.tab.info') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceStats" type="button" id="statsTabBtn">{{ t('device.tab.stats') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceShare" type="button" id="shareTabBtn">{{ t('device.tab.share') }}</button>
|
|
</li>
|
|
</ul>
|
|
<div class="tab-content">
|
|
<div class="tab-pane fade show active" id="tabDeviceInfo">
|
|
<div id="deviceInfoContent">
|
|
<div class="text-center py-3">
|
|
<div class="spinner-border spinner-border-sm"></div> {{ t('common.loading') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabDeviceStats">
|
|
<div id="deviceStatsContent">
|
|
<div class="text-center py-3 text-muted">
|
|
{{ t('device.stats_hint') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabDeviceShare">
|
|
<div id="deviceShareContent">
|
|
<div class="text-center py-3 text-muted">
|
|
{{ t('device.share_hint') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Settings Modal -->
|
|
<div class="modal fade" id="settingsModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-gear"></i> {{ t('common.settings') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabSettingsDevice" type="button">{{ t('device.title') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsMessages" type="button">{{ t('settings.tab.messages') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChat" type="button">{{ t('settings.tab.chat') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsInterface" type="button">{{ t('settings.tab.interface') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAppearance" type="button">{{ t('settings.tab.appearance') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsContacts" type="button">{{ t('settings.tab.contacts') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsRegions" type="button">{{ t('settings.tab.regions') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAnalyzer" type="button">{{ t('settings.tab.analyzer') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsObserver" type="button">{{ t('settings.tab.observer') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsNotifications" type="button">{{ t('settings.tab.notifications') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsDiagnostics" type="button">{{ t('settings.tab.diagnostics') }}</button>
|
|
</li>
|
|
</ul>
|
|
<div class="tab-content">
|
|
<!-- Device Settings Tab -->
|
|
<div class="tab-pane fade show active" id="tabSettingsDevice">
|
|
<ul class="nav nav-pills nav-fill mb-3" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active py-1 px-2" data-bs-toggle="pill"
|
|
data-bs-target="#tabDevicePublicInfo" type="button">{{ t('settings.device.public_info') }}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link py-1 px-2" data-bs-toggle="pill"
|
|
data-bs-target="#tabDeviceRadio" type="button">{{ t('settings.device.radio') }}</button>
|
|
</li>
|
|
</ul>
|
|
<div class="tab-content">
|
|
<!-- Public Info sub-tab -->
|
|
<div class="tab-pane fade show active" id="tabDevicePublicInfo">
|
|
<form id="devicePublicInfoForm">
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('common.name') }}</td>
|
|
<td class="pe-0"><input type="text" class="form-control form-control-sm"
|
|
id="settDeviceName" maxlength="32" placeholder="{{ t('settings.device.name_ph') }}"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('rptmgmt.set.lat') }}</td>
|
|
<td class="pe-0">
|
|
<div class="input-group input-group-sm">
|
|
<input type="number" class="form-control form-control-sm"
|
|
id="settDeviceLat" step="0.000001" min="-90" max="90" placeholder="0.000000">
|
|
<button type="button" class="btn btn-outline-secondary" id="settDevicePickMapBtn"
|
|
title="{{ t('rptmgmt.set.map_pick') }}"><i class="bi bi-geo-alt"></i></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('rptmgmt.set.lon') }}</td>
|
|
<td class="pe-0"><input type="number" class="form-control form-control-sm"
|
|
id="settDeviceLon" step="0.000001" min="-180" max="180" placeholder="0.000000"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.device.share_pos') }}
|
|
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
|
|
title="{{ t('settings.device.share_pos_help') }}"><i class="bi bi-info-circle"></i></span>
|
|
</td>
|
|
<td class="pe-0">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="settDeviceAdvertLoc">
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.device.path_hash_mode') }}
|
|
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
|
|
title="{{ t('settings.device.path_hash_mode_help') }}"><i class="bi bi-info-circle"></i></span>
|
|
</td>
|
|
<td class="pe-0">
|
|
<select class="form-select form-select-sm" id="settDevicePathHashMode">
|
|
<option value="0">{{ t('settings.device.hash_1b') }}</option>
|
|
<option value="1">{{ t('settings.device.hash_2b') }}</option>
|
|
<option value="2">{{ t('settings.device.hash_3b') }}</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.save') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<!-- Radio Settings sub-tab -->
|
|
<div class="tab-pane fade" id="tabDeviceRadio">
|
|
<form id="deviceRadioForm">
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0" colspan="2">
|
|
<select class="form-select form-select-sm" id="settRadioPreset">
|
|
<option value="">{{ t('settings.device.load_preset') }}</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.device.freq') }}</td>
|
|
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
|
|
id="settRadioFreq" step="0.001" min="100" max="1000"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.device.bw') }}</td>
|
|
<td class="pe-0">
|
|
<select class="form-select form-select-sm" id="settRadioBw" style="width:7rem">
|
|
<option value="7.8">7.8</option>
|
|
<option value="10.4">10.4</option>
|
|
<option value="15.6">15.6</option>
|
|
<option value="20.8">20.8</option>
|
|
<option value="31.25">31.25</option>
|
|
<option value="41.7">41.7</option>
|
|
<option value="62.5">62.5</option>
|
|
<option value="125">125</option>
|
|
<option value="250">250</option>
|
|
<option value="500">500</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.device.sf') }}</td>
|
|
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
|
|
id="settRadioSf" min="5" max="12"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.device.cr') }}</td>
|
|
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
|
|
id="settRadioCr" min="5" max="8"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.device.tx_power') }}</td>
|
|
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
|
|
id="settRadioTxPower" min="0" max="30"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.save') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsMessages">
|
|
<div id="settingsMessagesContent">
|
|
<form id="dmRetrySettingsForm">
|
|
<p class="text-muted small mb-3">{{ t('settings.msg.intro') }}</p>
|
|
<h6 class="text-muted mb-2">{{ t('settings.msg.direct_header') }}</h6>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.msg.direct_retries') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.msg.direct_retries_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settDirectMaxRetries" min="0" max="20" value="3"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.msg.flood_retries') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.msg.flood_retries_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settDirectFloodRetries" min="0" max="5" value="1"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.msg.interval') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.msg.direct_interval_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settDirectInterval" min="5" max="300" value="30"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h6 class="text-muted mb-2">{{ t('settings.msg.flood_header') }}</h6>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.msg.max_retries') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.msg.max_retries_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settFloodMaxRetries" min="0" max="10" value="3"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.msg.interval') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.msg.flood_interval_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settFloodInterval" min="5" max="300" value="60"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h6 class="text-muted mb-2">{{ t('settings.msg.other') }}</h6>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.msg.grace') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.msg.grace_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settGracePeriod" min="10" max="300" value="60"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.save') }}</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="settingsResetBtn">{{ t('settings.reset_defaults') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsChat">
|
|
<form id="chatSettingsForm">
|
|
<h6 class="text-muted mb-2">{{ t('settings.chat.quote') }}</h6>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.chat.quote_len') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.chat.quote_len_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settQuoteMaxBytes" min="5" max="120" value="20"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h6 class="text-muted mb-2">{{ t('settings.chat.route_popup') }}</h6>
|
|
<p class="text-muted small mb-2">{{ t('settings.chat.route_popup_desc') }}</p>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.chat.autoclose') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.chat.autoclose_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settPathPopupTimeout" min="1" max="60" value="8"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.chat.no_autoclose') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.chat.no_autoclose_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="settPathPopupNoAutoclose">
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.save') }}</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="chatSettingsResetBtn">{{ t('settings.reset_defaults') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsInterface">
|
|
<h6 class="text-muted mb-2">{{ t('settings.iface.layout') }}</h6>
|
|
<p class="text-muted small mb-2">{{ t('settings.iface.layout_desc') }}</p>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.iface.breakpoint') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.iface.breakpoint_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0" style="width:10rem">
|
|
<div class="d-flex gap-1">
|
|
<input type="number" class="form-control form-control-sm" id="settSidebarBreakpoint" min="600" max="2000" step="1" value="992">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="settSidebarBreakpointReset" title="{{ t('settings.iface.breakpoint_reset_title') }}"><i class="bi bi-arrow-counterclockwise"></i></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<hr>
|
|
<form id="uiSettingsForm">
|
|
<h6 class="text-muted mb-2">{{ t('settings.tab.notifications') }}</h6>
|
|
<p class="text-muted small mb-2">{{ t('settings.iface.toast_desc') }}</p>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.chat.autoclose') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.iface.toast_autoclose_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settToastTimeout" min="1" max="60" step="0.5" value="2"></td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.chat.no_autoclose') }} <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="{{ t('settings.iface.toast_no_autoclose_help') }}"><i class="bi bi-info-circle"></i></span></td>
|
|
<td class="pe-0">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="settToastNoAutoclose">
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.iface.position') }}</td>
|
|
<td class="pe-0">
|
|
<select class="form-select form-select-sm" id="settToastPosition">
|
|
<option value="top-left">{{ t('settings.iface.pos_top_left') }}</option>
|
|
<option value="top-right">{{ t('settings.iface.pos_top_right') }}</option>
|
|
<option value="bottom-left">{{ t('settings.iface.pos_bottom_left') }}</option>
|
|
<option value="bottom-right">{{ t('settings.iface.pos_bottom_right') }}</option>
|
|
<option value="center">{{ t('settings.iface.pos_center') }}</option>
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.save') }}</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="uiSettingsResetBtn">{{ t('settings.reset_defaults') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsAppearance">
|
|
<h6 class="text-muted mb-3">{{ t('settings.appear.language') }}</h6>
|
|
<div class="input-group input-group-sm mb-2">
|
|
<select class="form-select form-select-sm" id="settLanguage">
|
|
{% for code, name in available_languages.items() %}
|
|
<option value="{{ code }}"{% if code == lang %} selected{% endif %}>{{ name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<button class="btn btn-outline-secondary" type="button" id="reloadTranslationsBtn"
|
|
title="{{ t('settings.appear.reload_title') }}">
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|
</button>
|
|
</div>
|
|
<small class="d-block text-muted mb-3">
|
|
{{ t_html('settings.appear.language_hint') }}
|
|
</small>
|
|
|
|
<hr>
|
|
<h6 class="text-muted mb-3">{{ t('settings.appear.theme') }}</h6>
|
|
<div class="d-flex flex-column gap-2">
|
|
<div class="theme-option active" data-theme-value="light" onclick="setTheme('light')">
|
|
<div class="theme-option-preview light">
|
|
<i class="bi bi-sun"></i>
|
|
</div>
|
|
<div>
|
|
<div class="theme-option-label">{{ t('settings.appear.theme_light') }}</div>
|
|
<div class="theme-option-desc">{{ t('settings.appear.theme_light_desc') }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="theme-option" data-theme-value="dark" onclick="setTheme('dark')">
|
|
<div class="theme-option-preview dark">
|
|
<i class="bi bi-moon-stars" style="color: #60a5fa;"></i>
|
|
</div>
|
|
<div>
|
|
<div class="theme-option-label">{{ t('settings.appear.theme_dark') }}</div>
|
|
<div class="theme-option-desc">{{ t('settings.appear.theme_dark_desc') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
<h6 class="text-muted mb-3">{{ t('settings.appear.quick_access') }}</h6>
|
|
<div class="form-check mb-3">
|
|
<input class="form-check-input" type="checkbox" id="settHideFab">
|
|
<label class="form-check-label" for="settHideFab">{{ t('settings.appear.hide_fab') }}</label>
|
|
<small class="d-block text-muted">{{ t('settings.appear.hide_fab_hint') }}</small>
|
|
</div>
|
|
<div id="fabAppearanceControls">
|
|
<table class="table table-sm align-middle mb-3" id="settItemPlacementTable">
|
|
<thead>
|
|
<tr>
|
|
<th class="ps-0 fw-normal text-muted small">{{ t('settings.appear.item') }}</th>
|
|
<th class="pe-0 text-end fw-normal text-muted small">{{ t('settings.appear.placement') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr data-placement-key="filter">
|
|
<td class="ps-0">{{ t('menu.filter') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('menu.filter')) }}">
|
|
<input type="radio" class="btn-check" name="place-filter" id="place-filter-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-filter-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-filter" id="place-filter-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-filter-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="search">
|
|
<td class="ps-0">{{ t('menu.search') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('menu.search')) }}">
|
|
<input type="radio" class="btn-check" name="place-search" id="place-search-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-search-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-search" id="place-search-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-search-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="dm">
|
|
<td class="ps-0">{{ t('dm.title') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('dm.title')) }}">
|
|
<input type="radio" class="btn-check" name="place-dm" id="place-dm-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-dm-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-dm" id="place-dm-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-dm-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="contacts">
|
|
<td class="ps-0">{{ t('contacts.title') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('contacts.title')) }}">
|
|
<input type="radio" class="btn-check" name="place-contacts" id="place-contacts-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-contacts-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-contacts" id="place-contacts-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-contacts-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="settings">
|
|
<td class="ps-0">{{ t('common.settings') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('common.settings')) }}">
|
|
<input type="radio" class="btn-check" name="place-settings" id="place-settings-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-settings-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-settings" id="place-settings-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-settings-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="advert">
|
|
<td class="ps-0">{{ t('menu.advert') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('menu.advert')) }}">
|
|
<input type="radio" class="btn-check" name="place-advert" id="place-advert-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-advert-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-advert" id="place-advert-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-advert-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="floodadvert">
|
|
<td class="ps-0">{{ t('menu.flood_advert') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('menu.flood_advert')) }}">
|
|
<input type="radio" class="btn-check" name="place-floodadvert" id="place-floodadvert-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-floodadvert-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-floodadvert" id="place-floodadvert-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-floodadvert-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="map">
|
|
<td class="ps-0">{{ t('menu.map') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('menu.map')) }}">
|
|
<input type="radio" class="btn-check" name="place-map" id="place-map-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-map-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-map" id="place-map-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-map-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="console">
|
|
<td class="ps-0">{{ t('console.title') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('console.title')) }}">
|
|
<input type="radio" class="btn-check" name="place-console" id="place-console-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-console-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-console" id="place-console-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-console-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="repeaters">
|
|
<td class="ps-0">{{ t('repeaters.title') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('repeaters.title')) }}">
|
|
<input type="radio" class="btn-check" name="place-repeaters" id="place-repeaters-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-repeaters-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-repeaters" id="place-repeaters-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-repeaters-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="pathanalyzer">
|
|
<td class="ps-0">{{ t('pa.title') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('pa.title')) }}">
|
|
<input type="radio" class="btn-check" name="place-pathanalyzer" id="place-pathanalyzer-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-pathanalyzer-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-pathanalyzer" id="place-pathanalyzer-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-pathanalyzer-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="deviceinfo">
|
|
<td class="ps-0">{{ t('menu.device_info') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('menu.device_info')) }}">
|
|
<input type="radio" class="btn-check" name="place-deviceinfo" id="place-deviceinfo-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-deviceinfo-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-deviceinfo" id="place-deviceinfo-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-deviceinfo-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr data-placement-key="syslog">
|
|
<td class="ps-0">{{ t('logs.title') }}</td>
|
|
<td class="pe-0 text-end">
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('settings.appear.placement_aria', item=t('logs.title')) }}">
|
|
<input type="radio" class="btn-check" name="place-syslog" id="place-syslog-fab" value="fab" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-syslog-fab">{{ t('settings.appear.place_fab') }}</label>
|
|
<input type="radio" class="btn-check" name="place-syslog" id="place-syslog-menu" value="menu" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="place-syslog-menu">{{ t('settings.appear.place_menu') }}</label>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<table class="table table-sm table-borderless mb-3 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.appear.btn_size') }}</td>
|
|
<td class="pe-0 d-flex align-items-center gap-2" style="width:12rem">
|
|
<input type="range" class="form-range flex-grow-1" id="settFabSize" min="28" max="72" step="2" value="56">
|
|
<span class="text-muted small" id="settFabSizeVal" style="min-width:2.5rem;text-align:right">56</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.appear.spacing') }}</td>
|
|
<td class="pe-0 d-flex align-items-center gap-2">
|
|
<input type="range" class="form-range flex-grow-1" id="settFabGap" min="2" max="24" step="1" value="12">
|
|
<span class="text-muted small" id="settFabGapVal" style="min-width:2.5rem;text-align:right">12</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.appear.position') }}</td>
|
|
<td class="pe-0">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="settFabResetPos">{{ t('settings.appear.reset_default') }}</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsContacts">
|
|
<table class="table table-sm table-borderless mb-0 align-middle">
|
|
<tbody>
|
|
<tr>
|
|
<td class="ps-0">{{ t('settings.contacts.manual_approval') }}
|
|
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
|
|
title="{{ t('settings.contacts.manual_approval_help') }}"><i class="bi bi-info-circle"></i></span>
|
|
</td>
|
|
<td class="pe-0">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="settManualApproval">
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0" id="settSuppressAdvertNotifsLabel">{{ t('settings.contacts.suppress_notifs') }}
|
|
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
|
|
title="{{ t('settings.contacts.suppress_notifs_help') }}"><i class="bi bi-info-circle"></i></span>
|
|
</td>
|
|
<td class="pe-0">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="settSuppressAdvertNotifs">
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="ps-0" id="settAutoIgnoreAdvertsLabel">{{ t('settings.contacts.auto_ignore') }}
|
|
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
|
|
title="{{ t('settings.contacts.auto_ignore_help') }}"><i class="bi bi-info-circle"></i></span>
|
|
</td>
|
|
<td class="pe-0">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="settAutoIgnoreAdverts">
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsRegions">
|
|
<div class="alert alert-info py-2 small mb-3 mt-2">
|
|
<i class="bi bi-info-circle"></i> {{ t_html('settings.regions.intro') }}
|
|
</div>
|
|
<h6 class="mb-2">{{ t('settings.regions.registry') }}</h6>
|
|
<div id="regionsList" class="list-group mb-3">
|
|
<div class="text-center text-muted py-3 small">
|
|
<div class="spinner-border spinner-border-sm"></div> {{ t('common.loading') }}
|
|
</div>
|
|
</div>
|
|
<form id="addRegionForm" class="row g-2 mb-0">
|
|
<div class="col-8">
|
|
<input type="text" class="form-control form-control-sm" id="newRegionName"
|
|
placeholder="{{ t('settings.regions.name_ph') }}" required maxlength="30" autocomplete="off">
|
|
</div>
|
|
<div class="col-4">
|
|
<button type="submit" class="btn btn-primary btn-sm w-100">
|
|
<i class="bi bi-plus-circle"></i> {{ t('common.add') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<div class="form-text small">
|
|
{{ t_html('settings.regions.tip') }}
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsAnalyzer">
|
|
<div class="alert alert-info py-2 small mb-3 mt-2">
|
|
<i class="bi bi-info-circle"></i> {{ t_html('settings.analyzer.intro') }}
|
|
</div>
|
|
<h6 class="mb-2">{{ t('settings.analyzer.services') }}</h6>
|
|
<div id="analyzersList" class="list-group mb-3">
|
|
<div class="text-center text-muted py-3 small">
|
|
<div class="spinner-border spinner-border-sm"></div> {{ t('common.loading') }}
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-primary btn-sm" id="addAnalyzerBtn">
|
|
<i class="bi bi-plus-circle"></i> {{ t('analyzer.add') }}
|
|
</button>
|
|
<div class="form-text small mt-2">
|
|
{{ t('settings.analyzer.tip') }}
|
|
</div>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsObserver">
|
|
<div class="alert alert-info py-2 small mb-3 mt-2">
|
|
<i class="bi bi-info-circle"></i> {{ t_html('settings.observer.intro') }}
|
|
</div>
|
|
<div class="form-check form-switch mb-2">
|
|
<input class="form-check-input" type="checkbox" id="observerEnabledToggle">
|
|
<label class="form-check-label" for="observerEnabledToggle">{{ t('settings.observer.enable') }}</label>
|
|
</div>
|
|
<div class="row g-2 mb-1">
|
|
<div class="col-6">
|
|
<label class="form-label small mb-1" for="observerIataInput">{{ t('settings.observer.iata') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="observerIataInput"
|
|
maxlength="3" placeholder="{{ t('settings.observer.iata_ph') }}" autocomplete="off"
|
|
style="text-transform: uppercase;">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label small mb-1" for="observerAdvertIntervalInput">{{ t('settings.observer.advert_interval') }}</label>
|
|
<input type="number" class="form-control form-control-sm" id="observerAdvertIntervalInput"
|
|
min="0" max="8760" step="1" placeholder="{{ t('settings.observer.advert_interval_ph') }}">
|
|
</div>
|
|
</div>
|
|
<div class="form-text small mb-2">
|
|
{{ t_html('settings.observer.hint') }}
|
|
</div>
|
|
<div id="observerStatusLine" class="small text-muted mb-3"></div>
|
|
<h6 class="mb-2">{{ t('settings.observer.brokers') }}</h6>
|
|
<div id="observerBrokersList" class="list-group mb-3">
|
|
<div class="text-center text-muted py-3 small">
|
|
<div class="spinner-border spinner-border-sm"></div> {{ t('common.loading') }}
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-primary btn-sm" id="addObserverBrokerBtn">
|
|
<i class="bi bi-plus-circle"></i> {{ t('observer.add_broker') }}
|
|
</button>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsNotifications">
|
|
<p class="text-muted small mb-3">{{ t('settings.notif.desc') }}</p>
|
|
<button id="notificationsToggle" class="list-group-item list-group-item-action d-flex align-items-center gap-3 border rounded" type="button" style="width: 100%;">
|
|
<i class="bi bi-bell" style="font-size: 1.5rem;"></i>
|
|
<div class="flex-grow-1 text-start">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span>{{ t('settings.tab.notifications') }}</span>
|
|
<span id="notificationStatus" class="badge bg-secondary">{{ t('common.disabled') }}</span>
|
|
</div>
|
|
<small class="text-muted d-block mt-1" style="font-size: 0.75rem;">
|
|
{{ t('settings.notif.works_hidden') }}
|
|
</small>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<div class="tab-pane fade" id="tabSettingsDiagnostics">
|
|
<div class="alert alert-info py-2 small mb-3 mt-2">
|
|
<i class="bi bi-info-circle"></i> {{ t_html('settings.diag.intro') }}
|
|
</div>
|
|
<div class="alert alert-warning py-2 small mb-3">
|
|
<i class="bi bi-shield-exclamation"></i> {{ t_html('settings.diag.privacy') }}
|
|
</div>
|
|
|
|
<div id="diagIdleControls">
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-6">
|
|
<label class="form-label small mb-1" for="diagDurationSelect">{{ t('settings.diag.duration') }}</label>
|
|
<select class="form-select form-select-sm" id="diagDurationSelect"></select>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label small mb-1" for="diagNoteInput">{{ t('settings.diag.note') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="diagNoteInput"
|
|
maxlength="200" placeholder="{{ t('settings.diag.note_ph') }}" autocomplete="off">
|
|
</div>
|
|
</div>
|
|
<div class="form-check form-switch mb-3">
|
|
<input class="form-check-input" type="checkbox" id="diagDebugLogsToggle" checked>
|
|
<label class="form-check-label small" for="diagDebugLogsToggle">{{ t('settings.diag.debug_logs') }}</label>
|
|
<div class="form-text small">{{ t('settings.diag.debug_logs_hint') }}</div>
|
|
</div>
|
|
<button type="button" class="btn btn-danger btn-sm" id="diagStartBtn">
|
|
<i class="bi bi-record-circle"></i> {{ t('settings.diag.start') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div id="diagRecordingControls" class="d-none">
|
|
<div class="alert alert-danger py-2 mb-2">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<span class="spinner-grow spinner-grow-sm"></span>
|
|
<strong>{{ t('settings.diag.recording') }}</strong>
|
|
</div>
|
|
<div class="small mt-1" id="diagLiveCounters"></div>
|
|
<div class="progress mt-2" style="height: 4px;">
|
|
<div class="progress-bar bg-danger" id="diagProgressBar" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
<p class="small text-muted mb-2">{{ t('settings.diag.reproduce_hint') }}</p>
|
|
<button type="button" class="btn btn-primary btn-sm" id="diagStopBtn">
|
|
<i class="bi bi-stop-circle"></i> {{ t('settings.diag.stop') }}
|
|
</button>
|
|
</div>
|
|
|
|
<hr class="my-3">
|
|
<h6 class="mb-2">{{ t('settings.diag.captures') }}</h6>
|
|
<div id="diagCapturesList" class="list-group mb-3">
|
|
<div class="text-center text-muted py-3 small">
|
|
<div class="spinner-border spinner-border-sm"></div> {{ t('common.loading') }}
|
|
</div>
|
|
</div>
|
|
|
|
<h6 class="mb-2">{{ t('settings.diag.sharing') }}</h6>
|
|
<div class="form-text small mb-2">{{ t_html('settings.diag.sharing_hint') }}</div>
|
|
<div class="row g-2">
|
|
<div class="col-12">
|
|
<label class="form-label small mb-1" for="diagUploadUrlInput">{{ t('settings.diag.upload_url') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="diagUploadUrlInput" autocomplete="off">
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label small mb-1" for="diagUploadTokenInput">{{ t('settings.diag.upload_token') }}</label>
|
|
<input type="password" class="form-control form-control-sm" id="diagUploadTokenInput"
|
|
placeholder="{{ t('settings.diag.upload_token_ph') }}" autocomplete="off">
|
|
<div class="form-text small" id="diagTokenHint"></div>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm mt-2" id="diagSaveUploadBtn">
|
|
{{ t('common.save') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.close') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Analyzer Edit Modal (create + edit) -->
|
|
<div class="modal fade" id="analyzerEditModal" tabindex="-1" style="z-index: 1080;">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-clipboard-data"></i> <span id="analyzerEditModalTitle">{{ t('analyzer.add') }}</span></h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form id="analyzerEditForm">
|
|
<div class="modal-body">
|
|
<input type="hidden" id="analyzerEditId" value="">
|
|
<div class="mb-3">
|
|
<label class="form-label small mb-1" for="analyzerEditName">{{ t('common.name') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="analyzerEditName"
|
|
placeholder="{{ t('analyzer.name_ph') }}" required maxlength="60" autocomplete="off">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label small mb-1" for="analyzerEditUrl">{{ t('analyzer.url') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="analyzerEditUrl"
|
|
placeholder="https://analyzer.example.com/#/packets/{packetHash}" required autocomplete="off">
|
|
<div class="form-text small">{{ t_html('analyzer.url_hint') }}</div>
|
|
</div>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="analyzerEditEnabled" checked>
|
|
<label class="form-check-label small" for="analyzerEditEnabled">{{ t('common.enabled') }}</label>
|
|
</div>
|
|
<div id="analyzerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.save') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Analyzer Chooser Modal -->
|
|
<div class="modal fade" id="analyzerChooserModal" tabindex="-1" style="z-index: 1080;">
|
|
<div class="modal-dialog modal-dialog-centered modal-sm">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-clipboard-data"></i> {{ t('analyzer.choose') }}</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-2">
|
|
<div id="analyzerChooserList" class="list-group"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Observer Broker Edit Modal (create + edit) -->
|
|
<div class="modal fade" id="observerBrokerEditModal" tabindex="-1" style="z-index: 1080;">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-broadcast"></i> <span id="observerBrokerEditModalTitle">{{ t('observer.add_broker') }}</span></h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form id="observerBrokerEditForm">
|
|
<div class="modal-body">
|
|
<input type="hidden" id="observerBrokerEditId" value="">
|
|
<div class="mb-2">
|
|
<label class="form-label small mb-1" for="observerBrokerEditName">{{ t('common.name') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="observerBrokerEditName"
|
|
placeholder="{{ t('observer.name_ph') }}" required maxlength="60" autocomplete="off">
|
|
</div>
|
|
<div class="row g-2 mb-1">
|
|
<div class="col-8">
|
|
<label class="form-label small mb-1" for="observerBrokerEditHost">{{ t('observer.host') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="observerBrokerEditHost"
|
|
placeholder="mqtt.example.com" required autocomplete="off">
|
|
</div>
|
|
<div class="col-4">
|
|
<label class="form-label small mb-1" for="observerBrokerEditPort">{{ t('observer.port') }}</label>
|
|
<input type="number" class="form-control form-control-sm" id="observerBrokerEditPort"
|
|
min="1" max="65535" value="1883" required>
|
|
</div>
|
|
</div>
|
|
<div class="form-text small mb-2">{{ t('observer.ports_hint') }}</div>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-6">
|
|
<label class="form-label small mb-1" for="observerBrokerEditUsername">{{ t('observer.username') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="observerBrokerEditUsername"
|
|
autocomplete="off">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label small mb-1" for="observerBrokerEditPassword">{{ t('observer.password') }}</label>
|
|
<input type="password" class="form-control form-control-sm" id="observerBrokerEditPassword"
|
|
autocomplete="new-password">
|
|
</div>
|
|
</div>
|
|
<div class="form-text small mb-2 d-none" id="observerBrokerPasswordHint">
|
|
{{ t('observer.password_hint') }}
|
|
</div>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="observerBrokerEditTls">
|
|
<label class="form-check-label small" for="observerBrokerEditTls">{{ t('observer.use_tls') }}</label>
|
|
</div>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="observerBrokerEditTlsVerify" checked>
|
|
<label class="form-check-label small" for="observerBrokerEditTlsVerify">{{ t('observer.verify_tls') }}</label>
|
|
</div>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="observerBrokerEditEnabled" checked>
|
|
<label class="form-check-label small" for="observerBrokerEditEnabled">{{ t('common.enabled') }}</label>
|
|
</div>
|
|
<div id="observerBrokerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
|
|
<button type="submit" class="btn btn-primary btn-sm">{{ t('common.save') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Coordinate Picker Map Modal -->
|
|
<div class="modal fade" id="coordPickerModal" tabindex="-1" style="z-index: 1080;">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> {{ t('coord.title') }}</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0">
|
|
<div id="coordPickerMap" style="height: 400px; width: 100%;"></div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<span class="me-auto small text-muted" id="coordPickerLabel">{{ t('coord.hint') }}</span>
|
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
|
|
<button type="button" class="btn btn-sm btn-primary" id="coordPickerConfirmBtn" disabled>{{ t('coord.confirm') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quote Dialog Modal -->
|
|
<div class="modal fade" id="quoteModal" tabindex="-1">
|
|
<div class="modal-dialog modal-sm modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-quote"></i> {{ t('quote.title') }}</h6>
|
|
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body py-2">
|
|
<p class="text-muted small mb-2" id="quotePreview"></p>
|
|
<div class="d-flex gap-2 align-items-center mb-2">
|
|
<label class="form-label mb-0 small text-nowrap" for="quoteBytesInput">{{ t('quote.max_bytes') }}</label>
|
|
<input type="number" class="form-control form-control-sm" id="quoteBytesInput" min="5" max="120" style="width:5rem">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer py-1">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="quoteTruncatedBtn">{{ t('quote.truncated') }}</button>
|
|
<button type="button" class="btn btn-primary btn-sm" id="quoteFullBtn">{{ t('quote.full') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map Modal (Leaflet) -->
|
|
<div class="modal fade" id="mapModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-geo-alt"></i> <span id="mapModalTitle">{{ t('menu.map') }}</span></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0">
|
|
<!-- Type filter (hidden for single contact view) -->
|
|
<div id="mapTypeFilter" class="d-none px-3 py-2 border-bottom bg-light">
|
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
|
<span class="map-filter-badge active" data-type="1" id="mapFilterCOM">COM</span>
|
|
<span class="map-filter-badge active" data-type="2" id="mapFilterREP">REP</span>
|
|
<span class="map-filter-badge active" data-type="3" id="mapFilterROOM">ROOM</span>
|
|
<span class="map-filter-badge active" data-type="4" id="mapFilterSENS">SENS</span>
|
|
<div class="form-check form-switch ms-auto mb-0">
|
|
<input class="form-check-input" type="checkbox" id="mapCachedSwitch">
|
|
<label class="form-check-label small" for="mapCachedSwitch">{{ t('repeaters.map.cached') }}</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="leafletMap" style="height: 400px; width: 100%;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Update Modal -->
|
|
<div class="modal fade" id="updateModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-cloud-download"></i> {{ t('update.title') }}</h5>
|
|
</div>
|
|
<div class="modal-body text-center py-4">
|
|
<div id="updateStatus">
|
|
<div class="spinner-border text-primary mb-3" role="status">
|
|
<span class="visually-hidden">{{ t('common.loading') }}</span>
|
|
</div>
|
|
<p id="updateMessage" class="mb-0">{{ t('update.checking') }}</p>
|
|
<p id="updateWhatsNew" class="d-none mt-2 mb-0"><a href="#" target="_blank" class="text-muted small"><i class="bi bi-github"></i> {{ t('update.whats_new') }}</a></p>
|
|
</div>
|
|
<div id="updateProgress" class="d-none">
|
|
<div class="progress mb-3" style="height: 8px;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
role="progressbar" style="width: 100%"></div>
|
|
</div>
|
|
<p id="updateProgressMessage" class="text-muted mb-0">{{ t('update.updating') }}</p>
|
|
</div>
|
|
<div id="updateResult" class="d-none">
|
|
<i id="updateResultIcon" class="bi fs-1 mb-3 d-block"></i>
|
|
<p id="updateResultMessage" class="mb-0"></p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer justify-content-center" id="updateFooter">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="updateCancelBtn">{{ t('common.cancel') }}</button>
|
|
<button type="button" class="btn btn-primary d-none" id="updateConfirmBtn">{{ t('update.now') }}</button>
|
|
<button type="button" class="btn btn-primary d-none" id="updateReloadBtn" onclick="location.reload()">{{ t('update.reload') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Backup Modal -->
|
|
<div class="modal fade" id="backupModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-database-down"></i> {{ t('backup.title') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<button class="btn btn-primary" id="createBackupBtn" onclick="createBackup()">
|
|
<i class="bi bi-plus-circle"></i> {{ t('backup.create') }}
|
|
</button>
|
|
<span id="backupAutoStatus" class="text-muted small"></span>
|
|
</div>
|
|
<div id="backupList">
|
|
<div class="text-center text-muted py-3">
|
|
<div class="spinner-border spinner-border-sm"></div> {{ t('common.loading') }}
|
|
</div>
|
|
</div>
|
|
<hr class="my-3">
|
|
<h6 class="mb-2"><i class="bi bi-arrows-collapse"></i> {{ t('backup.optimize_title') }}</h6>
|
|
<p class="text-muted small mb-2">
|
|
{{ t_html('backup.optimize_hint') }}
|
|
</p>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<button class="btn btn-outline-primary btn-sm" id="vacuumDbBtn" onclick="optimizeDatabase()">
|
|
<i class="bi bi-arrows-collapse"></i> {{ t('backup.optimize_now') }}
|
|
</button>
|
|
<span id="vacuumDbStatus" class="text-muted small">{{ t('backup.current_size') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search Modal -->
|
|
<div class="modal fade" id="searchModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-search"></i> {{ t('chat.fab.search_title') }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="input-group mb-3">
|
|
<input type="text" class="form-control" id="searchInput" placeholder="{{ t('search.ph') }}" autofocus>
|
|
<button class="btn btn-primary" type="button" id="searchBtn">
|
|
<i class="bi bi-search"></i>
|
|
</button>
|
|
<button class="btn btn-outline-secondary" type="button" id="searchHelpBtn" title="{{ t('search.help_title') }}">
|
|
<i class="bi bi-question-circle"></i>
|
|
</button>
|
|
</div>
|
|
<div id="searchHelp" class="alert alert-light small mb-3" style="display:none;">
|
|
<strong>{{ t('search.tips') }}</strong>
|
|
<ul class="mb-1 ps-3">
|
|
<li><code>hello world</code> — {{ t('search.tip_and') }}</li>
|
|
<li><code>"hello world"</code> — {{ t('search.tip_phrase') }}</li>
|
|
<li><code>hello OR world</code> — {{ t('search.tip_or') }}</li>
|
|
<li><code>hello NOT world</code> — {{ t('search.tip_not') }}</li>
|
|
<li><code>hell*</code> — {{ t('search.tip_prefix') }}</li>
|
|
</ul>
|
|
<div class="text-muted">{{ t_html('search.special_chars') }}<br>
|
|
<a href="https://www.sqlite.org/fts5.html#full_text_query_syntax" target="_blank" rel="noopener">{{ t('search.fts5_ref') }} <i class="bi bi-box-arrow-up-right"></i></a></div>
|
|
</div>
|
|
<div id="searchResults">
|
|
<div class="text-center text-muted py-4">
|
|
<i class="bi bi-search" style="font-size: 2rem;"></i>
|
|
<p class="mt-2 mb-0">{{ t('search.empty') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
|
|
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
|
|
<div id="notificationToast" class="toast" role="alert">
|
|
<div class="toast-header">
|
|
<strong class="me-auto">mc-webui</strong>
|
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
<div class="toast-body"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bootstrap 5 JS Bundle (local) -->
|
|
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
|
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""></script>
|
|
|
|
<!-- Share token formats (must load before message-utils.js, which renders them) -->
|
|
<script src="{{ url_for('static', filename='js/share-tokens.js') }}"></script>
|
|
|
|
<!-- Message Content Processing Utilities (must load before app.js and dm.js) -->
|
|
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>
|
|
|
|
<!-- Filter Utilities (must load before app.js) -->
|
|
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
|
|
|
|
<!-- SocketIO for real-time updates -->
|
|
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
|
|
|
|
<!-- FAB Utilities (drag, sizing — must load before app.js) -->
|
|
<script src="{{ url_for('static', filename='js/fab-utils.js') }}"></script>
|
|
|
|
<!-- Custom JS -->
|
|
<!-- QR Code generator (for Device Share) -->
|
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
|
|
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
|
|
|
<!-- PWA Viewport Fix for Android -->
|
|
<script>
|
|
// Fix viewport corruption on Android PWA after page reload
|
|
window.addEventListener('load', function() {
|
|
// Force viewport recalculation without modifying body height
|
|
setTimeout(() => {
|
|
window.scrollTo(0, 0);
|
|
// Force reflow using a less invasive method
|
|
void(document.documentElement.offsetHeight);
|
|
// Trigger resize event to recalculate layout
|
|
window.dispatchEvent(new Event('resize'));
|
|
}, 150);
|
|
});
|
|
</script>
|
|
|
|
<!-- Service Worker Registration for PWA -->
|
|
<script>
|
|
// Register Service Worker for PWA functionality
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/static/js/sw.js')
|
|
.then((registration) => {
|
|
console.log('Service Worker registered:', registration.scope);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Service Worker registration failed:', error);
|
|
});
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<!-- Theme Switching -->
|
|
<script>
|
|
function setTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
localStorage.setItem('mc-webui-theme', theme);
|
|
// Update theme selector UI
|
|
document.querySelectorAll('.theme-option').forEach(function(el) {
|
|
el.classList.toggle('active', el.getAttribute('data-theme-value') === theme);
|
|
});
|
|
}
|
|
|
|
// Initialize theme selector UI on settings modal open
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var current = localStorage.getItem('mc-webui-theme') || 'light';
|
|
document.querySelectorAll('.theme-option').forEach(function(el) {
|
|
el.classList.toggle('active', el.getAttribute('data-theme-value') === current);
|
|
});
|
|
|
|
// --- FAB appearance controls ---
|
|
var fabSizeSlider = document.getElementById('settFabSize');
|
|
var fabSizeVal = document.getElementById('settFabSizeVal');
|
|
var fabGapSlider = document.getElementById('settFabGap');
|
|
var fabGapVal = document.getElementById('settFabGapVal');
|
|
var fabResetPos = document.getElementById('settFabResetPos');
|
|
|
|
// Load saved values
|
|
var savedSize = localStorage.getItem('mc-webui-fab-size');
|
|
var savedGap = localStorage.getItem('mc-webui-fab-gap');
|
|
if (savedSize && fabSizeSlider) { fabSizeSlider.value = savedSize; fabSizeVal.textContent = savedSize; }
|
|
if (savedGap && fabGapSlider) { fabGapSlider.value = savedGap; fabGapVal.textContent = savedGap; }
|
|
|
|
// Live preview on slider change
|
|
if (fabSizeSlider) {
|
|
fabSizeSlider.addEventListener('input', function() {
|
|
var v = this.value;
|
|
fabSizeVal.textContent = v;
|
|
document.documentElement.style.setProperty('--fab-custom-size', v + 'px');
|
|
localStorage.setItem('mc-webui-fab-size', v);
|
|
});
|
|
}
|
|
if (fabGapSlider) {
|
|
fabGapSlider.addEventListener('input', function() {
|
|
var v = this.value;
|
|
fabGapVal.textContent = v;
|
|
document.documentElement.style.setProperty('--fab-custom-gap', v + 'px');
|
|
localStorage.setItem('mc-webui-fab-gap', v);
|
|
});
|
|
}
|
|
|
|
// Reset position button
|
|
if (fabResetPos) {
|
|
fabResetPos.addEventListener('click', function() {
|
|
localStorage.removeItem('mc-webui-fab-pos');
|
|
localStorage.removeItem('mc-webui-fab-pos-dm');
|
|
var container = document.getElementById('fabContainer');
|
|
if (container) {
|
|
container.style.left = '';
|
|
container.style.right = '16px';
|
|
container.style.top = '80px';
|
|
}
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{% block extra_scripts %}{% endblock %}
|
|
</body>
|
|
</html>
|