feat(i18n): translate the DM panel (stage 6)

Covers app/templates/dm.html and app/static/js/dm.js: the sidebar, the
searchable conversation selector, the composer, message bubbles and their
delivery meta, the Contact Info modal, path management, and the repeater
list/map pickers.

The DM panel's path management and repeater pickers are the code the My
Repeaters panel was adapted from, so its strings reuse the repeaters.*
keys added in stage 3a rather than duplicating them. Shared chat chrome
(filter bar, FAB titles, composer placeholder, status bar) goes to a new
chat.* namespace so index.html and app.js can reuse it in stages 7 and 9,
and console.status.* is promoted to common.connected/disconnected/
connecting now that two status bars want the same three words.

formatRelativeTimeDm() was the last of the four local relative-time
implementations; it is gone, folded into formatTimeAgo() from
datetime-utils.js. The contact-info row uses the long form to match the
contacts page, the map popup the short form to match My Repeaters.

Also fixed along the way, both found by grepping this slice's shared
strings against the already-translated panels:

- repeaters.js reset the map picker label from JS in three places with a
  hardcoded English string, so the header translated on load and flipped
  back to English the moment the picker opened (missed in stage 3a).
- tHtml() on a plural key always renders the "one" form, because only
  tn() passes the count to the resolver. That made the repeater picker's
  shared-prefix tooltip read "3 repeater ma ten prefiks". Fixed in both
  callers and now an error in scripts/i18n_check.py, which had no way to
  catch it.

Left English on purpose: Flood/Direct/FLOOD and the SNR labels (glossary),
and the 'Device path' label written to the DB on import - it is stored
data, so translating it would freeze one language into the row.

Catalog: 471 -> 526 keys, pl at 100%. Verified against the local
container with i18n-diff.js plus a new i18n-stage6-dm.js that drives the
panel through each modal, since the diff tool cannot see hidden ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-31 21:38:14 +02:00
parent b1add0efc2
commit 3b34dd9646
9 changed files with 315 additions and 187 deletions
+4 -4
View File
@@ -60,7 +60,7 @@ function connectWebSocket() {
updateStatus('disconnected');
enableInput(false);
// Transient session event — show inline but don't persist to transcript
addMessage(t('console.status.disconnected'), 'error', false);
addMessage(t('common.disconnected'), 'error', false);
// Clear pending command indicator
if (pendingCommandDiv) {
@@ -245,15 +245,15 @@ function updateStatus(status) {
switch (status) {
case 'connected':
text.textContent = t('console.status.connected');
text.textContent = t('common.connected');
text.className = 'text-success';
break;
case 'disconnected':
text.textContent = t('console.status.disconnected');
text.textContent = t('common.disconnected');
text.className = 'text-danger';
break;
case 'connecting':
text.textContent = t('console.status.connecting');
text.textContent = t('common.connecting');
text.className = 'text-warning';
break;
}
+117 -117
View File
@@ -76,7 +76,7 @@ let chatSocket = null; // SocketIO connection to /chat namespace
* Get display-friendly name (truncate full pubkeys to short prefix)
*/
function displayName(name) {
if (!name) return 'Unknown';
if (!name) return t('common.unknown');
if (/^[0-9a-f]{12,64}$/i.test(name)) return name.substring(0, 8) + '...';
return name;
}
@@ -111,7 +111,7 @@ function resolveConversationName(conversationId) {
// Fallback
if (conversationId && conversationId.startsWith('name_')) return conversationId.substring(5);
if (conversationId && conversationId.startsWith('pk_')) return conversationId.substring(3, 11) + '...';
return 'Unknown';
return t('common.unknown');
}
let chatSocketEverConnected = false;
@@ -196,8 +196,8 @@ function connectChatSocket() {
statusEl.className = 'bi bi-check2 dm-status delivered';
const tooltip = [];
if (data.snr != null) tooltip.push(`SNR: ${data.snr}`);
if (data.route_type) tooltip.push(`Route: ${data.route_type}`);
statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : 'Delivered';
if (data.route_type) tooltip.push(t('dm.route', { route: data.route_type }));
statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : t('dm.status.delivered');
// Unwrap status icon from wrapper span
const wrapper = statusEl.closest('[data-dm-id]');
if (wrapper) {
@@ -211,7 +211,7 @@ function connectChatSocket() {
chatSocket.on('dm_retry_status', (data) => {
if (!data.dm_id) return;
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
if (info) info.textContent = t('dm.attempt', { n: data.attempt, max: data.max_attempts });
});
// DM retry exhausted — mark as failed, show final attempt count
@@ -223,7 +223,7 @@ function connectChatSocket() {
const icon = wrapper.querySelector('.dm-status');
if (icon) {
icon.className = 'bi bi-x-circle dm-status timeout';
icon.title = 'Delivery failed — all retries exhausted';
icon.title = t('dm.status.failed');
}
wrapper.removeAttribute('onclick');
wrapper.classList.remove('dm-status-unknown');
@@ -232,7 +232,7 @@ function connectChatSocket() {
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) {
if (data.attempt && data.max_attempts) {
info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
info.textContent = t('dm.attempt', { n: data.attempt, max: data.max_attempts });
} else {
info.textContent = '';
}
@@ -250,9 +250,9 @@ function connectChatSocket() {
if (!msgDiv) return;
// Build delivery meta text
const parts = [];
if (data.attempt && data.max_attempts) parts.push(`Attempt ${data.attempt}/${data.max_attempts}`);
if (data.attempt && data.max_attempts) parts.push(t('dm.attempt', { n: data.attempt, max: data.max_attempts }));
const hexRoute = formatDmRoute(data.path, data.hash_size);
if (hexRoute) parts.push(`Route: ${hexRoute}`);
if (hexRoute) parts.push(t('dm.route', { route: hexRoute }));
if (parts.length > 0) {
let metaEl = msgDiv.querySelector('.dm-delivery-meta');
if (!metaEl) {
@@ -660,7 +660,7 @@ function renderDropdownItems(query) {
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-dropdown-separator';
sep.textContent = 'Recent conversations';
sep.textContent = t('dm.dropdown.recent');
dropdown.appendChild(sep);
filteredConvs.forEach(item => {
@@ -672,7 +672,7 @@ function renderDropdownItems(query) {
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-dropdown-separator';
sep.textContent = 'Contacts';
sep.textContent = t('dm.dropdown.contacts');
dropdown.appendChild(sep);
filteredContacts.forEach(contact => {
@@ -686,7 +686,7 @@ function renderDropdownItems(query) {
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-dropdown-separator text-center';
empty.textContent = q ? 'No matches' : 'No contacts available';
empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts');
dropdown.appendChild(empty);
}
}
@@ -747,7 +747,7 @@ function populateDmSidebar(query) {
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = 'Recent conversations';
sep.textContent = t('dm.dropdown.recent');
list.appendChild(sep);
filteredConvs.forEach(item => {
@@ -759,7 +759,7 @@ function populateDmSidebar(query) {
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = 'Contacts';
sep.textContent = t('dm.dropdown.contacts');
list.appendChild(sep);
filteredContacts.forEach(contact => {
@@ -773,7 +773,7 @@ function populateDmSidebar(query) {
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-sidebar-separator text-center';
empty.textContent = q ? 'No matches' : 'No contacts available';
empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts');
list.appendChild(empty);
}
}
@@ -923,7 +923,7 @@ async function selectConversation(conversationId) {
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = false;
input.placeholder = `Message ${displayName(currentRecipient)}...`;
input.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) });
}
if (sendBtn) {
sendBtn.disabled = false;
@@ -960,7 +960,7 @@ function clearConversation() {
const sendBtn = document.getElementById('dmSendBtn');
if (input) {
input.disabled = true;
input.placeholder = 'Type a message...';
input.placeholder = t('chat.input_ph');
input.value = '';
}
if (sendBtn) {
@@ -973,8 +973,8 @@ function clearConversation() {
container.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
<p class="mb-1">${tHtml('dm.empty.select')}</p>
<small class="text-muted">${tHtml('dm.empty.select_hint')}</small>
</div>
`;
}
@@ -1013,17 +1013,9 @@ function findCurrentContact() {
return findCurrentContactByConvId(currentConversationId);
}
/**
* Minimal relative time formatter.
*/
function formatRelativeTimeDm(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(). This was the last of
// the four local copies. The contact-info row uses the long form to match the contacts
// page; the map popup uses the short form to match the My Repeaters map picker.
/**
* Populate the Contact Info modal body.
@@ -1034,7 +1026,7 @@ function populateContactInfoModal() {
const contact = findCurrentContact();
if (!contact) {
body.innerHTML = '<p class="text-muted">No contact information available.</p>';
body.innerHTML = `<p class="text-muted">${tHtml('dm.no_contact_info')}</p>`;
return;
}
@@ -1063,11 +1055,11 @@ function populateContactInfoModal() {
keyDiv.className = 'text-muted small font-monospace mb-2';
keyDiv.style.cursor = 'pointer';
keyDiv.textContent = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
keyDiv.title = 'Click to copy full public key';
keyDiv.title = t('dm.copy_pubkey_title');
keyDiv.onclick = () => {
const pk = contact.public_key || contact.public_key_prefix || '';
navigator.clipboard.writeText(pk).then(() => {
showNotification('Public key copied', 'info');
showNotification(t('contacts.toast.pubkey_copied'), 'info');
}).catch(() => {});
};
body.appendChild(keyDiv);
@@ -1081,7 +1073,7 @@ function populateContactInfoModal() {
else if (diff < 3600) icon = '🟡';
const div = document.createElement('div');
div.className = 'small mb-2';
div.textContent = `${icon} Last advert: ${formatRelativeTimeDm(ts)}`;
div.textContent = `${icon} ${t('contacts.last_advert', { time: formatTimeAgo(ts, { long: true }) })}`;
body.appendChild(div);
}
@@ -1103,9 +1095,9 @@ function populateContactInfoModal() {
const pathHex = contact.out_path ? contact.out_path.substring(0, hopCount * hashSize * 2) : '';
div.innerHTML = `
<span><i class="bi bi-signpost-split"></i> ${mode} <span class="text-muted">(${hops} hops)</span></span>
<span><i class="bi bi-signpost-split"></i> ${escapeHtml(mode)} <span class="text-muted">(${tn('contacts.hops', hops)})</span></span>
${pathHex ? `<button type="button" class="btn btn-outline-primary btn-sm py-0 px-1"
id="dmImportDevicePathBtn" title="Import device path to configured paths"
id="dmImportDevicePathBtn" title="${tHtml('dm.import_device_path_title')}"
style="font-size: 0.7rem; line-height: 1.3;">
<i class="bi bi-download"></i>
</button>` : ''}
@@ -1125,6 +1117,9 @@ function populateContactInfoModal() {
body: JSON.stringify({
path_hex: pathHex,
hash_size: hashSize,
// Stored in the DB, not rendered chrome - it stays
// English so the row does not change meaning when
// the operator switches the UI language later.
label: 'Device path',
is_primary: true
})
@@ -1132,12 +1127,12 @@ function populateContactInfoModal() {
const data = await response.json();
if (data.success) {
await renderPathList(pubkey);
showNotification('Device path imported', 'info');
showNotification(t('dm.toast.device_path_imported'), 'info');
} else {
showNotification(data.error || 'Import failed', 'danger');
showNotification(data.error || t('dm.toast.import_failed'), 'danger');
}
} catch (e) {
showNotification('Import failed', 'danger');
showNotification(t('dm.toast.import_failed'), 'danger');
}
});
}
@@ -1209,7 +1204,7 @@ async function loadMessages() {
// Always update placeholder with best known name
const msgInput = document.getElementById('dmMessageInput');
if (msgInput) {
msgInput.placeholder = `Message ${displayName(currentRecipient)}...`;
msgInput.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) });
}
// Keep search input in sync
const searchInput = document.getElementById('dmContactSearchInput');
@@ -1225,11 +1220,11 @@ async function loadMessages() {
updateLastRefresh();
} else {
container.innerHTML = '<div class="text-center text-danger py-4">Error loading messages</div>';
container.innerHTML = `<div class="text-center text-danger py-4">${tHtml('dm.load_messages_error')}</div>`;
}
} catch (error) {
console.error('Error loading messages:', error);
container.innerHTML = '<div class="text-center text-danger py-4">Failed to load messages</div>';
container.innerHTML = `<div class="text-center text-danger py-4">${tHtml('dm.load_messages_failed')}</div>`;
}
}
@@ -1244,8 +1239,8 @@ function displayMessages(messages) {
container.innerHTML = `
<div class="dm-empty-state">
<i class="bi bi-chat-dots"></i>
<p>No messages yet</p>
<small class="text-muted">Send a message to start the conversation</small>
<p>${tHtml('dm.empty.no_messages')}</p>
<small class="text-muted">${tHtml('dm.empty.no_messages_hint')}</small>
</div>
`;
lastMessageTimestamp = 0;
@@ -1270,12 +1265,12 @@ function displayMessages(messages) {
const ackAttr = msg.expected_ack ? ` data-ack="${msg.expected_ack}"` : '';
const dmIdAttr = msg.id ? ` data-dm-id="${msg.id}"` : '';
if (msg.status === 'delivered') {
let title = 'Delivered';
let title = t('dm.status.delivered');
if (msg.delivery_attempt && msg.delivery_max_attempts) {
title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`;
}
const route = formatDmRoute(msg.delivery_path, msg.delivery_path_hash_size || msg.path_hash_size);
if (route) title += `, Route: ${route}`;
if (route) title += `, ${t('dm.route', { route: route })}`;
else if (msg.delivery_route) title += `, ${msg.delivery_route.replace('PATH_', '')}`;
if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) {
title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`;
@@ -1283,9 +1278,9 @@ function displayMessages(messages) {
if (msg.delivery_route) title += ` (${msg.delivery_route})`;
statusIcon = `<i class="bi bi-check2 dm-status delivered"${ackAttr} title="${title}"></i>`;
} else if (msg.status === 'failed') {
statusIcon = `<span${dmIdAttr}><i class="bi bi-x-circle dm-status timeout"${ackAttr} title="Delivery failed — all retries exhausted"></i></span>`;
statusIcon = `<span${dmIdAttr}><i class="bi bi-x-circle dm-status timeout"${ackAttr} title="${tHtml('dm.status.failed')}"></i></span>`;
} else if (msg.status === 'pending') {
statusIcon = `<i class="bi bi-clock dm-status pending"${ackAttr} title="Sending..."></i>`;
statusIcon = `<i class="bi bi-clock dm-status pending"${ackAttr} title="${tHtml('dm.status.sending')}"></i>`;
} else {
// No ACK received — show clickable "?" with retry counter
statusIcon = `<span class="dm-status-unknown"${dmIdAttr} onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i></span>`;
@@ -1310,7 +1305,7 @@ function displayMessages(messages) {
&& msg.delivery_attempt) {
const parts = [];
if (msg.delivery_attempt && msg.delivery_max_attempts) {
parts.push(`Attempt ${msg.delivery_attempt}/${msg.delivery_max_attempts}`);
parts.push(t('dm.attempt', { n: msg.delivery_attempt, max: msg.delivery_max_attempts }));
}
// Show route only for delivered messages (not failed)
if (msg.status === 'delivered') {
@@ -1328,7 +1323,7 @@ function displayMessages(messages) {
let retryInfo = '';
if (msg.is_own) {
const isPending = !msg.status || (msg.status !== 'delivered' && msg.status !== 'failed');
const initialText = isPending && msg.expected_ack ? 'Sending...' : '';
const initialText = isPending && msg.expected_ack ? t('dm.status.sending') : '';
retryInfo = `<div class="dm-delivery-meta dm-retry-info" data-dm-id="${msg.id || ''}">${initialText}</div>`;
}
@@ -1339,7 +1334,7 @@ function displayMessages(messages) {
// only raw resend (arrow-repeat).
const resendBtn = msg.is_own ? `
<div class="dm-actions">
<button class="btn btn-outline-secondary btn-sm dm-action-btn" onclick='resendMessage(${JSON.stringify(msg.content)})' title="Edit message">
<button class="btn btn-outline-secondary btn-sm dm-action-btn" onclick='resendMessage(${JSON.stringify(msg.content)})' title="${tHtml('dm.edit_title')}">
<i class="bi bi-pencil-square"></i>
</button>
</div>
@@ -1404,17 +1399,17 @@ async function sendMessage() {
if (data.success) {
input.value = '';
updateCharCounter();
showNotification('Message sent', 'success');
showNotification(t('dm.toast.sent'), 'success');
// Reload messages once to show sent message
// ACK delivery updates arrive via SocketIO in real-time
await loadMessages();
} else {
showNotification('Failed to send: ' + data.error, 'danger');
showNotification(t('dm.toast.send_failed', { error: data.error }), 'danger');
}
} catch (error) {
console.error('Error sending message:', error);
showNotification('Failed to send message', 'danger');
showNotification(t('dm.toast.send_error'), 'danger');
} finally {
if (sendBtn) sendBtn.disabled = false;
input.focus();
@@ -1547,10 +1542,10 @@ function buildDmRouteHtml(hexPath, hashSize) {
const short = segments.length > 4
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
: segments.join('\u2192');
if (segments.length <= 4) return `Route: ${short}`;
if (segments.length <= 4) return tHtml('dm.route', { route: short });
const hs = hashSize || 1;
const escaped = hexPath.replace(/'/g, "\\'");
return `<span class="dm-route-link" onclick="showDmRoutePopup(this, '${escaped}', ${hs})">Route: ${short}</span>`;
return `<span class="dm-route-link" onclick="showDmRoutePopup(this, '${escaped}', ${hs})">${tHtml('dm.route', { route: short })}</span>`;
}
/**
@@ -1569,13 +1564,13 @@ function showDmRoutePopup(element, hexPath, hashSize) {
const entry = document.createElement('div');
entry.className = 'path-entry';
entry.innerHTML = `${fullRoute}<span class="path-detail">Hops: ${segments.length}</span>`;
entry.title = 'Tap to copy route';
entry.innerHTML = `${fullRoute}<span class="path-detail">${tHtml('chat.route_hops', { count: segments.length })}</span>`;
entry.title = t('chat.copy_route_title');
entry.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(commaRoute).then(() => {
const orig = entry.innerHTML;
entry.innerHTML = '<span style="opacity:0.8">Copied!</span>';
entry.innerHTML = `<span style="opacity:0.8">${tHtml('common.copied')}</span>`;
setTimeout(() => { entry.innerHTML = orig; }, 1000);
});
});
@@ -1609,7 +1604,7 @@ function showDeliveryInfo(element) {
const popup = document.createElement('div');
popup.className = 'dm-delivery-popup';
popup.textContent = 'Delivery unknown \u2014 no ACK received. Message may still have been delivered.';
popup.textContent = t('dm.delivery_unknown');
element.style.position = 'relative';
element.appendChild(popup);
@@ -1764,9 +1759,9 @@ function updateStatus(status) {
if (!statusEl) return;
const icons = {
connected: '<i class="bi bi-circle-fill status-connected"></i> Connected',
disconnected: '<i class="bi bi-circle-fill status-disconnected"></i> Disconnected',
connecting: '<i class="bi bi-circle-fill status-connecting"></i> Connecting...'
connected: `<i class="bi bi-circle-fill status-connected"></i> ${tHtml('common.connected')}`,
disconnected: `<i class="bi bi-circle-fill status-disconnected"></i> ${tHtml('common.disconnected')}`,
connecting: `<i class="bi bi-circle-fill status-connecting"></i> ${tHtml('common.connecting')}`
};
statusEl.innerHTML = icons[status] || icons.connecting;
@@ -1778,7 +1773,7 @@ function updateStatus(status) {
function updateLastRefresh() {
const el = document.getElementById('dmLastRefresh');
if (el) {
el.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
el.textContent = t('chat.updated', { time: new Date().toLocaleTimeString() });
}
}
@@ -1869,7 +1864,7 @@ function checkDmNotifications(conversations) {
try {
const notification = new Notification('mc-webui', {
body: `New private messages: ${delta}`,
body: tn('dm.notify.new_messages', delta),
icon: '/static/images/android-chrome-192x192.png',
badge: '/static/images/android-chrome-192x192.png',
tag: 'mc-webui-dm',
@@ -1909,13 +1904,13 @@ function initializeDmFabToggle() {
// Restore collapsed state (shared with main chat)
if (localStorage.getItem('mc-webui-fab-collapsed') === '1') {
container.classList.add('collapsed');
toggle.title = 'Show buttons';
toggle.title = t('chat.fab.show');
}
toggle.addEventListener('click', () => {
container.classList.toggle('collapsed');
const isCollapsed = container.classList.contains('collapsed');
toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons';
toggle.title = isCollapsed ? t('chat.fab.show') : t('chat.fab.hide');
localStorage.setItem('mc-webui-fab-collapsed', isCollapsed ? '1' : '0');
});
@@ -2054,7 +2049,7 @@ function applyDmFilter(query) {
noMatchesDiv.className = 'filter-no-matches';
noMatchesDiv.innerHTML = `
<i class="bi bi-search"></i>
<p>No messages match "${escapeHtml(currentDmFilterQuery)}"</p>
<p>${tHtml('chat.filter.no_matches', { query: currentDmFilterQuery })}</p>
`;
container.appendChild(noMatchesDiv);
}
@@ -2151,7 +2146,7 @@ async function loadAutoRetryConfig() {
const data = await response.json();
if (data.success) {
showNotification(
data.enabled ? 'Auto Retry enabled' : 'Auto Retry disabled',
data.enabled ? t('dm.toast.auto_retry_on') : t('dm.toast.auto_retry_off'),
'info'
);
}
@@ -2201,13 +2196,13 @@ async function renderPathList(pubkey) {
const listEl = document.getElementById('dmPathList');
if (!listEl) return;
listEl.innerHTML = '<div class="text-muted small">Loading...</div>';
listEl.innerHTML = `<div class="text-muted small">${tHtml('common.loading')}</div>`;
try {
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`);
const data = await response.json();
if (!data.success || !data.paths.length) {
listEl.innerHTML = '<div class="text-muted small mb-2">No paths configured. Use + to add.</div>';
listEl.innerHTML = `<div class="text-muted small mb-2">${tHtml('repeaters.paths.none')}</div>`;
return;
}
@@ -2226,23 +2221,23 @@ async function renderPathList(pubkey) {
const hashLabel = path.hash_size + 'B';
item.innerHTML = `
<span class="path-hex" title="${path.path_hex}">${pathDisplay}</span>
<span class="path-hex" title="${escapeHtml(path.path_hex)}">${pathDisplay}</span>
<span class="badge bg-secondary">${hashLabel}</span>
${path.label ? `<span class="path-label" title="${path.label}">${path.label}</span>` : ''}
${path.label ? `<span class="path-label" title="${escapeHtml(path.label)}">${escapeHtml(path.label)}</span>` : ''}
<span class="path-actions">
<button class="btn btn-link p-0 ${path.is_primary ? 'text-warning' : 'text-muted'}"
title="${path.is_primary ? 'Primary path' : 'Set as primary'}"
title="${tHtml(path.is_primary ? 'repeaters.paths.is_primary_title' : 'repeaters.paths.set_primary_title')}"
data-action="primary" data-id="${path.id}">
<i class="bi bi-star${path.is_primary ? '-fill' : ''}"></i>
</button>
<button class="btn btn-link p-0 text-primary"
title="Set as device path"
title="${tHtml('repeaters.paths.apply_title')}"
data-action="apply" data-id="${path.id}">
<i class="bi bi-upload"></i>
</button>
${index > 0 ? `<button class="btn btn-link p-0 text-muted" title="Move up" data-action="up" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-up"></i></button>` : ''}
${index < data.paths.length - 1 ? `<button class="btn btn-link p-0 text-muted" title="Move down" data-action="down" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-down"></i></button>` : ''}
<button class="btn btn-link p-0 text-danger" title="Delete" data-action="delete" data-id="${path.id}">
${index > 0 ? `<button class="btn btn-link p-0 text-muted" title="${tHtml('common.move_up')}" data-action="up" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-up"></i></button>` : ''}
${index < data.paths.length - 1 ? `<button class="btn btn-link p-0 text-muted" title="${tHtml('common.move_down')}" data-action="down" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-down"></i></button>` : ''}
<button class="btn btn-link p-0 text-danger" title="${tHtml('common.delete')}" data-action="delete" data-id="${path.id}">
<i class="bi bi-trash"></i>
</button>
</span>
@@ -2269,7 +2264,7 @@ async function renderPathList(pubkey) {
});
});
} catch (e) {
listEl.innerHTML = '<div class="text-danger small">Failed to load paths</div>';
listEl.innerHTML = `<div class="text-danger small">${tHtml('repeaters.paths.load_failed')}</div>`;
console.error('Failed to load paths:', e);
}
}
@@ -2295,14 +2290,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 refreshContactInfoPath();
} 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');
}
}
@@ -2381,7 +2376,7 @@ function setupPathFormHandlers(pubkey) {
const label = document.getElementById('dmPathLabelInput').value.trim();
if (!pathHex) {
showNotification('Path hex is required', 'danger');
showNotification(t('repeaters.toast.path_hex_required'), 'danger');
return;
}
@@ -2395,14 +2390,18 @@ function setupPathFormHandlers(pubkey) {
// 1B: only block adjacent duplicates
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');
const uniqAdj = [...new Set(adjDupes)];
showNotification(tn('repeaters.toast.adjacent_dupes', uniqAdj.length,
{ hops: uniqAdj.map(d => d.toUpperCase()).join(', ') }), 'danger');
return;
}
} else {
// 2B/3B: block any duplicate
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');
const uniqDupes = [...new Set(dupes)];
showNotification(tn('repeaters.toast.dupes', uniqDupes.length,
{ hops: uniqDupes.map(d => d.toUpperCase()).join(', ') }), 'danger');
return;
}
}
@@ -2417,12 +2416,12 @@ function setupPathFormHandlers(pubkey) {
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');
}
});
@@ -2464,7 +2463,7 @@ function setupPathFormHandlers(pubkey) {
const newResetBtn = resetFloodBtn.cloneNode(true);
resetFloodBtn.parentNode.replaceChild(newResetBtn, resetFloodBtn);
newResetBtn.addEventListener('click', async () => {
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 {
@@ -2473,13 +2472,13 @@ function setupPathFormHandlers(pubkey) {
});
const data = await response.json();
if (data.success) {
showNotification('Device path reset to FLOOD', 'info');
showNotification(t('repeaters.toast.reset_flood_done'), 'info');
await refreshContactInfoPath();
} 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');
}
});
}
@@ -2489,7 +2488,7 @@ function setupPathFormHandlers(pubkey) {
const newClearBtn = clearPathsBtn.cloneNode(true);
clearPathsBtn.parentNode.replaceChild(newClearBtn, clearPathsBtn);
newClearBtn.addEventListener('click', async () => {
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 {
@@ -2499,12 +2498,12 @@ function setupPathFormHandlers(pubkey) {
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');
}
});
}
@@ -2525,7 +2524,7 @@ async function loadRepeaterPicker(pubkey) {
_repeatersCache = data.repeaters;
}
} catch (e) {
listEl.innerHTML = '<div class="text-danger small p-2">Failed to load repeaters</div>';
listEl.innerHTML = `<div class="text-danger small p-2">${tHtml('repeaters.load_failed')}</div>`;
return;
}
}
@@ -2556,7 +2555,7 @@ function renderRepeaterList(listEl, repeaters, pubkey) {
});
if (!filtered.length) {
listEl.innerHTML = '<div class="text-muted small p-2">No repeaters found</div>';
listEl.innerHTML = `<div class="text-muted small p-2">${tHtml('repeaters.picker.none')}</div>`;
return;
}
@@ -2572,8 +2571,8 @@ function renderRepeaterList(listEl, repeaters, pubkey) {
item.className = 'repeater-picker-item';
item.innerHTML = `
<span class="badge ${samePrefix > 1 ? 'bg-warning text-dark' : 'bg-success'}">${prefix}</span>
<span class="flex-grow-1 text-truncate">${rpt.name}</span>
${samePrefix > 1 ? '<i class="bi bi-exclamation-triangle text-warning" title="' + samePrefix + ' repeaters share this prefix"></i>' : ''}
<span class="flex-grow-1 text-truncate">${escapeHtml(rpt.name)}</span>
${samePrefix > 1 ? `<i class="bi bi-exclamation-triangle text-warning" title="${tn('repeaters.picker.shared_prefix', samePrefix)}"></i>` : ''}
`;
item.addEventListener('click', () => {
// Check for duplicate hop
@@ -2582,13 +2581,13 @@ function renderRepeaterList(listEl, repeaters, pubkey) {
if (hashSize === 1) {
// 1B: only block if same as last hop (adjacent duplicate)
if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefixLc) {
showNotification(`${prefix} cannot be adjacent to itself`, 'warning');
showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefix }), 'warning');
return;
}
} else {
// 2B/3B: block any duplicate
if (existingHops.includes(prefixLc)) {
showNotification(`${prefix} is already in the path`, 'warning');
showNotification(t('repeaters.toast.hop_already_used', { hop: prefix }), 'warning');
return;
}
}
@@ -2655,7 +2654,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';
@@ -2679,7 +2679,7 @@ function openRepeaterMapPicker() {
const addBtn = document.getElementById('rptMapAddBtn');
const selectedLabel = document.getElementById('rptMapSelected');
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
const modal = new bootstrap.Modal(modalEl);
@@ -2719,12 +2719,12 @@ function openRepeaterMapPicker() {
const existingHops = getCurrentPathHops(hashSize);
if (hashSize === 1) {
if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefix) {
showNotification(`${prefix.toUpperCase()} cannot be adjacent to itself`, 'warning');
showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefix.toUpperCase() }), 'warning');
return;
}
} else {
if (existingHops.includes(prefix)) {
showNotification(`${prefix.toUpperCase()} is already in the path`, 'warning');
showNotification(t('repeaters.toast.hop_already_used', { hop: prefix.toUpperCase() }), 'warning');
return;
}
}
@@ -2745,7 +2745,7 @@ function openRepeaterMapPicker() {
// Reset selection for next pick
_rptMapSelectedRepeater = null;
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
};
}
@@ -2766,7 +2766,7 @@ async function loadRepeaterMapMarkers() {
// Reset selection
_rptMapSelectedRepeater = null;
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
// Ensure repeaters cache is loaded
if (!_repeatersCache) {
@@ -2775,7 +2775,7 @@ async function loadRepeaterMapMarkers() {
const data = await response.json();
if (data.success) _repeatersCache = data.repeaters;
} catch (e) {
if (countEl) countEl.textContent = 'Failed to load';
if (countEl) countEl.textContent = t('repeaters.load_failed');
return;
}
}
@@ -2800,14 +2800,14 @@ async function loadRepeaterMapMarkers() {
} catch (e) { /* show all on error */ }
}
if (countEl) countEl.textContent = `${repeaters.length} repeaters`;
if (countEl) countEl.textContent = tn('repeaters.count', repeaters.length);
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1');
const bounds = [];
repeaters.forEach(rpt => {
const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase();
const lastSeen = rpt.last_advert ? formatRelativeTimeDm(rpt.last_advert) : '';
const lastSeen = rpt.last_advert ? formatTimeAgo(rpt.last_advert) : '';
const marker = L.circleMarker([rpt.adv_lat, rpt.adv_lon], {
radius: 10,
@@ -2819,16 +2819,16 @@ async function loadRepeaterMapMarkers() {
}).addTo(_rptMapMarkers);
marker.bindPopup(
`<b>${rpt.name}</b><br>` +
`<b>${escapeHtml(rpt.name)}</b><br>` +
`<code>${prefix}</code>` +
(lastSeen ? `<br><small class="text-muted">Last seen: ${lastSeen}</small>` : '')
(lastSeen ? `<br><small class="text-muted">${tHtml('repeaters.map.last_seen', { time: lastSeen })}</small>` : '')
);
marker.on('click', () => {
_rptMapSelectedRepeater = rpt;
if (addBtn) addBtn.disabled = false;
if (selectedLabel) {
selectedLabel.innerHTML = `<code>${prefix}</code> ${rpt.name}`;
selectedLabel.innerHTML = `<code>${prefix}</code> ${escapeHtml(rpt.name)}`;
}
});
@@ -2872,7 +2872,7 @@ async function loadNoAutoFloodToggle(pubkey) {
const data = await response.json();
if (data.success) {
showNotification(
data.no_auto_flood ? 'Keep path enabled' : 'Keep path disabled',
data.no_auto_flood ? t('dm.toast.keep_path_on') : t('dm.toast.keep_path_off'),
'info'
);
}
@@ -2912,8 +2912,8 @@ function updateRepeaterSearchPlaceholder() {
if (mode === 'id') {
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1');
const chars = hashSize * 2;
searchInput.placeholder = `Search by first ${chars} hex chars...`;
searchInput.placeholder = t('repeaters.addpath.search_id_ph', { chars: chars });
} else {
searchInput.placeholder = 'Search by name...';
searchInput.placeholder = t('repeaters.addpath.search_ph');
}
}
+6 -6
View File
@@ -93,8 +93,8 @@ function esc(s) {
// 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.
// single call site already guards it. contacts.js and dm.js have since been folded in
// the same way, so this is now the only implementation.
// ================================================================
// State
@@ -830,7 +830,7 @@ function renderHopPickerList(listEl, repeaters) {
item.innerHTML = `
<span class="badge ${samePrefix > 1 ? 'bg-warning text-dark' : 'bg-success'}">${esc(prefix)}</span>
<span class="flex-grow-1 text-truncate">${esc(rpt.name)}</span>
${samePrefix > 1 ? `<i class="bi bi-exclamation-triangle text-warning" title="${tHtml('repeaters.picker.shared_prefix', { count: samePrefix })}"></i>` : ''}
${samePrefix > 1 ? `<i class="bi bi-exclamation-triangle text-warning" title="${tn('repeaters.picker.shared_prefix', samePrefix)}"></i>` : ''}
`;
item.addEventListener('click', () => {
appendHopToPathInput(prefix.toLowerCase(), hashSize);
@@ -924,7 +924,7 @@ function openRepeaterMapPicker() {
const addBtn = document.getElementById('rptMapAddBtn');
const selectedLabel = document.getElementById('rptMapSelected');
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
const modal = new bootstrap.Modal(modalEl);
@@ -960,7 +960,7 @@ function openRepeaterMapPicker() {
if (appendHopToPathInput(prefix, hashSize)) {
_rptMapSelectedRepeater = null;
addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
}
};
}
@@ -981,7 +981,7 @@ async function loadRepeaterMapMarkers() {
_rptMapSelectedRepeater = null;
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint');
if (!_repeatersCache) {
try {
+1 -1
View File
@@ -309,7 +309,7 @@
<small class="device-name">{{ device_name }}</small>
<div class="d-flex align-items-center gap-2">
<span class="status-dot" id="statusDot"></span>
<small class="text-muted" id="statusText">{{ t('console.status.connecting') }}</small>
<small class="text-muted" id="statusText">{{ t('common.connecting') }}</small>
</div>
</div>
+52 -52
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Direct Messages - mc-webui</title>
<title>{{ t('dm.title') }} - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
@@ -79,7 +79,7 @@
<input type="text"
id="dmSidebarSearch"
class="form-control form-control-sm"
placeholder="Search contacts..."
placeholder="{{ t('dm.sidebar.search_ph') }}"
autocomplete="off">
</div>
<div class="dm-sidebar-list" id="dmSidebarList">
@@ -97,7 +97,7 @@
<input type="text"
id="dmContactSearchInput"
class="form-control"
placeholder="Select chat..."
placeholder="{{ t('dm.select_chat_ph') }}"
autocomplete="off">
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
</div>
@@ -105,7 +105,7 @@
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmClearSearchBtn"
title="Clear selection"
title="{{ t('dm.clear_selection_title') }}"
style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
@@ -113,7 +113,7 @@
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmContactInfoBtn"
title="Contact info"
title="{{ t('dm.contact_info_title') }}"
disabled>
<i class="bi bi-info-circle"></i>
</button>
@@ -127,7 +127,7 @@
<button type="button"
class="btn btn-outline-secondary btn-sm flex-shrink-0"
id="dmDesktopInfoBtn"
title="Contact info"
title="{{ t('dm.contact_info_title') }}"
disabled>
<i class="bi bi-info-circle"></i>
</button>
@@ -138,12 +138,12 @@
<!-- Filter bar overlay -->
<div id="dmFilterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="{{ t('chat.filter.ph') }}" autocomplete="off">
<span id="dmFilterMatchCount" class="filter-match-count"></span>
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="{{ t('chat.filter.clear_title') }}">
<i class="bi bi-x"></i>
</button>
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="{{ t('common.close') }}">
<i class="bi bi-x-lg"></i>
</button>
</div>
@@ -153,13 +153,13 @@
<!-- Placeholder shown when no conversation selected -->
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
<p class="mb-1">{{ t('dm.empty.select') }}</p>
<small class="text-muted">{{ t('dm.empty.select_hint') }}</small>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="{{ t('chat.scroll_bottom_title') }}">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
@@ -171,12 +171,12 @@
<textarea
id="dmMessageInput"
class="form-control"
placeholder="Type a message..."
placeholder="{{ t('chat.input_ph') }}"
rows="2"
maxlength="500"
disabled
></textarea>
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="Insert emoji">
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="{{ t('chat.emoji_title') }}">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
@@ -195,9 +195,9 @@
<div class="border-top">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="dmStatusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
<i class="bi bi-circle-fill text-secondary"></i> {{ t('common.connecting') }}
</span>
<span id="dmLastRefresh">Updated: Never</span>
<span id="dmLastRefresh">{{ t('chat.updated', time=t('common.never')) }}</span>
</div>
</div>
</div>
@@ -206,13 +206,13 @@
<!-- Floating Action Buttons -->
<div class="fab-container" id="dmFabContainer">
<button class="fab fab-toggle" id="dmFabToggle" title="Hide buttons">
<button class="fab fab-toggle" id="dmFabToggle" title="{{ t('chat.fab.hide') }}">
<i class="bi bi-chevron-right"></i>
</button>
<button class="fab fab-filter" id="dmFilterFab" title="Filter Messages">
<button class="fab fab-filter" id="dmFilterFab" title="{{ t('chat.filter.fab_title') }}">
<i class="bi bi-funnel-fill"></i>
</button>
<button class="fab fab-settings" id="dmSettingsFab" title="Settings">
<button class="fab fab-settings" id="dmSettingsFab" title="{{ t('common.settings') }}">
<i class="bi bi-gear-fill"></i>
</button>
</div>
@@ -223,15 +223,15 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title"><i class="bi bi-person-circle"></i> Contact Info</h6>
<h6 class="modal-title"><i class="bi bi-person-circle"></i> {{ t('dm.contact_info') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="dmContactInfoBody"></div>
<!-- Path management section (populated dynamically) -->
<div class="modal-body border-top pt-2 pb-1" id="dmPathSection" style="display: none;">
<div class="path-section-header">
<h6><i class="bi bi-signpost-split"></i> Paths</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="dmAddPathBtn" title="Add path">
<h6><i class="bi bi-signpost-split"></i> {{ t('repeaters.paths.title') }}</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="dmAddPathBtn" title="{{ t('repeaters.paths.add_title') }}">
<i class="bi bi-plus-lg"></i>
</button>
</div>
@@ -239,28 +239,28 @@
<!-- Path action buttons -->
<div class="d-flex justify-content-end gap-2 mt-1">
<button type="button" class="btn btn-outline-secondary btn-sm" id="dmClearPathsBtn"
title="Delete all configured paths from database">
<i class="bi bi-trash"></i> Clear Paths
title="{{ t('repeaters.paths.clear_title') }}">
<i class="bi bi-trash"></i> {{ t('repeaters.paths.clear') }}
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="dmResetFloodBtn"
title="Reset device path to FLOOD mode">
<i class="bi bi-broadcast"></i> Reset to FLOOD
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">
<div class="d-flex align-items-center justify-content-between w-100">
<div class="d-flex gap-3">
<div class="form-check form-switch" title="Auto Retry: resend DM if no ACK received">
<div class="form-check form-switch" title="{{ t('dm.auto_retry_title') }}">
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
<label class="form-check-label small" for="dmAutoRetryToggle">Auto Retry</label>
<label class="form-check-label small" for="dmAutoRetryToggle">{{ t('dm.auto_retry') }}</label>
</div>
<div class="form-check form-switch" title="Keep path: don't auto-reset to FLOOD after failed retries">
<div class="form-check form-switch" title="{{ t('dm.keep_path_title') }}">
<input class="form-check-input" type="checkbox" id="dmNoAutoFloodToggle">
<label class="form-check-label small" for="dmNoAutoFloodToggle">Keep path</label>
<label class="form-check-label small" for="dmNoAutoFloodToggle">{{ t('dm.keep_path') }}</label>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.close') }}</button>
</div>
</div>
</div>
@@ -272,23 +272,23 @@
<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> Select Repeater from Map</h6>
<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">Cached</label>
<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">Click a repeater on the map</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>Add</button>
<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>
@@ -299,32 +299,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-signpost-split"></i> Add Path</h6>
<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" id="dmAddPathForm">
<div class="mb-2">
<label class="form-label small mb-1">Hash Size</label>
<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">1B (max 64)</label>
<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">2B (max 32)</label>
<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">3B (max 21)</label>
<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">Path (hex)</label>
<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="dmPathHexInput"
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
placeholder="{{ t('repeaters.addpath.path_hex_ph') }}" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterBtn"
title="Pick repeater from list">
title="{{ t('repeaters.addpath.pick_list_title') }}">
<i class="bi bi-plus-circle"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterMapBtn"
title="Pick repeater from map">
title="{{ t('repeaters.addpath.pick_map_title') }}">
<i class="bi bi-geo-alt"></i>
</button>
</div>
@@ -335,24 +335,24 @@
<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">Name</label>
<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">ID</label>
<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="dmRepeaterSearch" placeholder="Search by name..." autocomplete="off">
id="dmRepeaterSearch" placeholder="{{ t('repeaters.addpath.search_ph') }}" autocomplete="off">
</div>
<div id="dmRepeaterList" style="max-height: 180px; overflow-y: auto;"></div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Label (optional)</label>
<label class="form-label small mb-1">{{ t('repeaters.addpath.label') }}</label>
<input type="text" class="form-control form-control-sm" id="dmPathLabelInput"
placeholder="e.g. via Mountain RPT" maxlength="50">
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" id="dmCancelPathBtn" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="dmSavePathBtn">Add Path</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="dmCancelPathBtn" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-sm btn-primary" id="dmSavePathBtn">{{ t('repeaters.addpath.title') }}</button>
</div>
</div>
</div>
+61 -3
View File
@@ -1,9 +1,23 @@
{
"chat.copy_route_title": "Tap to copy route",
"chat.emoji_title": "Insert emoji",
"chat.fab.hide": "Hide buttons",
"chat.fab.show": "Show buttons",
"chat.filter.clear_title": "Clear",
"chat.filter.fab_title": "Filter Messages",
"chat.filter.no_matches": "No messages match \"{query}\"",
"chat.filter.ph": "Filter messages...",
"chat.input_ph": "Type a message...",
"chat.route_hops": "Hops: {count}",
"chat.scroll_bottom_title": "Scroll to bottom",
"chat.updated": "Updated: {time}",
"common.add": "Add",
"common.back": "Back",
"common.cancel": "Cancel",
"common.cannot_undo": "This action cannot be undone.",
"common.close": "Close",
"common.connected": "Connected",
"common.connecting": "Connecting...",
"common.copied": "Copied!",
"common.days_ago": {
"one": "{count}d ago",
@@ -16,6 +30,7 @@
"common.delete": "Delete",
"common.deleting": "Deleting...",
"common.disabled": "Disabled",
"common.disconnected": "Disconnected",
"common.failed_with": "Failed: {error}",
"common.hours_ago": "{count}h ago",
"common.hours_ago_long": {
@@ -43,6 +58,7 @@
"common.save": "Save",
"common.save_failed": "Save failed",
"common.saving": "Saving...",
"common.settings": "Settings",
"common.try_again": "Try again",
"common.unknown": "Unknown",
"common.unknown_error": "Unknown error",
@@ -62,9 +78,6 @@
"console.no_output": "(no output)",
"console.scroll_bottom_aria": "Scroll to latest",
"console.scroll_bottom_title": "Scroll to latest",
"console.status.connected": "Connected",
"console.status.connecting": "Connecting...",
"console.status.disconnected": "Disconnected",
"console.title": "Console",
"contacts.active_recent": "Active (advert received recently)",
"contacts.add.adding": "Adding contact...",
@@ -193,6 +206,50 @@
"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}",
"dm.attempt": "Attempt {n}/{max}",
"dm.auto_retry": "Auto Retry",
"dm.auto_retry_title": "Auto Retry: resend DM if no ACK received",
"dm.clear_selection_title": "Clear selection",
"dm.contact_info": "Contact Info",
"dm.contact_info_title": "Contact info",
"dm.copy_pubkey_title": "Click to copy full public key",
"dm.delivery_unknown": "Delivery unknown — no ACK received. Message may still have been delivered.",
"dm.dropdown.contacts": "Contacts",
"dm.dropdown.no_contacts": "No contacts available",
"dm.dropdown.no_matches": "No matches",
"dm.dropdown.recent": "Recent conversations",
"dm.edit_title": "Edit message",
"dm.empty.no_messages": "No messages yet",
"dm.empty.no_messages_hint": "Send a message to start the conversation",
"dm.empty.select": "Select a conversation",
"dm.empty.select_hint": "Choose from the list or start a new chat from channel messages",
"dm.import_device_path_title": "Import device path to configured paths",
"dm.keep_path": "Keep path",
"dm.keep_path_title": "Keep path: don't auto-reset to FLOOD after failed retries",
"dm.load_messages_error": "Error loading messages",
"dm.load_messages_failed": "Failed to load messages",
"dm.msg_input_ph": "Message {name}...",
"dm.no_contact_info": "No contact information available.",
"dm.notify.new_messages": {
"one": "New private message: {count}",
"other": "New private messages: {count}"
},
"dm.route": "Route: {route}",
"dm.select_chat_ph": "Select chat...",
"dm.sidebar.search_ph": "Search contacts...",
"dm.status.delivered": "Delivered",
"dm.status.failed": "Delivery failed — all retries exhausted",
"dm.status.sending": "Sending...",
"dm.title": "Direct Messages",
"dm.toast.auto_retry_off": "Auto Retry disabled",
"dm.toast.auto_retry_on": "Auto Retry enabled",
"dm.toast.device_path_imported": "Device path imported",
"dm.toast.import_failed": "Import failed",
"dm.toast.keep_path_off": "Keep path disabled",
"dm.toast.keep_path_on": "Keep path enabled",
"dm.toast.send_error": "Failed to send message",
"dm.toast.send_failed": "Failed to send: {error}",
"dm.toast.sent": "Message sent",
"logs.clear_title": "Clear display",
"logs.entries": {
"one": "{count} entry",
@@ -345,6 +402,7 @@
"repeaters.addpath.path_hex_ph": "e.g. 5e,e7 or 5e34,e761",
"repeaters.addpath.pick_list_title": "Pick repeater from list",
"repeaters.addpath.pick_map_title": "Pick repeater from map",
"repeaters.addpath.search_id_ph": "Search by first {chars} hex chars...",
"repeaters.addpath.search_ph": "Search by name...",
"repeaters.addpath.title": "Add Path",
"repeaters.confirm.clear_paths": "Clear all configured paths?\n\nThis will delete all paths from the database. The device path will not be changed.",
+63 -3
View File
@@ -1,9 +1,23 @@
{
"chat.copy_route_title": "Dotknij, aby skopiować trasę",
"chat.emoji_title": "Wstaw emoji",
"chat.fab.hide": "Ukryj przyciski",
"chat.fab.show": "Pokaż przyciski",
"chat.filter.clear_title": "Wyczyść",
"chat.filter.fab_title": "Filtruj wiadomości",
"chat.filter.no_matches": "Brak wiadomości pasujących do „{query}”",
"chat.filter.ph": "Filtruj wiadomości...",
"chat.input_ph": "Napisz wiadomość...",
"chat.route_hops": "Hopy: {count}",
"chat.scroll_bottom_title": "Przewiń na dół",
"chat.updated": "Aktualizacja: {time}",
"common.add": "Dodaj",
"common.back": "Wstecz",
"common.cancel": "Anuluj",
"common.cannot_undo": "Tej operacji nie można cofnąć.",
"common.close": "Zamknij",
"common.connected": "Połączono",
"common.connecting": "Łączenie...",
"common.copied": "Skopiowano!",
"common.days_ago": {
"one": "{count} dzień temu",
@@ -20,6 +34,7 @@
"common.delete": "Usuń",
"common.deleting": "Usuwanie...",
"common.disabled": "Wyłączone",
"common.disconnected": "Rozłączono",
"common.failed_with": "Niepowodzenie: {error}",
"common.hours_ago": "{count} godz. temu",
"common.hours_ago_long": {
@@ -53,6 +68,7 @@
"common.save": "Zapisz",
"common.save_failed": "Zapis nie powiódł się",
"common.saving": "Zapisywanie...",
"common.settings": "Ustawienia",
"common.try_again": "Spróbuj ponownie",
"common.unknown": "Nieznany",
"common.unknown_error": "Nieznany błąd",
@@ -74,9 +90,6 @@
"console.no_output": "(brak wyniku)",
"console.scroll_bottom_aria": "Przewiń do najnowszych",
"console.scroll_bottom_title": "Przewiń do najnowszych",
"console.status.connected": "Połączono",
"console.status.connecting": "Łączenie...",
"console.status.disconnected": "Rozłączono",
"console.title": "Konsola",
"contacts.active_recent": "Aktywny (advert odebrany niedawno)",
"contacts.add.adding": "Dodawanie kontaktu...",
@@ -211,6 +224,52 @@
"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}",
"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",
"dm.clear_selection_title": "Wyczyść wybór",
"dm.contact_info": "Informacje o kontakcie",
"dm.contact_info_title": "Informacje o kontakcie",
"dm.copy_pubkey_title": "Kliknij, aby skopiować pełny klucz publiczny",
"dm.delivery_unknown": "Nieznany status dostarczenia — brak ACK. Wiadomość mogła jednak zostać dostarczona.",
"dm.dropdown.contacts": "Kontakty",
"dm.dropdown.no_contacts": "Brak dostępnych kontaktów",
"dm.dropdown.no_matches": "Brak wyników",
"dm.dropdown.recent": "Ostatnie rozmowy",
"dm.edit_title": "Edytuj wiadomość",
"dm.empty.no_messages": "Brak wiadomości",
"dm.empty.no_messages_hint": "Wyślij wiadomość, aby rozpocząć rozmowę",
"dm.empty.select": "Wybierz rozmowę",
"dm.empty.select_hint": "Wybierz z listy lub rozpocznij nową rozmowę z wiadomości kanałowych",
"dm.import_device_path_title": "Zaimportuj ścieżkę urządzenia do skonfigurowanych ścieżek",
"dm.keep_path": "Zachowaj ścieżkę",
"dm.keep_path_title": "Zachowaj ścieżkę: nie przywracaj automatycznie trybu FLOOD po nieudanych próbach",
"dm.load_messages_error": "Błąd wczytywania wiadomości",
"dm.load_messages_failed": "Nie udało się wczytać wiadomości",
"dm.msg_input_ph": "Wiadomość do {name}...",
"dm.no_contact_info": "Brak informacji o kontakcie.",
"dm.notify.new_messages": {
"one": "Nowa wiadomość prywatna: {count}",
"few": "Nowe wiadomości prywatne: {count}",
"many": "Nowych wiadomości prywatnych: {count}",
"other": "Nowe wiadomości prywatne: {count}"
},
"dm.route": "Trasa: {route}",
"dm.select_chat_ph": "Wybierz rozmowę...",
"dm.sidebar.search_ph": "Szukaj kontaktów...",
"dm.status.delivered": "Dostarczono",
"dm.status.failed": "Dostarczenie nieudane — wyczerpano wszystkie próby",
"dm.status.sending": "Wysyłanie...",
"dm.title": "Wiadomości prywatne",
"dm.toast.auto_retry_off": "Auto ponawianie wyłączone",
"dm.toast.auto_retry_on": "Auto ponawianie włączone",
"dm.toast.device_path_imported": "Zaimportowano ścieżkę urządzenia",
"dm.toast.import_failed": "Import nieudany",
"dm.toast.keep_path_off": "Zachowywanie ścieżki wyłączone",
"dm.toast.keep_path_on": "Zachowywanie ścieżki włączone",
"dm.toast.send_error": "Nie udało się wysłać wiadomości",
"dm.toast.send_failed": "Nie udało się wysłać: {error}",
"dm.toast.sent": "Wiadomość wysłana",
"logs.clear_title": "Wyczyść widok",
"logs.entries": {
"one": "{count} wpis",
@@ -383,6 +442,7 @@
"repeaters.addpath.path_hex_ph": "np. 5e,e7 lub 5e34,e761",
"repeaters.addpath.pick_list_title": "Wybierz repeater z listy",
"repeaters.addpath.pick_map_title": "Wybierz repeater z mapy",
"repeaters.addpath.search_id_ph": "Szukaj po pierwszych {chars} znakach hex...",
"repeaters.addpath.search_ph": "Szukaj po nazwie...",
"repeaters.addpath.title": "Dodaj ścieżkę",
"repeaters.confirm.clear_paths": "Usunąć wszystkie skonfigurowane ścieżki?\n\nSpowoduje to usunięcie wszystkich ścieżek z bazy danych. Ścieżka na urządzeniu nie zostanie zmieniona.",
+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. This first step covers the groundwork plus the **System Log** panel; the remaining panels follow one at a time, so for now most of the interface is 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 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.
- **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.
+10
View File
@@ -181,6 +181,16 @@ def check_usage(en: dict, kinds: dict[str, set[str]], sites: dict[str, list[str]
err(f'{sites[key][0]}: {key!r} contains markup but is used via t()/tn() - '
f'use tHtml()/t_html()')
# A plural value only picks the right form when the count reaches the resolver,
# and tn() is the only helper that passes it. t()/tHtml() fall back to the "one"
# form and render it for every count - silently, and correctly-looking in English
# where "1 hop"/"2 hops" differ only in a letter. Polish makes it obvious, but by
# then it is in a screenshot, not in a test.
if isinstance(value, dict) and kinds[key] - {'tn'}:
wrong = ', '.join(sorted(f'{f}()' for f in kinds[key] - {'tn'}))
err(f'{sites[key][0]}: {key!r} is a plural value but is used via {wrong} - '
f'use tn(key, count)')
def check_language(lang: str, en: dict, catalog: dict) -> float:
translated = [k for k in en if k in catalog]