diff --git a/app/static/js/repeaters.js b/app/static/js/repeaters.js
index b054236..69a3afb 100644
--- a/app/static/js/repeaters.js
+++ b/app/static/js/repeaters.js
@@ -91,14 +91,10 @@ function esc(s) {
.replaceAll('"', '"');
}
-function formatRelativeTime(timestamp) {
- if (!timestamp) return 'Never';
- const diff = Math.floor(Date.now() / 1000) - timestamp;
- if (diff < 60) return 'Just now';
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
- return `${Math.floor(diff / 86400)}d ago`;
-}
+// Relative time comes from datetime-utils.js as formatTimeAgo(). The local copy this
+// replaced also had a "Never" branch for a falsy timestamp, which was unreachable — the
+// single call site already guards it. contacts.js and dm.js still carry their own
+// copies; they get folded in with their own slices.
// ================================================================
// State
@@ -132,14 +128,14 @@ async function loadRepeaters(forceRefresh = false) {
const response = await fetch(url);
const data = await response.json();
if (!data.success) {
- listEl.innerHTML = `
${esc(data.error || 'Failed to load repeaters')}
`;
+ listEl.innerHTML = `${esc(data.error || t('repeaters.load_failed'))}
`;
return;
}
myRepeaters = data.repeaters || [];
renderRepeaterRows();
} catch (e) {
console.error('Failed to load repeaters:', e);
- listEl.innerHTML = 'Failed to load repeaters
';
+ listEl.innerHTML = `${tHtml('repeaters.load_failed')}
`;
}
}
@@ -149,9 +145,7 @@ function renderRepeaterRows() {
const countEl = document.getElementById('repeaterCount');
if (countEl) {
- countEl.textContent = myRepeaters.length
- ? `${myRepeaters.length} repeater${myRepeaters.length === 1 ? '' : 's'}`
- : '';
+ countEl.textContent = myRepeaters.length ? tn('repeaters.count', myRepeaters.length) : '';
}
listEl.innerHTML = '';
@@ -172,14 +166,14 @@ function renderRepeaterRows() {
: ' ';
const statusLine = isLoggingIn
- ? 'Logging in… (may take up to 60 s on flood paths) '
+ ? `${tHtml('repeaters.row.logging_in')} `
: (r.on_device
? `${esc(r.path_or_mode || '—')} `
- : 'Not stored on the device ');
+ : `${tHtml('repeaters.row.not_on_device')} `);
// "last login" goes on its own line so a long path keeps the full row
// width to itself (on narrow phones it otherwise gets truncated hard).
const roleLine = (r.on_device && !isLoggingIn && r.last_login_at)
- ? `last login: ${esc(r.last_login_role || '?')} `
+ ? `${tHtml('repeaters.row.last_login', { role: r.last_login_role || '?' })} `
: '';
row.innerHTML = `
@@ -192,14 +186,14 @@ function renderRepeaterRows() {
+ title="${tHtml('repeaters.row.set_path_title')}" ${r.on_device ? '' : 'disabled'}>
+ title="${tHtml(r.password_set ? 'repeaters.password.change_title' : 'repeaters.password.set_title')}">
-
+
@@ -232,11 +226,11 @@ function onRepeaterClick(pubkey) {
const r = findRepeater(pubkey);
if (!r) return;
if (!r.on_device) {
- showNotification('This repeater is not stored on the device — it cannot be managed', 'warning');
+ showNotification(t('repeaters.toast.not_on_device'), 'warning');
return;
}
if (_loginPubkey) {
- showNotification('Another login is already in progress', 'warning');
+ showNotification(t('repeaters.toast.login_in_progress'), 'warning');
return;
}
if (!r.password_set) {
@@ -275,12 +269,12 @@ async function doLogin(pubkey, password, save) {
if (data && data.success) {
const role = data.is_admin ? 'ADMIN' : 'GUEST';
- showNotification(`Logged in to ${r.name || 'repeater'} as ${role}`, 'success');
+ showNotification(t('repeaters.toast.logged_in', { name: r.name || t('repeaters.term'), role }), 'success');
window.location.href = `/repeaters/manage?pubkey=${encodeURIComponent(pubkey)}`;
return;
} else {
await loadRepeaters();
- const error = (data && data.error) || 'Login failed';
+ const error = (data && data.error) || t('repeaters.toast.login_failed');
showNotification(error, 'danger');
// Offer a retry with password correction (wrong password and
// unreachable repeater are indistinguishable at protocol level).
@@ -306,16 +300,18 @@ function openPasswordModal(mode, pubkey, errorHint = '') {
const name = r.name || r.public_key.substring(0, 12);
if (mode === 'set') {
- title.textContent = `Set password — ${name}`;
- info.textContent = 'The password is stored in the app database and used to log in to this repeater without asking again.';
- submitBtn.textContent = 'Save';
+ title.textContent = `${t('repeaters.password.set_title')} — ${name}`;
+ info.textContent = t('repeaters.password.set_info');
+ submitBtn.textContent = t('common.save');
saveWrap.style.display = 'none';
} else {
- title.textContent = `Log in — ${name}`;
+ title.textContent = `${t('repeaters.password.login_title')} — ${name}`;
+ // errorHint comes from the backend and stays English; only the advice around it
+ // is translated.
info.innerHTML = errorHint
- ? `${esc(errorHint)} Check the password and try again.`
- : 'Enter the repeater password to log in.';
- submitBtn.textContent = 'Log in';
+ ? `${esc(errorHint)} ${tHtml('repeaters.password.retry_hint')}`
+ : tHtml('repeaters.password.login_info');
+ submitBtn.textContent = t('repeaters.password.login_btn');
saveWrap.style.display = '';
saveCheck.checked = true;
}
@@ -351,7 +347,7 @@ async function submitPasswordModal() {
const password = input.value;
if (!password) {
- showNotification('Password cannot be empty', 'warning');
+ showNotification(t('repeaters.toast.password_empty'), 'warning');
return;
}
@@ -366,13 +362,13 @@ async function submitPasswordModal() {
});
const data = await response.json();
if (data.success) {
- showNotification('Password saved', 'success');
+ showNotification(t('repeaters.toast.password_saved'), 'success');
await loadRepeaters();
} else {
- showNotification(data.error || 'Failed to save password', 'danger');
+ showNotification(data.error || t('repeaters.toast.password_save_failed'), 'danger');
}
} catch (e) {
- showNotification('Failed to save password', 'danger');
+ showNotification(t('repeaters.toast.password_save_failed'), 'danger');
}
} else {
doLogin(pubkey, password, saveCheck.checked);
@@ -387,7 +383,9 @@ function openRemoveModal(pubkey) {
const r = findRepeater(pubkey);
if (!r) return;
_removeCtx = { pubkey };
- document.getElementById('removeRepeaterName').textContent = r.name || r.public_key.substring(0, 12);
+ document.getElementById('removeRepeaterQuestion').innerHTML = tHtml('repeaters.remove.question', {
+ name: r.name || r.public_key.substring(0, 12)
+ });
_removeModal.show();
}
@@ -399,13 +397,13 @@ async function confirmRemoveRepeater() {
const response = await fetch(`/api/repeaters/${encodeURIComponent(pubkey)}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
- showNotification('Repeater removed from list', 'info');
+ showNotification(t('repeaters.toast.removed'), 'info');
await loadRepeaters();
} else {
- showNotification(data.error || 'Failed to remove repeater', 'danger');
+ showNotification(data.error || t('repeaters.toast.remove_failed'), 'danger');
}
} catch (e) {
- showNotification('Failed to remove repeater', 'danger');
+ showNotification(t('repeaters.toast.remove_failed'), 'danger');
}
}
@@ -416,14 +414,14 @@ async function confirmRemoveRepeater() {
async function openAddRepeaterModal() {
_addRepeaterModal.show();
const listEl = document.getElementById('deviceRptList');
- listEl.innerHTML = 'Loading device contacts...
';
+ listEl.innerHTML = `${tHtml('repeaters.add_loading')}
`;
document.getElementById('deviceRptSearch').value = '';
try {
const response = await fetch('/api/contacts/detailed');
const data = await response.json();
if (!data.success) {
- listEl.innerHTML = `${esc(data.error || 'Failed to load device contacts')}
`;
+ listEl.innerHTML = `${esc(data.error || t('repeaters.toast.contacts_load_failed'))}
`;
return;
}
_deviceRepeaters = (data.contacts || [])
@@ -432,7 +430,7 @@ async function openAddRepeaterModal() {
renderDeviceRepeaterList();
} catch (e) {
console.error('Failed to load device contacts:', e);
- listEl.innerHTML = 'Failed to load device contacts
';
+ listEl.innerHTML = `${tHtml('repeaters.toast.contacts_load_failed')}
`;
}
}
@@ -447,10 +445,10 @@ function renderDeviceRepeaterList() {
items = items.filter(c => (c.name || '').toLowerCase().includes(searchVal));
}
- if (countEl) countEl.textContent = `${(_deviceRepeaters || []).length} repeaters on device`;
+ if (countEl) countEl.textContent = tn('repeaters.on_device_count', (_deviceRepeaters || []).length);
if (!items.length) {
- listEl.innerHTML = 'No repeaters found on the device.
';
+ listEl.innerHTML = `${tHtml('repeaters.add_none_found')}
`;
return;
}
@@ -466,7 +464,7 @@ function renderDeviceRepeaterList() {
${esc(c.public_key_prefix)} · ${esc(c.path_or_mode || '')}
${added
- ? 'Added '
+ ? `${tHtml('repeaters.added_badge')} `
: ' '}
`;
if (!added) {
@@ -485,14 +483,14 @@ async function addRepeater(contact) {
});
const data = await response.json();
if (data.success) {
- showNotification(`${contact.name || 'Repeater'} added`, 'success');
+ showNotification(t('repeaters.toast.added', { name: contact.name || t('repeaters.term') }), 'success');
await loadRepeaters();
renderDeviceRepeaterList();
} else {
- showNotification(data.error || 'Failed to add repeater', 'danger');
+ showNotification(data.error || t('repeaters.toast.add_failed'), 'danger');
}
} catch (e) {
- showNotification('Failed to add repeater', 'danger');
+ showNotification(t('repeaters.toast.add_failed'), 'danger');
}
}
@@ -523,13 +521,13 @@ async function renderPathList(pubkey) {
const listEl = document.getElementById('pathList');
if (!listEl) return;
- listEl.innerHTML = 'Loading...
';
+ listEl.innerHTML = `${tHtml('common.loading')}
`;
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`);
const data = await response.json();
if (!data.success || !data.paths.length) {
- listEl.innerHTML = 'No paths configured. Use + to add.
';
+ listEl.innerHTML = `${tHtml('repeaters.paths.none')}
`;
return;
}
@@ -552,18 +550,18 @@ async function renderPathList(pubkey) {
${path.label ? `${esc(path.label)} ` : ''}
- ${index > 0 ? ` ` : ''}
- ${index < data.paths.length - 1 ? ` ` : ''}
-
+ ${index > 0 ? ` ` : ''}
+ ${index < data.paths.length - 1 ? ` ` : ''}
+
@@ -589,7 +587,7 @@ async function renderPathList(pubkey) {
});
});
} catch (e) {
- listEl.innerHTML = 'Failed to load paths
';
+ listEl.innerHTML = `${tHtml('repeaters.paths.load_failed')}
`;
console.error('Failed to load paths:', e);
}
}
@@ -615,14 +613,14 @@ async function applyPathToDevice(pubkey, pathId) {
);
const data = await response.json();
if (data.success) {
- showNotification('Device path updated', 'info');
+ showNotification(t('repeaters.toast.path_updated'), 'info');
await refreshDevicePathDisplay();
} else {
- showNotification(data.error || 'Failed to set device path', 'danger');
+ showNotification(data.error || t('repeaters.toast.path_set_failed'), 'danger');
}
} catch (e) {
console.error('Failed to apply path to device:', e);
- showNotification('Failed to set device path', 'danger');
+ showNotification(t('repeaters.toast.path_set_failed'), 'danger');
}
}
@@ -674,7 +672,7 @@ async function saveNewPath() {
const label = document.getElementById('pathLabelInput').value.trim();
if (!pathHex) {
- showNotification('Path hex is required', 'danger');
+ showNotification(t('repeaters.toast.path_hex_required'), 'danger');
return;
}
@@ -686,13 +684,15 @@ async function saveNewPath() {
if (hashSize === 1) {
const adjDupes = hops.filter((h, i) => i > 0 && hops[i - 1] === h);
if (adjDupes.length > 0) {
- showNotification(`Adjacent duplicate hop(s): ${[...new Set(adjDupes)].map(d => d.toUpperCase()).join(', ')}`, 'danger');
+ showNotification(tn('repeaters.toast.adjacent_dupes', [...new Set(adjDupes)].length,
+ { hops: [...new Set(adjDupes)].map(d => d.toUpperCase()).join(', ') }), 'danger');
return;
}
} else {
const dupes = hops.filter((h, i) => hops.indexOf(h) !== i);
if (dupes.length > 0) {
- showNotification(`Duplicate hop(s): ${[...new Set(dupes)].map(d => d.toUpperCase()).join(', ')}`, 'danger');
+ showNotification(tn('repeaters.toast.dupes', [...new Set(dupes)].length,
+ { hops: [...new Set(dupes)].map(d => d.toUpperCase()).join(', ') }), 'danger');
return;
}
}
@@ -707,19 +707,19 @@ async function saveNewPath() {
if (data.success) {
_addPathModal.hide();
await renderPathList(pubkey);
- showNotification('Path added', 'info');
+ showNotification(t('repeaters.toast.path_added'), 'info');
} else {
- showNotification(data.error || 'Failed to add path', 'danger');
+ showNotification(data.error || t('repeaters.toast.path_add_failed'), 'danger');
}
} catch (e) {
- showNotification('Failed to add path', 'danger');
+ showNotification(t('repeaters.toast.path_add_failed'), 'danger');
}
}
async function resetPathToFlood() {
const pubkey = _pathPubkey;
if (!pubkey) return;
- if (!confirm('Reset device path to FLOOD?\n\nThis resets the path on the device only. Your configured paths will be kept.')) {
+ if (!confirm(t('repeaters.confirm.reset_flood'))) {
return;
}
try {
@@ -728,20 +728,20 @@ async function resetPathToFlood() {
});
const data = await response.json();
if (data.success) {
- showNotification('Device path reset to FLOOD', 'info');
+ showNotification(t('repeaters.toast.reset_flood_done'), 'info');
await refreshDevicePathDisplay();
} else {
- showNotification(data.error || 'Reset failed', 'danger');
+ showNotification(data.error || t('repeaters.toast.reset_failed'), 'danger');
}
} catch (e) {
- showNotification('Reset failed', 'danger');
+ showNotification(t('repeaters.toast.reset_failed'), 'danger');
}
}
async function clearAllPaths() {
const pubkey = _pathPubkey;
if (!pubkey) return;
- if (!confirm('Clear all configured paths?\n\nThis will delete all paths from the database. The device path will not be changed.')) {
+ if (!confirm(t('repeaters.confirm.clear_paths'))) {
return;
}
try {
@@ -751,12 +751,12 @@ async function clearAllPaths() {
const data = await response.json();
if (data.success) {
await renderPathList(pubkey);
- showNotification(`${data.paths_deleted || 0} path(s) cleared`, 'info');
+ showNotification(tn('repeaters.toast.paths_cleared', data.paths_deleted || 0), 'info');
} else {
- showNotification(data.error || 'Clear failed', 'danger');
+ showNotification(data.error || t('repeaters.toast.clear_failed'), 'danger');
}
} catch (e) {
- showNotification('Clear failed', 'danger');
+ showNotification(t('repeaters.toast.clear_failed'), 'danger');
}
}
@@ -784,7 +784,7 @@ async function loadHopPicker() {
_repeatersCache = data.repeaters;
}
} catch (e) {
- listEl.innerHTML = 'Failed to load repeaters
';
+ listEl.innerHTML = `${tHtml('repeaters.load_failed')}
`;
return;
}
}
@@ -814,7 +814,7 @@ function renderHopPickerList(listEl, repeaters) {
});
if (!filtered.length) {
- listEl.innerHTML = 'No repeaters found
';
+ listEl.innerHTML = `${tHtml('repeaters.picker.none')}
`;
return;
}
@@ -830,7 +830,7 @@ function renderHopPickerList(listEl, repeaters) {
item.innerHTML = `
${esc(prefix)}
${esc(rpt.name)}
- ${samePrefix > 1 ? ' ' : ''}
+ ${samePrefix > 1 ? ` ` : ''}
`;
item.addEventListener('click', () => {
appendHopToPathInput(prefix.toLowerCase(), hashSize);
@@ -855,12 +855,12 @@ function appendHopToPathInput(prefixLc, hashSize) {
const existingHops = getCurrentPathHops(hashSize);
if (hashSize === 1) {
if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefixLc) {
- showNotification(`${prefixLc.toUpperCase()} cannot be adjacent to itself`, 'warning');
+ showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefixLc.toUpperCase() }), 'warning');
return false;
}
} else {
if (existingHops.includes(prefixLc)) {
- showNotification(`${prefixLc.toUpperCase()} is already in the path`, 'warning');
+ showNotification(t('repeaters.toast.hop_already_used', { hop: prefixLc.toUpperCase() }), 'warning');
return false;
}
}
@@ -901,7 +901,8 @@ function checkUniquenessWarning(repeaters, hashSize) {
});
if (ambiguous.length > 0) {
- warningEl.textContent = `⚠ Ambiguous prefix(es): ${ambiguous.map(h => h.toUpperCase()).join(', ')}. Consider using a larger hash size.`;
+ warningEl.textContent = tn('repeaters.toast.ambiguous_prefix', ambiguous.length,
+ { hops: ambiguous.map(h => h.toUpperCase()).join(', ') });
warningEl.style.display = '';
} else {
warningEl.style.display = 'none';
@@ -1018,7 +1019,7 @@ async function loadRepeaterMapMarkers() {
repeaters.forEach(rpt => {
const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase();
- const lastSeen = rpt.last_advert ? formatRelativeTime(rpt.last_advert) : '';
+ const lastSeen = rpt.last_advert ? formatTimeAgo(rpt.last_advert) : '';
const marker = L.circleMarker([rpt.adv_lat, rpt.adv_lon], {
radius: 10,
@@ -1032,7 +1033,7 @@ async function loadRepeaterMapMarkers() {
marker.bindPopup(
`${esc(rpt.name)} ` +
`${esc(prefix)}` +
- (lastSeen ? `Last seen: ${lastSeen} ` : '')
+ (lastSeen ? `${tHtml('repeaters.map.last_seen', { time: lastSeen })} ` : '')
);
marker.on('click', () => {
diff --git a/app/templates/repeaters.html b/app/templates/repeaters.html
index 79e9bc9..275f4a5 100644
--- a/app/templates/repeaters.html
+++ b/app/templates/repeaters.html
@@ -3,7 +3,7 @@
- My Repeaters - mc-webui
+ {{ t('repeaters.title') }} - mc-webui