mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-11-06 18:52:22 +01:00
Completes the sharing feature: you can now send what the cards render. A "+" button next to the emoji button opens a three-item menu — contact, channel, location. Five actions in the official app collapse into three because "me" and "my position" are not separate destinations, just the first, pinned entry of the picker they belong to. Same number of taps, one less menu level, and every entry behaves identically. The payload is inserted into the input as plain text, byte-identical to what the official app sends. A protected "chip" was considered and dropped: it is unknown whether the official app renders a card when the token is not the whole message body, and that risk is not worth the cosmetics. The reassurance went into a live preview above the composer instead, rendered through the very same processMessageContent() the recipient will use — so it cannot drift. Edit the token by hand and the preview vanishes, which is immediate feedback that the message will no longer render as a card. Details worth knowing: - The contact picker lists device memory and cache together, since the cache is where adverts land and those are the contacts worth passing on. It renders at most 150 rows: this mesh has over 1500 cached contacts, and the full list costs thousands of DOM nodes for something nobody scrolls to the end of. Search runs over the whole set. - The channel picker carries the region scope along, because a recipient transmitting under the firmware default would not match the sender. - The location picker holds both cases in one dialog: our own position as a button, plus a Leaflet map for any other point. - Byte budgets are enforced up front (135 channel / 150 DM). A token that cannot fit is refused rather than truncated — shortening a public key or a channel secret destroys it. - The DM composer starts disabled until a conversation is chosen, so the button mirrors the input's disabled state via a MutationObserver. Also fixes _selfInfo being populated only inside the map modal's shown handler, which left it null on a fresh page — the location cards need our own position at render time for distance and bearing. Verified in a real browser against the live device: 32 assertions on the channel composer, 13 on the DM panel (including its local map modal), both suites green with no console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1661 lines
115 KiB
HTML
1661 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>
|
|
|
|
<!-- Share composer: the "+" button and its pickers (needs message-utils.js) -->
|
|
<script src="{{ url_for('static', filename='js/share-composer.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>
|