feat(i18n): translate the remaining base.html modals (stage 7b)

Manage Channels, region scope picker, Share Channel, Device Info,
analyzer registry (edit + chooser), Observer MQTT broker editor,
coordinate picker, quote dialog, map, update, backup and search.

The Settings modal (base.html ~458-1147) is still untouched - stage 8.
Where the two share markup, this commit anchors on the container id
rather than the markup: the "Loading..." spinner appears six times in
base.html and only three of them are in this slice.

Also in app.js, on the same rule as 7a - the writer that replaces a
string this slice keyed: the analyzer and broker modal titles (they
swap Add/Edit on open), the map modal title, the coordinate picker
label, and the database size line under Optimize.

Three real misses came out of the browser diff, none of which the
checker could see:

- Cancel/Save in the analyzer and broker editors were never keyed. They
  only surfaced once because the diff dedupes by text, so a single
  report line stood for both modals.
- The database size under Optimize is written by JS on open, so the
  translated server-rendered value was replaced a moment later.
- All three Device Info tabs render their bodies from app.js. Excluded
  from the check and left whole for stage 9 rather than translating the
  Share tab alone.

Left English on purpose: FTS5 query examples (hello OR world, hell*) -
the syntax is typed verbatim in any language, only what each one means
is translated - plus mqtt.example.com and the {packetHash} token.

Catalog: 573 -> 651 keys, pl at 100%. Verified with a new
i18n-stage7b-modals.js that opens each modal through the Bootstrap API
instead of its real trigger, since several are only reachable from a row
or message that may not exist on a given device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-08-01 09:54:02 +02:00
parent e11debbda4
commit cd9a3cb397
5 changed files with 259 additions and 104 deletions
+8 -8
View File
@@ -248,7 +248,7 @@ function updateMapMarkers() {
async function showAllContactsOnMap() {
const modalEl = document.getElementById('mapModal');
const modal = new bootstrap.Modal(modalEl);
document.getElementById('mapModalTitle').textContent = 'All Contacts';
document.getElementById('mapModalTitle').textContent = t('map.all_contacts');
// Show type filter panel
const filterPanel = document.getElementById('mapTypeFilter');
@@ -2443,7 +2443,7 @@ function openCoordPicker() {
const confirmBtn = document.getElementById('coordPickerConfirmBtn');
const label = document.getElementById('coordPickerLabel');
if (confirmBtn) confirmBtn.disabled = true;
if (label) label.textContent = 'Click on the map to select coordinates';
if (label) label.textContent = t('coord.hint');
const modal = new bootstrap.Modal(modalEl);
@@ -3529,13 +3529,13 @@ function openAnalyzerEditModal(id) {
if (id) {
const a = (window.analyzerCache.analyzers || []).find(x => x.id === id);
if (!a) return;
titleEl.textContent = 'Edit analyzer';
titleEl.textContent = t('analyzer.edit');
idEl.value = String(a.id);
nameEl.value = a.name || '';
urlEl.value = a.url_template || '';
enabledEl.checked = !a.is_disabled;
} else {
titleEl.textContent = 'Add analyzer';
titleEl.textContent = t('analyzer.add');
idEl.value = '';
nameEl.value = '';
urlEl.value = '';
@@ -3878,7 +3878,7 @@ function openObserverBrokerModal(id) {
if (id) {
const b = (window.observerCache.brokers || []).find(x => x.id === id);
if (!b) return;
titleEl.textContent = 'Edit broker';
titleEl.textContent = t('observer.edit_broker');
idEl.value = String(b.id);
nameEl.value = b.name || '';
hostEl.value = b.host || '';
@@ -3889,7 +3889,7 @@ function openObserverBrokerModal(id) {
enabledEl.checked = !b.is_disabled;
passHintEl.classList.toggle('d-none', !b.has_password);
} else {
titleEl.textContent = 'Add broker';
titleEl.textContent = t('observer.add_broker');
idEl.value = '';
nameEl.value = '';
hostEl.value = '';
@@ -7024,7 +7024,7 @@ async function loadDatabaseSize() {
const response = await fetch('/api/db/size');
const data = await response.json();
if (data.success) {
statusEl.textContent = `Current size: ${_formatBytes(data.size)}`;
statusEl.textContent = t('backup.size', { size: _formatBytes(data.size) });
} else {
statusEl.textContent = 'Size: unknown';
}
@@ -7082,7 +7082,7 @@ async function optimizeDatabase() {
if (status.success === true && status.size_after !== undefined) {
const freed = status.freed > 0 ? `freed ${_formatBytes(status.freed)}` : 'no space to reclaim';
showNotification(`Optimized: ${freed} in ${status.elapsed_seconds}s`, 'success');
if (statusEl) statusEl.textContent = `Current size: ${_formatBytes(status.size_after)}`;
if (statusEl) statusEl.textContent = t('backup.size', { size: _formatBytes(status.size_after) });
} else if (status.error) {
showNotification('Optimize failed: ' + status.error, 'danger');
loadDatabaseSize();
+92 -93
View File
@@ -271,43 +271,43 @@
<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> Manage Channels</h5>
<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>Your Channels</h6>
<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> Loading...
<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> Add New Channel
<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> Join Existing
<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>Create New Channel</h6>
<h6>{{ t('channels.create_title') }}</h6>
<form id="createChannelForm">
<div class="mb-2">
<label for="newChannelName" class="form-label">Channel Name</label>
<label for="newChannelName" class="form-label">{{ t('channels.name') }}</label>
<input type="text" class="form-control" id="newChannelName"
placeholder="e.g., Malopolska"
placeholder="{{ t('channels.name_ph') }}"
pattern="[a-zA-Z0-9_\-]+"
required>
<small class="text-muted">Only letters, numbers, _ and -</small>
<small class="text-muted">{{ t('channels.name_hint') }}</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create & Auto-generate Key
<i class="bi bi-plus-circle"></i> {{ t('channels.create_btn') }}
</button>
</form>
</div>
@@ -316,25 +316,25 @@
<!-- Join Channel Form (collapsed) -->
<div class="collapse" id="joinChannelForm">
<div class="card card-body mb-3">
<h6>Join Existing Channel</h6>
<h6>{{ t('channels.join_title') }}</h6>
<form id="joinChannelFormSubmit">
<div class="mb-2">
<label for="joinChannelName" class="form-label">Channel Name</label>
<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">Channel Key (32 hex chars) <small class="text-muted">- optional for channels starting with #</small></label>
<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="485af7e164459d280d8818d9c99fb30d (leave empty for # channels)"
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> Join Channel
<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> Scan QR Code
<i class="bi bi-qr-code-scan"></i> {{ t('channels.scan_qr') }}
</button>
</div>
</form>
@@ -352,19 +352,19 @@
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pin-map"></i>
Set Region Scope for <span id="regionPickerChannelName" class="fw-bold"></span>
{{ 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> Only repeaters allowing the selected region will forward messages from this channel.
<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">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="regionPickerSaveBtn">Save</button>
<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>
@@ -375,7 +375,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-share"></i> Share Channel</h5>
<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">
@@ -383,23 +383,23 @@
<!-- QR Code -->
<div class="text-center mb-3">
<img id="shareChannelQR" src="" alt="QR Code" class="img-fluid" style="max-width: 300px;">
<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">Channel Key:</label>
<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> Copy
<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>
Share this QR code or key with others to let them join this channel.
{{ t('channels.share_hint') }}
</div>
</div>
</div>
@@ -411,40 +411,40 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-cpu"></i> Device</h5>
<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">Info</button>
<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">Stats</button>
<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">Share</button>
<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> Loading...
<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">
Click to load stats
{{ 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">
Click to generate share code
{{ t('device.share_hint') }}
</div>
</div>
</div>
@@ -1150,32 +1150,32 @@
<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">Add analyzer</span></h6>
<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">Name</label>
<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="e.g. MarWoj Analyzer" required maxlength="60" autocomplete="off">
placeholder="{{ t('analyzer.name_ph') }}" required maxlength="60" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label small mb-1" for="analyzerEditUrl">URL template</label>
<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">Must include <code>{packetHash}</code>.</div>
<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">Enabled</label>
<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">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<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>
@@ -1187,7 +1187,7 @@
<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> Choose analyzer</h6>
<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">
@@ -1202,62 +1202,62 @@
<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">Add broker</span></h6>
<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">Name</label>
<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="e.g. My MQTT server" required maxlength="60" autocomplete="off">
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">Host</label>
<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">Port</label>
<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">Typical ports: 1883 (plain), 8883 (TLS).</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">Username</label>
<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">Password</label>
<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">
Leave the password blank to keep the current one.
{{ 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">Use TLS</label>
<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">Verify TLS certificate</label>
<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">Enabled</label>
<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">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<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>
@@ -1269,16 +1269,16 @@
<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> Pick Coordinates</h6>
<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">Click on the map to select coordinates</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="coordPickerConfirmBtn" disabled>Confirm</button>
<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>
@@ -1289,19 +1289,19 @@
<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> Quote message</h6>
<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">Max bytes:</label>
<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">Truncated</button>
<button type="button" class="btn btn-primary btn-sm" id="quoteFullBtn">Full quote</button>
<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>
@@ -1312,7 +1312,7 @@
<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">Map</span></h5>
<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">
@@ -1325,7 +1325,7 @@
<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">Cached</label>
<label class="form-check-label small" for="mapCachedSwitch">{{ t('repeaters.map.cached') }}</label>
</div>
</div>
</div>
@@ -1340,22 +1340,22 @@
<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> Update mc-webui</h5>
<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">Loading...</span>
<span class="visually-hidden">{{ t('common.loading') }}</span>
</div>
<p id="updateMessage" class="mb-0">Checking for updates...</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> What's new?</a></p>
<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">Updating...</p>
<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>
@@ -1363,9 +1363,9 @@
</div>
</div>
<div class="modal-footer justify-content-center" id="updateFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="updateCancelBtn">Cancel</button>
<button type="button" class="btn btn-primary d-none" id="updateConfirmBtn">Update Now</button>
<button type="button" class="btn btn-primary d-none" id="updateReloadBtn" onclick="location.reload()">Reload Page</button>
<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>
@@ -1376,32 +1376,31 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-database-down"></i> Database Backup</h5>
<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> Create Backup
<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> Loading...
<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> Optimize database</h6>
<h6 class="mb-2"><i class="bi bi-arrows-collapse"></i> {{ t('backup.optimize_title') }}</h6>
<p class="text-muted small mb-2">
Reclaim space freed by message retention. Runs SQLite <code>VACUUM</code>;
readers keep working but writes are paused for a few seconds.
{{ 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> Optimize now
<i class="bi bi-arrows-collapse"></i> {{ t('backup.optimize_now') }}
</button>
<span id="vacuumDbStatus" class="text-muted small">Current size: …</span>
<span id="vacuumDbStatus" class="text-muted small">{{ t('backup.current_size') }}</span>
</div>
</div>
</div>
@@ -1413,35 +1412,35 @@
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-search"></i> Search Messages</h5>
<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="Search all messages..." autofocus>
<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="Search syntax help">
<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>Search tips:</strong>
<strong>{{ t('search.tips') }}</strong>
<ul class="mb-1 ps-3">
<li><code>hello world</code>messages containing both words</li>
<li><code>"hello world"</code>exact phrase</li>
<li><code>hello OR world</code>either word</li>
<li><code>hello NOT world</code>hello but not world</li>
<li><code>hell*</code>prefix match (hello,hellas...)</li>
<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">Special characters (<code>. , - :</code>) should be wrapped in quotes.<br>
<a href="https://www.sqlite.org/fts5.html#full_text_query_syntax" target="_blank" rel="noopener">Full FTS5 syntax reference <i class="bi bi-box-arrow-up-right"></i></a></div>
<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">Search across all channel and direct messages</p>
<p class="mt-2 mb-0">{{ t('search.empty') }}</p>
</div>
</div>
</div>
+79 -1
View File
@@ -1,4 +1,37 @@
{
"analyzer.add": "Add analyzer",
"analyzer.choose": "Choose analyzer",
"analyzer.edit": "Edit analyzer",
"analyzer.name_ph": "e.g. MarWoj Analyzer",
"analyzer.url": "URL template",
"analyzer.url_hint": "Must include <code>{packetHash}</code>.",
"backup.create": "Create Backup",
"backup.current_size": "Current size: …",
"backup.optimize_hint": "Reclaim space freed by message retention. Runs SQLite <code>VACUUM</code>; readers keep working but writes are paused for a few seconds.",
"backup.optimize_now": "Optimize now",
"backup.optimize_title": "Optimize database",
"backup.size": "Current size: {size}",
"backup.title": "Database Backup",
"channels.add_new": "Add New Channel",
"channels.create_btn": "Create & Auto-generate Key",
"channels.create_title": "Create New Channel",
"channels.join_btn": "Join Channel",
"channels.join_existing": "Join Existing",
"channels.join_title": "Join Existing Channel",
"channels.key": "Channel Key:",
"channels.key_label": "Channel Key (32 hex chars)",
"channels.key_optional": "- optional for channels starting with #",
"channels.key_ph": "485af7e164459d280d8818d9c99fb30d (leave empty for # channels)",
"channels.name": "Channel Name",
"channels.name_hint": "Only letters, numbers, _ and -",
"channels.name_ph": "e.g., Malopolska",
"channels.qr_alt": "QR Code",
"channels.region_hint": "Only repeaters allowing the selected region will forward messages from this channel.",
"channels.region_scope_for": "Set Region Scope for",
"channels.scan_qr": "Scan QR Code",
"channels.share_hint": "Share this QR code or key with others to let them join this channel.",
"channels.share_title": "Share Channel",
"channels.your": "Your Channels",
"chat.channels": "Channels",
"chat.copy_route_title": "Tap to copy route",
"chat.emoji_title": "Insert emoji",
@@ -27,6 +60,7 @@
"common.connected": "Connected",
"common.connecting": "Connecting...",
"common.copied": "Copied!",
"common.copy": "Copy",
"common.days_ago": {
"one": "{count}d ago",
"other": "{count}d ago"
@@ -39,6 +73,7 @@
"common.deleting": "Deleting...",
"common.disabled": "Disabled",
"common.disconnected": "Disconnected",
"common.enabled": "Enabled",
"common.failed_with": "Failed: {error}",
"common.hours_ago": "{count}h ago",
"common.hours_ago_long": {
@@ -58,6 +93,7 @@
},
"common.move_down": "Move down",
"common.move_up": "Move up",
"common.name": "Name",
"common.network_error": "Network error",
"common.network_error_detail": "Network error: {error}",
"common.never": "Never",
@@ -214,6 +250,15 @@
"contacts.toast.settings_load_error": "Error loading settings",
"contacts.toast.settings_network_error": "Network error saving settings",
"contacts.toast.settings_save_failed": "Failed to save settings: {error}",
"coord.confirm": "Confirm",
"coord.hint": "Click on the map to select coordinates",
"coord.title": "Pick Coordinates",
"device.share_hint": "Click to generate share code",
"device.stats_hint": "Click to load stats",
"device.tab.info": "Info",
"device.tab.share": "Share",
"device.tab.stats": "Stats",
"device.title": "Device",
"dm.attempt": "Attempt {n}/{max}",
"dm.auto_retry": "Auto Retry",
"dm.auto_retry_title": "Auto Retry: resend DM if no ACK received",
@@ -275,6 +320,7 @@
"logs.loading": "Loading logs...",
"logs.pause_title": "Pause/Resume",
"logs.title": "System Log",
"map.all_contacts": "All Contacts",
"menu.advert": "Send Advert",
"menu.advert_desc": "Announce presence (normal)",
"menu.advert_title": "Send single advertisement (recommended for normal operation)",
@@ -317,6 +363,17 @@
"nav.mark_all_read_title": "Mark all as read",
"nav.select_channel_title": "Select channel",
"nav.tcp_title": "TCP connection",
"observer.add_broker": "Add broker",
"observer.edit_broker": "Edit broker",
"observer.host": "Host",
"observer.name_ph": "e.g. My MQTT server",
"observer.password": "Password",
"observer.password_hint": "Leave the password blank to keep the current one.",
"observer.port": "Port",
"observer.ports_hint": "Typical ports: 1883 (plain), 8883 (TLS).",
"observer.use_tls": "Use TLS",
"observer.username": "Username",
"observer.verify_tls": "Verify TLS certificate",
"pa.any_hb": "Any HB",
"pa.any_hops": "Any hops",
"pa.byte_2_3": "2/3-byte",
@@ -431,6 +488,10 @@
"pa.view.routes": "Routes",
"pa.view.routes_title": "Route segment statistics",
"pa.view_aria": "View",
"quote.full": "Full quote",
"quote.max_bytes": "Max bytes:",
"quote.title": "Quote message",
"quote.truncated": "Truncated",
"repeaters.add": "Add repeater",
"repeaters.add_loading": "Loading device contacts...",
"repeaters.add_modal_title": "Add Repeater",
@@ -655,5 +716,22 @@
"rptmgmt.tools.telemetry": "Telemetry",
"rptmgmt.tools.telemetry_desc": "Sensor channels (Cayenne LPP)",
"rptmgmt.tools_heading": "Management Tools",
"rptmgmt.updated_just_now": "Updated just now"
"rptmgmt.updated_just_now": "Updated just now",
"search.empty": "Search across all channel and direct messages",
"search.fts5_ref": "Full FTS5 syntax reference",
"search.help_title": "Search syntax help",
"search.ph": "Search all messages...",
"search.special_chars": "Special characters (<code>. , - :</code>) should be wrapped in quotes.",
"search.tip_and": "messages containing both words",
"search.tip_not": "hello but not world",
"search.tip_or": "either word",
"search.tip_phrase": "exact phrase",
"search.tip_prefix": "prefix match (hello,hellas...)",
"search.tips": "Search tips:",
"update.checking": "Checking for updates...",
"update.now": "Update Now",
"update.reload": "Reload Page",
"update.title": "Update mc-webui",
"update.updating": "Updating...",
"update.whats_new": "What's new?"
}
+79 -1
View File
@@ -1,4 +1,37 @@
{
"analyzer.add": "Dodaj analizator",
"analyzer.choose": "Wybierz analizator",
"analyzer.edit": "Edytuj analizator",
"analyzer.name_ph": "np. Mój analizator",
"analyzer.url": "Szablon URL",
"analyzer.url_hint": "Musi zawierać <code>{packetHash}</code>.",
"backup.create": "Utwórz kopię",
"backup.current_size": "Bieżący rozmiar: …",
"backup.optimize_hint": "Odzyskaj miejsce zwolnione przez retencję wiadomości. Uruchamia SQLite <code>VACUUM</code>; odczyty działają, ale zapisy są wstrzymane na kilka sekund.",
"backup.optimize_now": "Optymalizuj teraz",
"backup.optimize_title": "Optymalizuj bazę danych",
"backup.size": "Bieżący rozmiar: {size}",
"backup.title": "Kopia zapasowa bazy danych",
"channels.add_new": "Dodaj nowy kanał",
"channels.create_btn": "Utwórz i wygeneruj klucz",
"channels.create_title": "Utwórz nowy kanał",
"channels.join_btn": "Dołącz do kanału",
"channels.join_existing": "Dołącz do istniejącego",
"channels.join_title": "Dołącz do istniejącego kanału",
"channels.key": "Klucz kanału:",
"channels.key_label": "Klucz kanału (32 znaki hex)",
"channels.key_optional": "- opcjonalny dla kanałów zaczynających się od #",
"channels.key_ph": "485af7e164459d280d8818d9c99fb30d (zostaw puste dla kanałów #)",
"channels.name": "Nazwa kanału",
"channels.name_hint": "Tylko litery, cyfry, _ i -",
"channels.name_ph": "np. Malopolska",
"channels.qr_alt": "Kod QR",
"channels.region_hint": "Tylko repeatery zezwalające na wybrany region będą przekazywać wiadomości z tego kanału.",
"channels.region_scope_for": "Ustaw zakres regionu dla",
"channels.scan_qr": "Zeskanuj kod QR",
"channels.share_hint": "Udostępnij ten kod QR lub klucz innym, aby mogli dołączyć do tego kanału.",
"channels.share_title": "Udostępnij kanał",
"channels.your": "Twoje kanały",
"chat.channels": "Kanały",
"chat.copy_route_title": "Dotknij, aby skopiować trasę",
"chat.emoji_title": "Wstaw emoji",
@@ -27,6 +60,7 @@
"common.connected": "Połączono",
"common.connecting": "Łączenie...",
"common.copied": "Skopiowano!",
"common.copy": "Kopiuj",
"common.days_ago": {
"one": "{count} dzień temu",
"few": "{count} dni temu",
@@ -43,6 +77,7 @@
"common.deleting": "Usuwanie...",
"common.disabled": "Wyłączone",
"common.disconnected": "Rozłączono",
"common.enabled": "Włączone",
"common.failed_with": "Niepowodzenie: {error}",
"common.hours_ago": "{count} godz. temu",
"common.hours_ago_long": {
@@ -68,6 +103,7 @@
},
"common.move_down": "Przesuń w dół",
"common.move_up": "Przesuń w górę",
"common.name": "Nazwa",
"common.network_error": "Błąd sieci",
"common.network_error_detail": "Błąd sieci: {error}",
"common.never": "Nigdy",
@@ -232,6 +268,15 @@
"contacts.toast.settings_load_error": "Błąd wczytywania ustawień",
"contacts.toast.settings_network_error": "Błąd sieci przy zapisie ustawień",
"contacts.toast.settings_save_failed": "Nie udało się zapisać ustawień: {error}",
"coord.confirm": "Zatwierdź",
"coord.hint": "Kliknij na mapie, aby wybrać współrzędne",
"coord.title": "Wybierz współrzędne",
"device.share_hint": "Kliknij, aby wygenerować kod udostępniania",
"device.stats_hint": "Kliknij, aby wczytać statystyki",
"device.tab.info": "Informacje",
"device.tab.share": "Udostępnij",
"device.tab.stats": "Statystyki",
"device.title": "Urządzenie",
"dm.attempt": "Próba {n}/{max}",
"dm.auto_retry": "Auto ponawianie",
"dm.auto_retry_title": "Auto ponawianie: wyślij DM ponownie, jeśli nie przyjdzie ACK",
@@ -299,6 +344,7 @@
"logs.loading": "Ładowanie dziennika...",
"logs.pause_title": "Wstrzymaj/Wznów",
"logs.title": "Dziennik systemowy",
"map.all_contacts": "Wszystkie kontakty",
"menu.advert": "Wyślij advert",
"menu.advert_desc": "Ogłoś obecność (normalnie)",
"menu.advert_title": "Wyślij pojedynczy advert (zalecane przy normalnej pracy)",
@@ -341,6 +387,17 @@
"nav.mark_all_read_title": "Oznacz wszystko jako przeczytane",
"nav.select_channel_title": "Wybierz kanał",
"nav.tcp_title": "Połączenie TCP",
"observer.add_broker": "Dodaj brokera",
"observer.edit_broker": "Edytuj brokera",
"observer.host": "Host",
"observer.name_ph": "np. Mój serwer MQTT",
"observer.password": "Hasło",
"observer.password_hint": "Zostaw hasło puste, aby zachować bieżące.",
"observer.port": "Port",
"observer.ports_hint": "Typowe porty: 1883 (bez szyfrowania), 8883 (TLS).",
"observer.use_tls": "Użyj TLS",
"observer.username": "Nazwa użytkownika",
"observer.verify_tls": "Weryfikuj certyfikat TLS",
"pa.any_hb": "Dowolne HB",
"pa.any_hops": "Dowolne hopy",
"pa.byte_2_3": "2/3 bajty",
@@ -471,6 +528,10 @@
"pa.view.routes": "Trasy",
"pa.view.routes_title": "Statystyki segmentów tras",
"pa.view_aria": "Widok",
"quote.full": "Pełny cytat",
"quote.max_bytes": "Maks. bajtów:",
"quote.title": "Zacytuj wiadomość",
"quote.truncated": "Skrócony",
"repeaters.add": "Dodaj repeater",
"repeaters.add_loading": "Wczytywanie kontaktów z urządzenia...",
"repeaters.add_modal_title": "Dodaj repeater",
@@ -711,5 +772,22 @@
"rptmgmt.tools.telemetry": "Telemetria",
"rptmgmt.tools.telemetry_desc": "Kanały czujników (Cayenne LPP)",
"rptmgmt.tools_heading": "Narzędzia zarządzania",
"rptmgmt.updated_just_now": "Zaktualizowano przed chwilą"
"rptmgmt.updated_just_now": "Zaktualizowano przed chwilą",
"search.empty": "Szukaj we wszystkich wiadomościach kanałowych i prywatnych",
"search.fts5_ref": "Pełna dokumentacja składni FTS5",
"search.help_title": "Pomoc do składni wyszukiwania",
"search.ph": "Szukaj we wszystkich wiadomościach...",
"search.special_chars": "Znaki specjalne (<code>. , - :</code>) należy ująć w cudzysłów.",
"search.tip_and": "wiadomości zawierające oba słowa",
"search.tip_not": "hello, ale bez world",
"search.tip_or": "dowolne z tych słów",
"search.tip_phrase": "dokładna fraza",
"search.tip_prefix": "dopasowanie przedrostka (hello, hellas...)",
"search.tips": "Wskazówki wyszukiwania:",
"update.checking": "Sprawdzanie aktualizacji...",
"update.now": "Aktualizuj teraz",
"update.reload": "Przeładuj stronę",
"update.title": "Aktualizacja mc-webui",
"update.updating": "Aktualizowanie...",
"update.whats_new": "Co nowego?"
}
+1 -1
View File
@@ -12,7 +12,7 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
### Features
- **The interface can be translated, and Polish has started.** mc-webui was written English-only, with every label and message baked into the code. There is now a translation system behind it, and a **Language** setting at the top of Settings → Appearance. So far it covers the groundwork plus the panels that open in their own window **System Log**, **Console**, **My Repeaters**, **Path Analyzer**, **Contacts** and **Direct Messages**. The main chat window and the Settings dialog follow next, so for now parts of the interface are still English whichever language you pick. Your choice applies to the browser you set it in and also becomes the default for anyone else opening the server without a preference of their own.
- **The interface can be translated, and Polish has started.** mc-webui was written English-only, with every label and message baked into the code. There is now a translation system behind it, and a **Language** setting at the top of Settings → Appearance. So far it covers the main window — menu, chat, and its dialogs — plus every panel that opens in its own window: **System Log**, **Console**, **My Repeaters**, **Path Analyzer**, **Contacts** and **Direct Messages**. The **Settings** dialog and many of the short messages the app shows while it works are still English; they follow next. Your choice applies to the browser you set it in and also becomes the default for anyone else opening the server without a preference of their own.
- **You can add a language yourself, without waiting for a release.** A language is a single file. Copy `en.json` from `app/translations/`, translate the values, and drop it into a `translations` folder inside your config directory — the same place the database lives. Refresh the page and it appears in the Language list, named however you named it, with no rebuild and no restart. Anything you leave untranslated falls back to English, so a half-finished translation is perfectly usable. A file you drop in also overrides one that ships with the app, so you can correct the built-in Polish on your own server. English and Polish ship in the box; everything else is open to whoever wants to write it. See [translations.md](translations.md), which explains the format and — importantly — which words to leave alone: mesh terms like flood, hop, advert, RSSI and the repeater roles stay English in every language, because that is what the firmware, the CLI and the forums all use. The Console and the log lines themselves stay English for the same reason.
- **Times and dates behave the same in every language.** Only the words are translated — "Yesterday" becomes "Wczoraj", "5 min ago" becomes "5 min temu". The clock and the number formatting keep following your browser's own settings, so switching the menus to English will not suddenly turn your 24-hour clock into "9:53 AM". One display of large numbers on the repeater statistics page had been hard-coded to American thousands separators; it now follows your locale like everything else.