Files
MarekWo 93c74d150f feat(i18n): translate the My Repeaters panel (stage 3a)
~100 keys across repeaters.html and repeaters.js: the list, the add picker, the
password/login flow, the path editor, the map picker, and every toast.

Boundary call made here and written into docs/translations.md: "path" is
translated ("ścieżka"), because it is a word the user reads, not a value the
device reports. The mode names it can hold — Flood, Direct, FLOOD — stay as-is,
as do hop hex, repeater names and login roles. The rule is now stated explicitly
in the translator guide, since "Skonfigurowane path" is exactly the kind of
half-translation that makes a UI worse than leaving it in English.

Removed a third copy of relative-time formatting. Stage 0b merged app.js and
dm.js's formatTime but I grepped for the wrong names and missed
formatRelativeTime; repeaters.js had its own, with a "Never" branch that was
unreachable because its one call site already guards a falsy timestamp. It now
uses the shared formatTimeAgo. contacts.js and dm.js still carry their own
copies — noted in place, folded in with stages 5 and 6.

Two checker fixes, both found by this slice:
- Keys chosen inline, tHtml(x ? 'a.b' : 'c.d'), were invisible to the scanner.
  That produced false "unused key" warnings and would have silently hidden a
  typo in either branch. Added a second pattern for that shape.
- The generic en/pl diff caught two real misses I had made — the page <title>
  and the toast header, both still reading "My Repeaters". Swept every other
  template for the same pattern; the remaining ones belong to later stages.

Verified: checker clean, and the diff now reports every comparable string on
/repeaters as changed between en and pl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:49:13 +02:00

444 lines
21 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>{{ t('repeaters.title') }} - mc-webui</title>
<!-- 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>
{% include "_head_i18n.html" %}
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (for the repeater map picker) -->
<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') }}">
<style>
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
html, body {
overflow: auto !important;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
.repeaters-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.repeaters-list-wrap {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.75rem 1rem;
}
.repeater-row {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.repeater-row .rpt-main {
cursor: pointer;
min-width: 0;
}
.repeater-row.rpt-offline .rpt-main {
cursor: default;
}
.repeater-row.rpt-offline {
opacity: 0.65;
}
.rpt-path {
font-size: 0.8rem;
}
.empty-state {
text-align: center;
color: var(--text-secondary, #6c757d);
padding: 3rem 1rem;
}
.empty-state i {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}
/* Device repeater picker rows */
.device-rpt-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
}
.device-rpt-item:last-child {
border-bottom: none;
}
.device-rpt-item:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.device-rpt-item.added {
opacity: 0.55;
cursor: default;
}
/* Path list items (adapted from DM Contact Info) */
.path-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.path-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
margin-bottom: 0.35rem;
font-size: 0.85rem;
}
.path-list-item.primary {
border-color: #ffc107;
}
.path-list-item .path-hex {
font-family: var(--bs-font-monospace, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-list-item .path-label {
color: var(--text-secondary, #6c757d);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
.path-list-item .path-actions {
margin-left: auto;
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.path-uniqueness-warning {
font-size: 0.75rem;
color: #b58900;
}
.repeater-picker-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
.repeater-picker-item:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
</style>
</head>
<body>
<!-- Toolbar -->
<div class="repeaters-toolbar">
<span class="text-muted small flex-grow-1" id="repeaterCount"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshBtn" title="{{ t('repeaters.refresh_title') }}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" class="btn btn-sm btn-success" id="addRepeaterBtn">
<i class="bi bi-plus-lg"></i> {{ t('repeaters.add') }}
</button>
</div>
<!-- Repeater list -->
<div class="repeaters-list-wrap">
<div id="repeaterList"></div>
<div class="empty-state" id="emptyState" style="display: none;">
<i class="bi bi-diagram-3"></i>
<p class="mb-1">{{ t('repeaters.empty') }}</p>
<p class="small mb-0">{{ t_html('repeaters.empty_hint') }}<br>
{{ t('repeaters.empty_note') }}</p>
</div>
</div>
<!-- Add Repeater Picker Modal -->
<div class="modal fade" id="addRepeaterModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-plus-circle"></i> {{ t('repeaters.add_modal_title') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 d-flex flex-column">
<div class="p-2 border-bottom">
<input type="text" class="form-control form-control-sm" id="deviceRptSearch"
placeholder="{{ t('repeaters.add_search_ph') }}" autocomplete="off">
</div>
<div id="deviceRptList" style="overflow-y: auto;">
<div class="text-muted small p-3">{{ t('repeaters.add_loading') }}</div>
</div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="deviceRptCount"></span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
</div>
</div>
</div>
</div>
<!-- Password Modal (set password / login prompt) -->
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true">
<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-key"></i> <span id="passwordModalTitle">{{ t('repeaters.password.set_title') }}</span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2 small text-muted" id="passwordModalInfo"></div>
<div class="input-group input-group-sm mb-2">
<input type="password" class="form-control" id="passwordInput"
placeholder="{{ t('repeaters.password.input_ph') }}" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="togglePasswordBtn" title="{{ t('repeaters.password.toggle_title') }}">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-check" id="savePasswordWrap">
<input class="form-check-input" type="checkbox" id="savePasswordCheck" checked>
<label class="form-check-label small" for="savePasswordCheck">
{{ t('repeaters.password.remember') }}
</label>
</div>
</div>
<div class="modal-footer py-2">
<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="passwordSubmitBtn">{{ t('common.save') }}</button>
</div>
</div>
</div>
</div>
<!-- Path Management Modal (per repeater) -->
<div class="modal fade" id="pathModal" tabindex="-1" aria-hidden="true">
<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-signpost-split"></i> {{ t('repeaters.paths.title') }} — <span id="pathModalName"></span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pb-1">
<div class="small text-muted mb-2">
{{ t('repeaters.paths.device_path') }} <span class="font-monospace" id="pathModalCurrent"></span>
</div>
<div class="path-section-header">
<h6 class="mb-0 small fw-bold">{{ t('repeaters.paths.configured') }}</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="addPathBtn" title="{{ t('repeaters.paths.add_title') }}">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div id="pathList"></div>
<div class="d-flex justify-content-end gap-2 mt-1 mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="clearPathsBtn"
title="{{ t('repeaters.paths.clear_title') }}">
<i class="bi bi-trash"></i> {{ t('repeaters.paths.clear') }}
</button>
{# FLOOD is the device's own mode name — kept as-is in every language. #}
<button type="button" class="btn btn-outline-danger btn-sm" id="resetFloodBtn"
title="{{ t('repeaters.paths.reset_flood_title') }}">
<i class="bi bi-broadcast"></i> {{ t('repeaters.paths.reset_flood') }}
</button>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
</div>
</div>
</div>
</div>
<!-- Add Path Modal (adapted from DM panel) -->
<div class="modal fade" id="addPathModal" tabindex="-1" style="z-index: 1070;">
<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-signpost-split"></i> {{ t('repeaters.addpath.title') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small mb-1">{{ t('repeaters.addpath.hash_size') }}</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
<label class="btn btn-outline-secondary" for="pathHash1">{{ t('repeaters.addpath.hash_1b') }}</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
<label class="btn btn-outline-secondary" for="pathHash2">{{ t('repeaters.addpath.hash_2b') }}</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
<label class="btn btn-outline-secondary" for="pathHash3">{{ t('repeaters.addpath.hash_3b') }}</label>
</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">{{ t('repeaters.addpath.path_hex') }}</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control font-monospace" id="pathHexInput"
placeholder="{{ t('repeaters.addpath.path_hex_ph') }}" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterBtn"
title="{{ t('repeaters.addpath.pick_list_title') }}">
<i class="bi bi-plus-circle"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterMapBtn"
title="{{ t('repeaters.addpath.pick_map_title') }}">
<i class="bi bi-geo-alt"></i>
</button>
</div>
<div id="pathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
</div>
<!-- Repeater picker (hidden by default) -->
<div id="repeaterPicker" style="display: none;" class="border rounded mb-2">
<div class="d-flex border-bottom">
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">{{ t('repeaters.addpath.by_name') }}</label>
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">{{ t('repeaters.addpath.by_id') }}</label>
</div>
<input type="text" class="form-control form-control-sm border-0"
id="repeaterSearch" placeholder="{{ t('repeaters.addpath.search_ph') }}" autocomplete="off">
</div>
<div id="repeaterList2" style="max-height: 180px; overflow-y: auto;"></div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">{{ t('repeaters.addpath.label') }}</label>
<input type="text" class="form-control form-control-sm" id="pathLabelInput"
placeholder="{{ t('repeaters.addpath.label_ph') }}" maxlength="50">
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-sm btn-primary" id="savePathBtn">{{ t('repeaters.addpath.title') }}</button>
</div>
</div>
</div>
</div>
<!-- Repeater Map Picker Modal (adapted from DM panel) -->
<div class="modal fade" id="repeaterMapModal" 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('repeaters.map.title') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
<label class="form-check-label small" for="rptMapCachedSwitch">{{ t('repeaters.map.cached') }}</label>
</div>
<span class="text-muted small ms-auto" id="rptMapCount"></span>
</div>
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="rptMapSelected">{{ t('repeaters.map.hint') }}</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>{{ t('common.add') }}</button>
</div>
</div>
</div>
</div>
<!-- Remove Confirmation Modal -->
<div class="modal fade" id="removeRepeaterModal" tabindex="-1" aria-hidden="true">
<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-trash"></i> {{ t('repeaters.remove.title') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{# Filled by JS via tHtml() so the name can sit anywhere in the sentence. #}
<p class="mb-1" id="removeRepeaterQuestion"></p>
<p class="small text-muted mb-0">{{ t('repeaters.remove.note') }}</p>
</div>
<div class="modal-footer py-2">
<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-danger" id="removeRepeaterConfirmBtn">{{ t('common.remove') }}</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications (position 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">{{ t('repeaters.title') }}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap 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>
<!-- My Repeaters JS -->
<script src="{{ url_for('static', filename='js/repeaters.js') }}"></script>
</body>
</html>