From 127cd7adf66c957f3e1e1ce876000874aa67a624 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 18:49:06 +0000 Subject: [PATCH 1/7] Add i18n support for web dashboard Implement lightweight i18n infrastructure with shared JSON translation files used by both server-side Jinja2 templates and client-side SPA. - Add custom i18n module (Python + JS, ~80 lines total, zero deps) - Create en.json with ~200 translation keys covering all web strings - Add WEB_LOCALE config setting (default: 'en', with localStorage override) - Translate all navigation labels, page titles, and footer in spa.html - Translate all 13 SPA page modules (home, dashboard, nodes, etc.) - Translate shared components (pagination, relative time, charts) - Translate all 3 admin pages (index, members, node-tags) - Fix Adverts/Advertisements inconsistency (standardize to Advertisements) - Add i18n unit tests with 100% coverage https://claude.ai/code/session_01FbnUnwYAwPrsQmAh5EuSkF --- src/meshcore_hub/common/config.py | 6 + src/meshcore_hub/common/i18n.py | 81 ++++++ src/meshcore_hub/web/app.py | 7 + src/meshcore_hub/web/static/js/charts.js | 10 +- src/meshcore_hub/web/static/js/spa/app.js | 37 +-- .../web/static/js/spa/components.js | 18 +- src/meshcore_hub/web/static/js/spa/i18n.js | 76 ++++++ .../web/static/js/spa/pages/admin/index.js | 36 +-- .../web/static/js/spa/pages/admin/members.js | 102 +++---- .../static/js/spa/pages/admin/node-tags.js | 166 +++++------ .../web/static/js/spa/pages/advertisements.js | 36 +-- .../web/static/js/spa/pages/custom-page.js | 6 +- .../web/static/js/spa/pages/dashboard.js | 44 +-- .../web/static/js/spa/pages/home.js | 55 ++-- .../web/static/js/spa/pages/map.js | 83 +++--- .../web/static/js/spa/pages/members.js | 25 +- .../web/static/js/spa/pages/messages.js | 40 +-- .../web/static/js/spa/pages/node-detail.js | 52 ++-- .../web/static/js/spa/pages/nodes.js | 50 ++-- .../web/static/js/spa/pages/not-found.js | 10 +- src/meshcore_hub/web/static/locales/en.json | 257 ++++++++++++++++++ src/meshcore_hub/web/templates/spa.html | 36 +-- tests/test_common/test_i18n.py | 128 +++++++++ 23 files changed, 963 insertions(+), 398 deletions(-) create mode 100644 src/meshcore_hub/common/i18n.py create mode 100644 src/meshcore_hub/web/static/js/spa/i18n.js create mode 100644 src/meshcore_hub/web/static/locales/en.json create mode 100644 tests/test_common/test_i18n.py diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index fbc8816..f21f545 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -262,6 +262,12 @@ class WebSettings(CommonSettings): description="Default theme for the web dashboard (dark or light)", ) + # Locale / language (default: English) + web_locale: str = Field( + default="en", + description="Locale/language for the web dashboard (e.g. 'en')", + ) + # Admin interface (disabled by default for security) web_admin_enabled: bool = Field( default=False, diff --git a/src/meshcore_hub/common/i18n.py b/src/meshcore_hub/common/i18n.py new file mode 100644 index 0000000..2a585a9 --- /dev/null +++ b/src/meshcore_hub/common/i18n.py @@ -0,0 +1,81 @@ +"""Lightweight i18n support for MeshCore Hub. + +Loads JSON translation files and provides a ``t()`` lookup function +that is shared between the Python (Jinja2) and JavaScript (SPA) sides. +The same ``en.json`` file is served as a static asset for the client and +read from disk for server-side template rendering. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_translations: dict[str, Any] = {} +_locale: str = "en" + +# Directory where locale JSON files live (web/static/locales/) +LOCALES_DIR = Path(__file__).parent.parent / "web" / "static" / "locales" + + +def load_locale(locale: str = "en", locales_dir: Path | None = None) -> None: + """Load a locale's translation file into memory. + + Args: + locale: Language code (e.g. ``"en"``). + locales_dir: Override directory containing ``.json`` files. + """ + global _translations, _locale + directory = locales_dir or LOCALES_DIR + path = directory / f"{locale}.json" + if not path.exists(): + logger.warning("Locale file not found: %s – falling back to 'en'", path) + path = directory / "en.json" + if path.exists(): + _translations = json.loads(path.read_text(encoding="utf-8")) + _locale = locale + logger.info("Loaded locale '%s' from %s", locale, path) + else: + logger.error("No locale files found in %s", directory) + + +def _resolve(key: str) -> Any: + """Walk a dot-separated key through the nested translation dict.""" + value: Any = _translations + for part in key.split("."): + if isinstance(value, dict): + value = value.get(part) + else: + return None + return value + + +def t(key: str, **kwargs: Any) -> str: + """Translate a key with optional interpolation. + + Supports ``{{var}}`` placeholders in translation strings. + + Args: + key: Dot-separated translation key (e.g. ``"nav.home"``). + **kwargs: Interpolation values. + + Returns: + Translated string, or the key itself as fallback. + """ + val = _resolve(key) + + if not isinstance(val, str): + return key + + # Interpolation: replace {{var}} placeholders + for k, v in kwargs.items(): + val = val.replace("{{" + k + "}}", str(v)) + + return val + + +def get_locale() -> str: + """Return the currently loaded locale code.""" + return _locale diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 6c76815..165fd8a 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -16,6 +16,7 @@ from fastapi.templating import Jinja2Templates from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from meshcore_hub import __version__ +from meshcore_hub.common.i18n import load_locale, t from meshcore_hub.common.schemas import RadioConfig from meshcore_hub.web.pages import PageLoader @@ -114,6 +115,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "timezone_iana": app.state.timezone, "is_authenticated": bool(request.headers.get("X-Forwarded-User")), "default_theme": app.state.web_theme, + "locale": app.state.web_locale, } return json.dumps(config) @@ -174,6 +176,10 @@ def create_app( # Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For) for HTTPS detection app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") + # Load i18n translations + app.state.web_locale = settings.web_locale or "en" + load_locale(app.state.web_locale) + # Store configuration in app state (use args if provided, else settings) app.state.web_theme = ( settings.web_theme if settings.web_theme in ("dark", "light") else "dark" @@ -227,6 +233,7 @@ def create_app( templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates.env.trim_blocks = True templates.env.lstrip_blocks = True + templates.env.globals["t"] = t app.state.templates = templates # Compute timezone diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js index a1d3136..ada8487 100644 --- a/src/meshcore_hub/web/static/js/charts.js +++ b/src/meshcore_hub/web/static/js/charts.js @@ -154,7 +154,7 @@ function createActivityChart(canvasId, advertData, messageData) { if (advertData && advertData.data && advertData.data.length > 0) { if (!labels) labels = formatDateLabels(advertData.data); datasets.push({ - label: 'Advertisements', + label: (window.t && window.t('nav.advertisements')) || 'Advertisements', data: advertData.data.map(function(d) { return d.count; }), borderColor: ChartColors.adverts, backgroundColor: ChartColors.advertsFill, @@ -168,7 +168,7 @@ function createActivityChart(canvasId, advertData, messageData) { if (messageData && messageData.data && messageData.data.length > 0) { if (!labels) labels = formatDateLabels(messageData.data); datasets.push({ - label: 'Messages', + label: (window.t && window.t('nav.messages')) || 'Messages', data: messageData.data.map(function(d) { return d.count; }), borderColor: ChartColors.messages, backgroundColor: ChartColors.messagesFill, @@ -200,7 +200,7 @@ function initDashboardCharts(nodeData, advertData, messageData) { createLineChart( 'nodeChart', nodeData, - 'Total Nodes', + (window.t && window.t('dashboard.total_nodes')) || 'Total Nodes', ChartColors.nodes, ChartColors.nodesFill, true @@ -211,7 +211,7 @@ function initDashboardCharts(nodeData, advertData, messageData) { createLineChart( 'advertChart', advertData, - 'Advertisements', + (window.t && window.t('nav.advertisements')) || 'Advertisements', ChartColors.adverts, ChartColors.advertsFill, true @@ -222,7 +222,7 @@ function initDashboardCharts(nodeData, advertData, messageData) { createLineChart( 'messageChart', messageData, - 'Messages', + (window.t && window.t('nav.messages')) || 'Messages', ChartColors.messages, ChartColors.messagesFill, true diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index c9cdcad..aba65f2 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -1,11 +1,13 @@ /** * MeshCore Hub SPA - Main Application Entry Point * - * Initializes the router, registers all page routes, and handles navigation. + * Initializes i18n, the router, registers all page routes, + * and handles navigation. */ import { Router } from './router.js'; import { getConfig } from './components.js'; +import { loadLocale, t } from './i18n.js'; // Page modules (lazy-loaded) const pages = { @@ -46,10 +48,10 @@ function pageHandler(loader) { console.error('Page load error:', e); appContainer.innerHTML = `
-

Error

-

Failed to load page

+

${t('common.error')}

+

${t('common.failed_to_load_page')}

${e.message || 'Unknown error'}

- Go Home + ${t('common.go_home')}
`; } }; @@ -129,28 +131,27 @@ function updateNavActiveState(pathname) { * @param {string} pathname */ function updatePageTitle(pathname) { - const config = getConfig(); const networkName = config.network_name || 'MeshCore Network'; const titles = { '/': networkName, - '/a': `Admin - ${networkName}`, - '/a/': `Admin - ${networkName}`, - '/a/node-tags': `Node Tags - Admin - ${networkName}`, - '/a/members': `Members - Admin - ${networkName}`, + '/a': t('page_title.admin', { network_name: networkName }), + '/a/': t('page_title.admin', { network_name: networkName }), + '/a/node-tags': t('page_title.admin_node_tags', { network_name: networkName }), + '/a/members': t('page_title.admin_members', { network_name: networkName }), }; // Add feature-dependent titles - if (features.dashboard !== false) titles['/dashboard'] = `Dashboard - ${networkName}`; - if (features.nodes !== false) titles['/nodes'] = `Nodes - ${networkName}`; - if (features.messages !== false) titles['/messages'] = `Messages - ${networkName}`; - if (features.advertisements !== false) titles['/advertisements'] = `Advertisements - ${networkName}`; - if (features.map !== false) titles['/map'] = `Map - ${networkName}`; - if (features.members !== false) titles['/members'] = `Members - ${networkName}`; + if (features.dashboard !== false) titles['/dashboard'] = t('page_title.dashboard', { network_name: networkName }); + if (features.nodes !== false) titles['/nodes'] = t('page_title.nodes', { network_name: networkName }); + if (features.messages !== false) titles['/messages'] = t('page_title.messages', { network_name: networkName }); + if (features.advertisements !== false) titles['/advertisements'] = t('page_title.advertisements', { network_name: networkName }); + if (features.map !== false) titles['/map'] = t('page_title.map', { network_name: networkName }); + if (features.members !== false) titles['/members'] = t('page_title.members', { network_name: networkName }); if (titles[pathname]) { document.title = titles[pathname]; } else if (pathname.startsWith('/nodes/')) { - document.title = `Node Detail - ${networkName}`; + document.title = t('page_title.node_detail', { network_name: networkName }); } else if (pathname.startsWith('/pages/')) { // Custom pages set their own title in the page module document.title = networkName; @@ -165,5 +166,7 @@ router.onNavigate((pathname) => { updatePageTitle(pathname); }); -// Start the router when DOM is ready +// Load locale then start the router +const locale = localStorage.getItem('meshcore-locale') || config.locale || 'en'; +await loadLocale(locale); router.start(); diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 5aa28e4..ea702d1 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -7,10 +7,12 @@ import { html, nothing } from 'lit-html'; import { render } from 'lit-html'; import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; +import { t } from './i18n.js'; // Re-export lit-html utilities for page modules export { html, nothing, unsafeHTML }; export { render as litRender } from 'lit-html'; +export { t } from './i18n.js'; /** * Get app config from the embedded window object. @@ -113,10 +115,10 @@ export function formatRelativeTime(isoString) { const diffMin = Math.floor(diffSec / 60); const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); - if (diffDay > 0) return `${diffDay}d ago`; - if (diffHour > 0) return `${diffHour}h ago`; - if (diffMin > 0) return `${diffMin}m ago`; - return '<1m ago'; + if (diffDay > 0) return t('time.days_ago', { count: diffDay }); + if (diffHour > 0) return t('time.hours_ago', { count: diffHour }); + if (diffMin > 0) return t('time.minutes_ago', { count: diffMin }); + return t('time.less_than_minute'); } /** @@ -226,12 +228,12 @@ export function pagination(page, totalPages, basePath, params = {}) { return html`
${page > 1 - ? html`Previous` - : html``} + ? html`${t('common.previous')}` + : html``} ${pageNumbers} ${page < totalPages - ? html`Next` - : html``} + ? html`${t('common.next')}` + : html``}
`; } diff --git a/src/meshcore_hub/web/static/js/spa/i18n.js b/src/meshcore_hub/web/static/js/spa/i18n.js new file mode 100644 index 0000000..c1e396a --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa/i18n.js @@ -0,0 +1,76 @@ +/** + * MeshCore Hub SPA - Lightweight i18n Module + * + * Loads a JSON translation file and provides a t() lookup function. + * Shares the same locale JSON files with the Python/Jinja2 server side. + * + * Usage: + * import { t, loadLocale } from './i18n.js'; + * await loadLocale('en'); + * t('nav.home'); // "Home" + * t('common.total', { count: 42 }); // "42 total" + */ + +let _translations = {}; +let _locale = 'en'; + +/** + * Load a locale JSON file from the server. + * @param {string} locale - Language code (e.g. 'en') + */ +export async function loadLocale(locale) { + try { + const res = await fetch(`/static/locales/${locale}.json`); + if (res.ok) { + _translations = await res.json(); + _locale = locale; + } else { + console.warn(`Failed to load locale '${locale}', status ${res.status}`); + } + } catch (e) { + console.warn(`Failed to load locale '${locale}':`, e); + } +} + +/** + * Resolve a dot-separated key in the translations object. + * @param {string} key + * @returns {*} + */ +function resolve(key) { + return key.split('.').reduce( + (obj, k) => (obj && typeof obj === 'object' ? obj[k] : undefined), + _translations, + ); +} + +/** + * Translate a key with optional {{var}} interpolation. + * Falls back to the key itself if not found. + * @param {string} key - Dot-separated translation key + * @param {Object} [params={}] - Interpolation values + * @returns {string} + */ +export function t(key, params = {}) { + let val = resolve(key); + + if (typeof val !== 'string') return key; + + // Replace {{var}} placeholders + if (Object.keys(params).length > 0) { + val = val.replace(/\{\{(\w+)\}\}/g, (_, k) => (k in params ? String(params[k]) : '')); + } + + return val; +} + +/** + * Get the currently loaded locale code. + * @returns {string} + */ +export function getLocale() { + return _locale; +} + +// Also expose t() globally for non-module scripts (e.g. charts.js) +window.t = t; diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js index e54cdec..408c08e 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/index.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/index.js @@ -1,4 +1,4 @@ -import { html, litRender, getConfig, errorAlert } from '../../components.js'; +import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../../components.js'; import { iconLock, iconUsers, iconTag } from '../../icons.js'; export async function render(container, params, router) { @@ -9,10 +9,10 @@ export async function render(container, params, router) { litRender(html`
${iconLock('h-16 w-16 opacity-30 mb-4')} -

Access Denied

-

The admin interface is not enabled.

-

Set WEB_ADMIN_ENABLED=true to enable admin features.

- Go Home +

${t('admin.access_denied')}

+

${t('admin.admin_not_enabled')}

+

${unsafeHTML(t('admin.admin_enable_hint'))}

+ ${t('common.go_home')}
`, container); return; } @@ -21,9 +21,9 @@ export async function render(container, params, router) { litRender(html`
${iconLock('h-16 w-16 opacity-30 mb-4')} -

Authentication Required

-

You must sign in to access the admin interface.

- Sign In +

${t('admin.auth_required')}

+

${t('admin.auth_required_description')}

+ ${t('common.sign_in')}
`, container); return; } @@ -31,20 +31,20 @@ export async function render(container, params, router) { litRender(html`
-

Admin

+

${t('admin.title')}

- Sign Out + ${t('common.sign_out')}
- Welcome to the admin panel. + ${t('admin.welcome')}
@@ -53,23 +53,23 @@ export async function render(container, params, router) {

${iconUsers('h-6 w-6')} - Members + ${t('admin.members_title')}

-

Manage network members and operators.

+

${t('admin.members_description')}

${iconTag('h-6 w-6')} - Node Tags + ${t('admin.node_tags_title')}

-

Manage custom tags and metadata for network nodes.

+

${t('admin.node_tags_description')}

`, container); } catch (e) { - litRender(errorAlert(e.message || 'Failed to load admin page'), container); + litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js index a70dae6..1c1450e 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/members.js @@ -1,7 +1,7 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js'; import { html, litRender, nothing, - getConfig, errorAlert, successAlert, + getConfig, errorAlert, successAlert, t, } from '../../components.js'; import { iconLock } from '../../icons.js'; @@ -13,9 +13,9 @@ export async function render(container, params, router) { litRender(html`
${iconLock('h-16 w-16 opacity-30 mb-4')} -

Access Denied

-

The admin interface is not enabled.

- Go Home +

${t('admin.access_denied')}

+

${t('admin.admin_not_enabled')}

+ ${t('common.go_home')}
`, container); return; } @@ -24,9 +24,9 @@ export async function render(container, params, router) { litRender(html`
${iconLock('h-16 w-16 opacity-30 mb-4')} -

Authentication Required

-

You must sign in to access the admin interface.

- Sign In +

${t('admin.auth_required')}

+

${t('admin.auth_required_description')}

+ ${t('common.sign_in')}
`, container); return; } @@ -45,11 +45,11 @@ export async function render(container, params, router) { - - - - - + + + + + ${members.map(m => html` @@ -69,8 +69,8 @@ export async function render(container, params, router) { `)} @@ -78,23 +78,23 @@ export async function render(container, params, router) { ` : html`
-

No members configured yet.

-

Click "Add Member" to create the first member.

+

${t('admin_members.no_members_yet')}

+

${t('admin_members.no_members_hint')}

`; litRender(html`
-

Members

+

${t('admin_members.title')}

- Sign Out + ${t('common.sign_out')}
${flashHtml} @@ -102,8 +102,8 @@ ${flashHtml}
-

Network Members (${members.length})

- +

${t('admin_members.network_members', { count: members.length })}

+
${tableHtml}
@@ -111,62 +111,62 @@ ${flashHtml} - + - + `, container); let activeDeleteId = ''; @@ -249,7 +249,7 @@ ${flashHtml} try { await apiPost('/api/v1/members', body); container.querySelector('#addModal').close(); - router.navigate('/a/members?message=' + encodeURIComponent('Member added successfully')); + router.navigate('/a/members?message=' + encodeURIComponent(t('admin_members.member_added'))); } catch (err) { container.querySelector('#addModal').close(); router.navigate('/a/members?error=' + encodeURIComponent(err.message)); @@ -289,7 +289,7 @@ ${flashHtml} try { await apiPut('/api/v1/members/' + encodeURIComponent(id), body); container.querySelector('#editModal').close(); - router.navigate('/a/members?message=' + encodeURIComponent('Member updated successfully')); + router.navigate('/a/members?message=' + encodeURIComponent(t('admin_members.member_updated'))); } catch (err) { container.querySelector('#editModal').close(); router.navigate('/a/members?error=' + encodeURIComponent(err.message)); @@ -314,7 +314,7 @@ ${flashHtml} try { await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId)); container.querySelector('#deleteModal').close(); - router.navigate('/a/members?message=' + encodeURIComponent('Member deleted successfully')); + router.navigate('/a/members?message=' + encodeURIComponent(t('admin_members.member_deleted'))); } catch (err) { container.querySelector('#deleteModal').close(); router.navigate('/a/members?error=' + encodeURIComponent(err.message)); @@ -322,6 +322,6 @@ ${flashHtml} }); } catch (e) { - litRender(errorAlert(e.message || 'Failed to load members'), container); + litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js index 2433eeb..f9419f5 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js @@ -1,8 +1,8 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js'; import { - html, litRender, nothing, + html, litRender, nothing, unsafeHTML, getConfig, typeEmoji, formatDateTimeShort, errorAlert, - successAlert, truncateKey, + successAlert, truncateKey, t, } from '../../components.js'; import { iconTag, iconLock } from '../../icons.js'; @@ -14,9 +14,9 @@ export async function render(container, params, router) { litRender(html`
${iconLock('h-16 w-16 opacity-30 mb-4')} -

Access Denied

-

The admin interface is not enabled.

- Go Home +

${t('admin.access_denied')}

+

${t('admin.admin_not_enabled')}

+ ${t('common.go_home')}
`, container); return; } @@ -25,9 +25,9 @@ export async function render(container, params, router) { litRender(html`
${iconLock('h-16 w-16 opacity-30 mb-4')} -

Authentication Required

-

You must sign in to access the admin interface.

- Sign In +

${t('admin.auth_required')}

+

${t('admin.auth_required_description')}

+ ${t('common.sign_in')}
`, container); return; } @@ -57,7 +57,7 @@ export async function render(container, params, router) { if (selectedPublicKey && selectedNode) { const nodeEmoji = typeEmoji(selectedNode.adv_type); - const nodeName = selectedNode.name || 'Unnamed Node'; + const nodeName = selectedNode.name || t('common.unnamed_node'); const otherNodes = allNodes.filter(n => n.public_key !== selectedPublicKey); const tagsTableHtml = tags.length > 0 @@ -66,11 +66,11 @@ export async function render(container, params, router) {
Member IDNameCallsignContactActions${t('admin_members.member_id')}${t('common.name')}${t('common.callsign')}${t('common.contact')}${t('common.actions')}
${m.contact || '-'}
- - + +
- - - - - + + + + + ${tags.map(tag => html` @@ -83,9 +83,9 @@ export async function render(container, params, router) { `)} @@ -93,14 +93,14 @@ export async function render(container, params, router) { ` : html`
-

No tags found for this node.

-

Add a new tag below.

+

${t('admin_node_tags.no_tags_found')}

+

${t('admin_node_tags.no_tags_hint')}

`; const bulkButtons = tags.length > 0 ? html` - - ` + + ` : nothing; contentHtml = html` @@ -116,7 +116,7 @@ export async function render(container, params, router) { @@ -124,25 +124,25 @@ export async function render(container, params, router) {
-

Tags (${tags.length})

+

${t('admin_node_tags.tags_count', { count: tags.length })}

${tagsTableHtml}
-

Add New Tag

+

${t('admin_node_tags.add_new_tag')}

- +
- +
- +
- +
- +
- + - + - + `; } else if (selectedPublicKey && !selectedNode) { contentHtml = html` @@ -293,8 +293,8 @@ export async function render(container, params, router) {
${iconTag('h-16 w-16 mx-auto mb-4 opacity-30')} -

Select a Node

-

Choose a node from the dropdown above to view and manage its tags.

+

${t('admin_node_tags.select_a_node')}

+

${t('admin_node_tags.select_a_node_description')}

`; } @@ -302,36 +302,36 @@ export async function render(container, params, router) { litRender(html`
-

Node Tags

+

${t('admin_node_tags.title')}

- Sign Out + ${t('common.sign_out')}
${flashHtml}
-

Select Node

+

${t('admin_node_tags.select_node')}

- +
- +
@@ -371,7 +371,7 @@ ${contentHtml}`, container); await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', { key, value, value_type, }); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag added successfully')); + router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.tag_added'))); } catch (err) { router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); } @@ -403,7 +403,7 @@ ${contentHtml}`, container); value, value_type, }); container.querySelector('#editModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag updated successfully')); + router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.tag_updated'))); } catch (err) { container.querySelector('#editModal').close(); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); @@ -435,7 +435,7 @@ ${contentHtml}`, container); new_public_key: newPublicKey, }); container.querySelector('#moveModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag moved successfully')); + router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.tag_moved'))); } catch (err) { container.querySelector('#moveModal').close(); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); @@ -460,7 +460,7 @@ ${contentHtml}`, container); try { await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey)); container.querySelector('#deleteModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('Tag deleted successfully')); + router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.tag_deleted'))); } catch (err) { container.querySelector('#deleteModal').close(); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); @@ -487,7 +487,7 @@ ${contentHtml}`, container); try { const result = await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/copy-to/' + encodeURIComponent(destKey)); container.querySelector('#copyAllModal').close(); - const msg = `Copied ${result.copied} tag(s), skipped ${result.skipped}`; + const msg = t('admin_node_tags.copied_tags', { copied: result.copied, skipped: result.skipped }); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg)); } catch (err) { container.querySelector('#copyAllModal').close(); @@ -511,7 +511,7 @@ ${contentHtml}`, container); try { await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags'); container.querySelector('#deleteAllModal').close(); - router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent('All tags deleted successfully')); + router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('admin_node_tags.all_tags_deleted'))); } catch (err) { container.querySelector('#deleteAllModal').close(); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); @@ -521,6 +521,6 @@ ${contentHtml}`, container); } } catch (e) { - litRender(errorAlert(e.message || 'Failed to load node tags'), container); + litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index b5efcfb..61c7aef 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -1,6 +1,6 @@ import { apiGet } from '../api.js'; import { - html, litRender, nothing, + html, litRender, nothing, t, getConfig, typeEmoji, formatDateTime, formatDateTimeShort, truncateKey, errorAlert, pagination, createFilterHandler, autoSubmit, submitOnEnter @@ -23,10 +23,10 @@ export async function render(container, params, router) { function renderPage(content, { total = null } = {}) { litRender(html`
-

Advertisements

+

${t('advertisements.title')}

${tzBadge} - ${total !== null ? html`${total} total` : nothing} + ${total !== null ? html`${t('common.total', { count: total })}` : nothing}
${content}`, container); @@ -57,10 +57,10 @@ ${content}`, container); ? html`
` @@ -70,17 +70,17 @@ ${content}`, container); ? html`
` : nothing; const mobileCards = advertisements.length === 0 - ? html`
No advertisements found.
` + ? html`
${t('advertisements.no_advertisements_found')}
` : advertisements.map(ad => { const emoji = typeEmoji(ad.adv_type); const adName = ad.node_tag_name || ad.node_name || ad.name; @@ -104,7 +104,7 @@ ${content}`, container);
- ${emoji} + ${emoji}
${nameBlock}
@@ -119,7 +119,7 @@ ${content}`, container); }); const tableRows = advertisements.length === 0 - ? html`
` + ? html`` : advertisements.map(ad => { const emoji = typeEmoji(ad.adv_type); const adName = ad.node_tag_name || ad.node_name || ad.name; @@ -144,7 +144,7 @@ ${content}`, container); return html`` + ? html`` : nodes.map(node => { - const tagName = node.tags?.find(t => t.key === 'name')?.value; + const tagName = node.tags?.find(tag => tag.key === 'name')?.value; const displayName = tagName || node.name; const emoji = typeEmoji(node.adv_type); const nameBlock = displayName @@ -110,14 +110,14 @@ ${content}`, container); const tags = node.tags || []; const tagsBlock = tags.length > 0 ? html`
- ${tags.slice(0, 3).map(t => html`${t.key}`)} + ${tags.slice(0, 3).map(tag => html`${tag.key}`)} ${tags.length > 3 ? html`+${tags.length - 3}` : nothing}
` : html`-`; return html`
KeyValueTypeUpdatedActions${t('common.key')}${t('common.value')}${t('common.type')}${t('common.updated')}${t('common.actions')}
${formatDateTimeShort(tag.updated_at)}
- - - + + +
No advertisements found.
${t('advertisements.no_advertisements_found')}
- ${emoji} + ${emoji}
${nameBlock}
@@ -165,15 +165,15 @@ ${content}`, container);
- +
${nodesFilter} ${membersFilter}
- - Clear + + ${t('common.clear')}
@@ -187,9 +187,9 @@ ${content}`, container); - - - + + + diff --git a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js index 3f72b0a..a0862ff 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js +++ b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js @@ -1,5 +1,5 @@ import { apiGet } from '../api.js'; -import { html, litRender, unsafeHTML, getConfig, errorAlert } from '../components.js'; +import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../components.js'; export async function render(container, params, router) { try { @@ -20,9 +20,9 @@ export async function render(container, params, router) { } catch (e) { if (e.message && e.message.includes('404')) { - litRender(errorAlert('Page not found'), container); + litRender(errorAlert(t('custom_page.page_not_found')), container); } else { - litRender(errorAlert(e.message || 'Failed to load page'), container); + litRender(errorAlert(e.message || t('custom_page.failed_to_load')), container); } } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index bc9285d..29e62b3 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -1,7 +1,7 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, - getConfig, typeEmoji, errorAlert, pageColors, + getConfig, typeEmoji, errorAlert, pageColors, t, } from '../components.js'; import { iconNodes, iconAdvertisements, iconMessages, iconChannel, @@ -43,7 +43,7 @@ function formatTimeShort(isoString) { function renderRecentAds(ads) { if (!ads || ads.length === 0) { - return html`

No advertisements recorded yet.

`; + return html`

${t('dashboard.no_advertisements_yet')}

`; } const rows = ads.slice(0, 5).map(ad => { const friendlyName = ad.tag_name || ad.name; @@ -67,9 +67,9 @@ function renderRecentAds(ads) {
NodeTimeReceivers${t('common.node')}${t('common.time')}${t('common.receivers')}
- - - + + + ${rows} @@ -90,7 +90,7 @@ function renderChannelMessages(channelMessages) { return html`

CH${String(channel)} - Channel ${String(channel)} + ${t('dashboard.channel', { number: String(channel) })}

${msgLines} @@ -102,7 +102,7 @@ function renderChannelMessages(channelMessages) {

${iconChannel('h-6 w-6')} - Recent Channel Messages + ${t('dashboard.recent_channel_messages')}

${channels} @@ -142,7 +142,7 @@ export async function render(container, params, router) { litRender(html`
-

Dashboard

+

${t('dashboard.title')}

${topCount > 0 ? html` @@ -152,9 +152,9 @@ ${topCount > 0 ? html`
${iconNodes('h-8 w-8')}
-
Total Nodes
+
${t('dashboard.total_nodes')}
${stats.total_nodes}
-
All discovered nodes
+
${t('dashboard.all_discovered_nodes')}
` : nothing} ${showAdverts ? html` @@ -162,9 +162,9 @@ ${topCount > 0 ? html`
${iconAdvertisements('h-8 w-8')}
-
Advertisements
+
${t('dashboard.advertisements')}
${stats.advertisements_7d}
-
Last 7 days
+
${t('time.last_7_days')}
` : nothing} ${showMessages ? html` @@ -172,9 +172,9 @@ ${topCount > 0 ? html`
${iconMessages('h-8 w-8')}
-
Messages
+
${t('dashboard.messages')}
${stats.messages_7d}
-
Last 7 days
+
${t('time.last_7_days')}
` : nothing}
@@ -184,9 +184,9 @@ ${topCount > 0 ? html`

${iconNodes('h-5 w-5')} - Total Nodes + ${t('dashboard.total_nodes')}

-

Over time (last 7 days)

+

${t('time.over_time_last_7_days')}

@@ -198,9 +198,9 @@ ${topCount > 0 ? html`

${iconAdvertisements('h-5 w-5')} - Advertisements + ${t('dashboard.advertisements')}

-

Per day (last 7 days)

+

${t('time.per_day_last_7_days')}

@@ -212,9 +212,9 @@ ${topCount > 0 ? html`

${iconMessages('h-5 w-5')} - Messages + ${t('dashboard.messages')}

-

Per day (last 7 days)

+

${t('time.per_day_last_7_days')}

@@ -229,7 +229,7 @@ ${bottomCount > 0 ? html`

${iconAdvertisements('h-6 w-6')} - Recent Advertisements + ${t('dashboard.recent_advertisements')}

${renderRecentAds(stats.recent_advertisements)}
@@ -256,6 +256,6 @@ ${bottomCount > 0 ? html` }; } catch (e) { - litRender(errorAlert(e.message || 'Failed to load dashboard'), container); + litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index 589242a..5fe5c51 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -1,7 +1,7 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, - getConfig, errorAlert, pageColors, + getConfig, errorAlert, pageColors, t, } from '../components.js'; import { iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap, @@ -11,12 +11,12 @@ import { function renderRadioConfig(rc) { if (!rc) return nothing; const fields = [ - ['Profile', rc.profile], - ['Frequency', rc.frequency], - ['Bandwidth', rc.bandwidth], - ['Spreading Factor', rc.spreading_factor], - ['Coding Rate', rc.coding_rate], - ['TX Power', rc.tx_power], + [t('home.profile'), rc.profile], + [t('home.frequency'), rc.frequency], + [t('home.bandwidth'), rc.bandwidth], + [t('home.spreading_factor'), rc.spreading_factor], + [t('home.coding_rate'), rc.coding_rate], + [t('home.tx_power'), rc.tx_power], ]; return fields .filter(([, v]) => v) @@ -49,8 +49,7 @@ export async function render(container, params, router) { const welcomeText = config.network_welcome_text ? html`

${config.network_welcome_text}

` : html`

- Welcome to the ${networkName} mesh network dashboard. - Monitor network activity, view connected nodes, and explore message history. + ${t('home.welcome_default', { network_name: networkName })}

`; const customPageButtons = features.pages !== false @@ -82,27 +81,27 @@ export async function render(container, params, router) { ${features.dashboard !== false ? html` ${iconDashboard('h-5 w-5 mr-2')} - Dashboard + ${t('nav.dashboard')} ` : nothing} ${features.nodes !== false ? html` ${iconNodes('h-5 w-5 mr-2')} - Nodes + ${t('nav.nodes')} ` : nothing} ${features.advertisements !== false ? html` ${iconAdvertisements('h-5 w-5 mr-2')} - Adverts + ${t('nav.advertisements')} ` : nothing} ${features.messages !== false ? html` ${iconMessages('h-5 w-5 mr-2')} - Messages + ${t('nav.messages')} ` : nothing} ${features.map !== false ? html` ${iconMap('h-5 w-5 mr-2')} - Map + ${t('nav.map')} ` : nothing} ${customPageButtons}
@@ -115,9 +114,9 @@ export async function render(container, params, router) {
${iconNodes('h-8 w-8')}
-
Total Nodes
+
${t('home.total_nodes')}
${stats.total_nodes}
-
All discovered nodes
+
${t('home.all_discovered_nodes')}
` : nothing} ${features.advertisements !== false ? html` @@ -125,9 +124,9 @@ export async function render(container, params, router) {
${iconAdvertisements('h-8 w-8')}
-
Advertisements
+
${t('home.advertisements')}
${stats.advertisements_7d}
-
Last 7 days
+
${t('time.last_7_days')}
` : nothing} ${features.messages !== false ? html` @@ -135,9 +134,9 @@ export async function render(container, params, router) {
${iconMessages('h-8 w-8')}
-
Messages
+
${t('home.messages')}
${stats.messages_7d}
-
Last 7 days
+
${t('time.last_7_days')}
` : nothing} ` : nothing} @@ -147,7 +146,7 @@ export async function render(container, params, router) {

${iconInfo('h-6 w-6')} - Network Info + ${t('home.network_info')}

${renderRadioConfig(rc)} @@ -157,19 +156,19 @@ export async function render(container, params, router) {
-

Our local off-grid mesh network is made possible by

+

${t('home.meshcore_attribution')}

-

Connecting people and things, without using the internet

+

${t('home.meshcore_tagline')}

@@ -180,9 +179,9 @@ export async function render(container, params, router) {

${iconChart('h-6 w-6')} - Network Activity + ${t('home.network_activity')}

-

Activity per day (last 7 days)

+

${t('time.activity_per_day_last_7_days')}

@@ -204,6 +203,6 @@ export async function render(container, params, router) { }; } catch (e) { - litRender(errorAlert(e.message || 'Failed to load home page'), container); + litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index a0b5599..ee26278 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -1,6 +1,6 @@ import { apiGet } from '../api.js'; import { - html, litRender, nothing, + html, litRender, nothing, t, typeEmoji, formatRelativeTime, escapeHtml, errorAlert, timezoneIndicator, } from '../components.js'; @@ -37,10 +37,10 @@ function normalizeType(type) { function getTypeDisplay(node) { const type = normalizeType(node.adv_type); - if (type === 'chat') return 'Chat'; - if (type === 'repeater') return 'Repeater'; - if (type === 'room') return 'Room'; - return type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown'; + if (type === 'chat') return (window.t && window.t('node_types.chat')) || 'Chat'; + if (type === 'repeater') return (window.t && window.t('node_types.repeater')) || 'Repeater'; + if (type === 'room') return (window.t && window.t('node_types.room')) || 'Room'; + return type ? type.charAt(0).toUpperCase() + type.slice(1) : (window.t && window.t('node_types.unknown')) || 'Unknown'; } // Leaflet DivIcon requires plain HTML strings, so keep escapeHtml here @@ -72,12 +72,12 @@ function createPopupContent(node) { const ownerDisplay = node.owner.callsign ? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')' : escapeHtml(node.owner.name); - ownerHtml = '

Owner: ' + ownerDisplay + '

'; + ownerHtml = '

' + ((window.t && window.t('map.owner')) || 'Owner:') + ' ' + ownerDisplay + '

'; } let roleHtml = ''; if (node.role) { - roleHtml = '

Role: ' + escapeHtml(node.role) + '

'; + roleHtml = '

' + ((window.t && window.t('map.role')) || 'Role:') + ' ' + escapeHtml(node.role) + '

'; } const typeDisplay = getTypeDisplay(node); @@ -87,25 +87,32 @@ function createPopupContent(node) { if (typeof node.is_infra !== 'undefined') { const dotColor = node.is_infra ? '#ef4444' : '#3b82f6'; const borderColor = node.is_infra ? '#b91c1c' : '#1e40af'; - const title = node.is_infra ? 'Infrastructure' : 'Public'; + const title = node.is_infra ? ((window.t && window.t('map.infrastructure')) || 'Infrastructure') : ((window.t && window.t('map.public')) || 'Public'); infraIndicatorHtml = ' '; } + const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:'; const lastSeenHtml = node.last_seen - ? '

Last seen: ' + node.last_seen.substring(0, 19).replace('T', ' ') + '

' + ? '

' + lastSeenLabel + ' ' + node.last_seen.substring(0, 19).replace('T', ' ') + '

' : ''; + const typeLabel = (window.t && window.t('common.type')) || 'Type:'; + const keyLabel = (window.t && window.t('common.key')) || 'Key:'; + const locationLabel = (window.t && window.t('common.location')) || 'Location:'; + const unknownLabel = (window.t && window.t('node_types.unknown')) || 'Unknown'; + const viewDetailsLabel = (window.t && window.t('common.view_details')) || 'View Details'; + return '
' + - '

' + nodeTypeEmoji + ' ' + escapeHtml(node.name || 'Unknown') + infraIndicatorHtml + '

' + + '

' + nodeTypeEmoji + ' ' + escapeHtml(node.name || unknownLabel) + infraIndicatorHtml + '

' + '
' + - '

Type: ' + escapeHtml(typeDisplay) + '

' + + '

' + typeLabel + ' ' + escapeHtml(typeDisplay) + '

' + roleHtml + ownerHtml + - '

Key: ' + escapeHtml(node.public_key.substring(0, 16)) + '...

' + - '

Location: ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '

' + + '

' + keyLabel + ' ' + escapeHtml(node.public_key.substring(0, 16)) + '...

' + + '

' + locationLabel + ' ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '

' + lastSeenHtml + '
' + - 'View Details' + + '' + viewDetailsLabel + '' + '
'; } @@ -166,10 +173,10 @@ export async function render(container, params, router) { litRender(html`
-

Map

+

${t('map.title')}

${timezoneIndicator()} - Loading... + ${t('common.loading')}
@@ -179,30 +186,30 @@ export async function render(container, params, router) {
- +
@@ -231,19 +238,19 @@ export async function render(container, params, router) {
- Legend: + ${t('map.legend')}
- Infrastructure + ${t('map.infrastructure')}
- Public + ${t('map.public')}
-

Nodes are placed on the map based on GPS coordinates from node reports or manual tags.

+

${t('map.gps_description')}

`, container); const mapEl = container.querySelector('#spa-map'); @@ -285,11 +292,11 @@ export async function render(container, params, router) { const filteredEl = container.querySelector('#filtered-count'); if (filteredNodes.length === allNodes.length) { - countEl.textContent = allNodes.length + ' nodes on map'; + countEl.textContent = t('map.nodes_on_map', { count: allNodes.length }); filteredEl.classList.add('hidden'); } else { - countEl.textContent = allNodes.length + ' total'; - filteredEl.textContent = filteredNodes.length + ' shown'; + countEl.textContent = t('common.total', { count: allNodes.length }); + filteredEl.textContent = t('common.shown', { count: filteredNodes.length }); filteredEl.classList.remove('hidden'); } @@ -302,12 +309,12 @@ export async function render(container, params, router) { } if (debug.total_nodes === 0) { - container.querySelector('#node-count').textContent = 'No nodes in database'; + container.querySelector('#node-count').textContent = t('map.no_nodes_in_database'); return () => map.remove(); } if (debug.nodes_with_coords === 0) { - container.querySelector('#node-count').textContent = debug.total_nodes + ' nodes (none have coordinates)'; + container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: debug.total_nodes }); return () => map.remove(); } @@ -328,6 +335,6 @@ export async function render(container, params, router) { return () => map.remove(); } catch (e) { - litRender(errorAlert(e.message || 'Failed to load map'), container); + litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/members.js b/src/meshcore_hub/web/static/js/spa/pages/members.js index 69cbf6b..c365e16 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/members.js @@ -1,6 +1,6 @@ import { apiGet } from '../api.js'; import { - html, litRender, nothing, + html, litRender, nothing, t, unsafeHTML, formatRelativeTime, formatDateTime, errorAlert, } from '../components.js'; import { iconInfo } from '../icons.js'; @@ -61,7 +61,7 @@ function renderMemberCard(member, nodes) { : nothing; const contactBlock = member.contact - ? html`

Contact: ${member.contact}

` + ? html`

${t('common.contact')}: ${member.contact}

` : nothing; return html`
@@ -85,22 +85,22 @@ export async function render(container, params, router) { if (members.length === 0) { litRender(html`
-

Members

- 0 members +

${t('members.title')}

+ ${t('members.count', { count: 0 })}
${iconInfo('stroke-current shrink-0 h-6 w-6')}
-

No members configured

-

To display network members, create a members.yaml file in your seed directory.

+

${t('members.no_members_configured')}

+

${t('members.no_members_description')}

-

Members File Format

-

Create a YAML file at $SEED_HOME/members.yaml with the following structure:

+

${t('members.members_file_format')}

+

${unsafeHTML(t('members.members_file_description'))}

members:
   - member_id: johndoe
     name: John Doe
@@ -113,8 +113,7 @@ export async function render(container, params, router) {
     role: Member
     description: Regular user in the downtown area.

- Run meshcore-hub collector seed to import members.
- To associate nodes with members, add a member_id tag to nodes in node_tags.yaml. + ${unsafeHTML(t('members.members_import_instructions'))}

`, container); @@ -139,8 +138,8 @@ export async function render(container, params, router) { litRender(html`
-

Members

- ${members.length} members +

${t('members.title')}

+ ${t('members.count', { count: members.length })}
@@ -148,6 +147,6 @@ export async function render(container, params, router) {
`, container); } catch (e) { - litRender(errorAlert(e.message || 'Failed to load members'), container); + litRender(errorAlert(e.message || t('common.failed_to_load_page')), container); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 738eba5..c3e7fe8 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -1,6 +1,6 @@ import { apiGet } from '../api.js'; import { - html, litRender, nothing, + html, litRender, nothing, t, getConfig, formatDateTime, formatDateTimeShort, truncateKey, errorAlert, pagination, timezoneIndicator, @@ -22,10 +22,10 @@ export async function render(container, params, router) { function renderPage(content, { total = null } = {}) { litRender(html`
-

Messages

+

${t('messages.title')}

${tzBadge} - ${total !== null ? html`${total} total` : nothing} + ${total !== null ? html`${t('common.total', { count: total })}` : nothing}
${content}`, container); @@ -41,14 +41,14 @@ ${content}`, container); const totalPages = Math.ceil(total / limit); const mobileCards = messages.length === 0 - ? html`
No messages found.
` + ? html`
${t('messages.no_messages_found')}
` : messages.map(msg => { const isChannel = msg.message_type === 'channel'; const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; - const typeTitle = isChannel ? 'Channel' : 'Contact'; + const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact'); let senderBlock; if (isChannel) { - senderBlock = html`Public`; + senderBlock = html`${t('messages.type_public')}`; } else { const senderName = msg.sender_tag_name || msg.sender_name; if (senderName) { @@ -95,14 +95,14 @@ ${content}`, container); }); const tableRows = messages.length === 0 - ? html`
` + ? html`` : messages.map(msg => { const isChannel = msg.message_type === 'channel'; const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; - const typeTitle = isChannel ? 'Channel' : 'Contact'; + const typeTitle = isChannel ? t('messages.type_channel') : t('messages.type_contact'); let senderBlock; if (isChannel) { - senderBlock = html`Public`; + senderBlock = html`${t('messages.type_public')}`; } else { const senderName = msg.sender_tag_name || msg.sender_name; if (senderName) { @@ -144,17 +144,17 @@ ${content}`, container);
- - Clear + + ${t('common.clear')}
@@ -168,11 +168,11 @@ ${content}`, container);
NodeTypeReceived${t('common.node')}${t('common.type')}${t('common.received')}
No messages found.
${t('messages.no_messages_found')}
- - - - - + + + + + diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index 18b79d1..8341c48 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -2,7 +2,7 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, getConfig, typeEmoji, formatDateTime, - truncateKey, errorAlert, + truncateKey, errorAlert, t, } from '../components.js'; import { iconError } from '../icons.js'; @@ -30,7 +30,7 @@ export async function render(container, params, router) { const config = getConfig(); const tagName = node.tags?.find(t => t.key === 'name')?.value; - const displayName = tagName || node.name || 'Unnamed Node'; + const displayName = tagName || node.name || t('common.unnamed_node'); const emoji = typeEmoji(node.adv_type); let lat = node.lat; @@ -57,12 +57,12 @@ export async function render(container, params, router) {
-

Scan to add as contact

+

${t('nodes.scan_to_add')}

`; const coordsHtml = hasCoords - ? html`
Location: ${lat}, ${lon}
` + ? html`
${t('common.location')}: ${lat}, ${lon}
` : nothing; const adsTableHtml = advertisements.length > 0 @@ -70,9 +70,9 @@ export async function render(container, params, router) {
TypeTimeFromMessageReceivers${t('common.type')}${t('common.time')}${t('common.from')}${t('common.message')}${t('common.receivers')}
- - - + + + @@ -101,7 +101,7 @@ export async function render(container, params, router) {
TimeTypeReceived By${t('common.time')}${t('common.type')}${t('common.received_by')}
` - : html`

No advertisements recorded.

`; + : html`

${t('nodes.no_advertisements')}

`; const tags = node.tags || []; const tagsTableHtml = tags.length > 0 @@ -109,9 +109,9 @@ export async function render(container, params, router) { - - - + + + @@ -123,25 +123,25 @@ export async function render(container, params, router) {
KeyValueType${t('common.key')}${t('common.value')}${t('common.type')}
` - : html`

No tags defined.

`; + : html`

${t('nodes.no_tags')}

`; const adminTagsHtml = (config.admin_enabled && config.is_authenticated) ? html`` : nothing; litRender(html`

- ${emoji} + ${emoji} ${displayName}

@@ -150,12 +150,12 @@ ${heroHtml}
-

Public Key

+

${t('common.public_key')}

${node.public_key}
-
First seen: ${formatDateTime(node.first_seen)}
-
Last seen: ${formatDateTime(node.last_seen)}
+
${t('common.first_seen')} ${formatDateTime(node.first_seen)}
+
${t('common.last_seen_label')} ${formatDateTime(node.last_seen)}
${coordsHtml}
@@ -164,14 +164,14 @@ ${heroHtml}
-

Recent Advertisements

+

${t('nodes.recent_advertisements')}

${adsTableHtml}
-

Tags

+

${t('nodes.tags')}

${tagsTableHtml} ${adminTagsHtml}
@@ -237,14 +237,14 @@ function renderNotFound(publicKey) { return html`
${iconError('stroke-current shrink-0 h-6 w-6')} - Node not found: ${publicKey} + ${t('nodes.node_not_found', { public_key: publicKey })}
-Back to Nodes`; +${t('nodes.back_to_nodes')}`; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index 1cdc936..6ebf605 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -4,7 +4,7 @@ import { getConfig, typeEmoji, formatDateTime, formatDateTimeShort, truncateKey, errorAlert, pagination, timezoneIndicator, - createFilterHandler, autoSubmit, submitOnEnter + createFilterHandler, autoSubmit, submitOnEnter, t } from '../components.js'; export async function render(container, params, router) { @@ -24,10 +24,10 @@ export async function render(container, params, router) { function renderPage(content, { total = null } = {}) { litRender(html`
-

Nodes

+

${t('nodes.title')}

${tzBadge} - ${total !== null ? html`${total} total` : nothing} + ${total !== null ? html`${t('common.total', { count: total })}` : nothing}
${content}`, container); @@ -51,19 +51,19 @@ ${content}`, container); ? html`
` : nothing; const mobileCards = nodes.length === 0 - ? html`
No nodes found.
` + ? html`
${t('nodes.no_nodes_found')}
` : nodes.map(node => { - const tagName = node.tags?.find(t => t.key === 'name')?.value; + const tagName = node.tags?.find(tag => tag.key === 'name')?.value; const displayName = tagName || node.name; const emoji = typeEmoji(node.adv_type); const nameBlock = displayName @@ -74,7 +74,7 @@ ${content}`, container); const tags = node.tags || []; const tagsBlock = tags.length > 0 ? html`
- ${tags.slice(0, 2).map(t => html`${t.key}`)} + ${tags.slice(0, 2).map(tag => html`${tag.key}`)} ${tags.length > 2 ? html`+${tags.length - 2}` : nothing}
` : nothing; @@ -82,7 +82,7 @@ ${content}`, container);
- ${emoji} + ${emoji}
${nameBlock}
@@ -97,9 +97,9 @@ ${content}`, container); }); const tableRows = nodes.length === 0 - ? html`
No nodes found.
${t('nodes.no_nodes_found')}
- ${emoji} + ${emoji}
${nameBlock}
@@ -138,25 +138,25 @@ ${content}`, container);
- +
${membersFilter}
- - Clear + + ${t('common.clear')}
@@ -170,9 +170,9 @@ ${content}`, container); - - - + + + diff --git a/src/meshcore_hub/web/static/js/spa/pages/not-found.js b/src/meshcore_hub/web/static/js/spa/pages/not-found.js index 686a37e..00ff1b6 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/not-found.js +++ b/src/meshcore_hub/web/static/js/spa/pages/not-found.js @@ -1,4 +1,4 @@ -import { html, litRender } from '../components.js'; +import { html, litRender, t } from '../components.js'; import { iconHome, iconNodes } from '../icons.js'; export async function render(container, params, router) { @@ -7,18 +7,18 @@ export async function render(container, params, router) {
404
-

Page Not Found

+

${t('not_found.title')}

- The page you're looking for doesn't exist or has been moved. + ${t('not_found.description')}

diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json new file mode 100644 index 0000000..7a3bdfe --- /dev/null +++ b/src/meshcore_hub/web/static/locales/en.json @@ -0,0 +1,257 @@ +{ + "nav": { + "home": "Home", + "dashboard": "Dashboard", + "nodes": "Nodes", + "advertisements": "Advertisements", + "messages": "Messages", + "map": "Map", + "members": "Members", + "admin": "Admin" + }, + "page_title": { + "dashboard": "Dashboard - {{network_name}}", + "nodes": "Nodes - {{network_name}}", + "node_detail": "Node Detail - {{network_name}}", + "advertisements": "Advertisements - {{network_name}}", + "messages": "Messages - {{network_name}}", + "map": "Map - {{network_name}}", + "members": "Members - {{network_name}}", + "admin": "Admin - {{network_name}}", + "admin_node_tags": "Node Tags - Admin - {{network_name}}", + "admin_members": "Members - Admin - {{network_name}}" + }, + "common": { + "filter": "Filter", + "clear": "Clear", + "clear_filters": "Clear Filters", + "search": "Search", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "move": "Move", + "save_changes": "Save Changes", + "previous": "Previous", + "next": "Next", + "go_home": "Go Home", + "loading": "Loading...", + "error": "Error", + "failed_to_load_page": "Failed to load page", + "total": "{{count}} total", + "shown": "{{count}} shown", + "type": "Type", + "name": "Name", + "key": "Key", + "value": "Value", + "time": "Time", + "node": "Node", + "actions": "Actions", + "updated": "Updated", + "sign_in": "Sign In", + "sign_out": "Sign Out", + "view_details": "View Details", + "all_types": "All Types", + "all_nodes": "All Nodes", + "all_members": "All Members", + "member": "Member", + "node_type": "Node Type", + "show": "Show", + "search_placeholder": "Search by name, ID, or public key...", + "contact": "Contact", + "description": "Description", + "callsign": "Callsign", + "tags": "Tags", + "last_seen": "Last Seen", + "first_seen": "First seen:", + "last_seen_label": "Last seen:", + "location": "Location", + "public_key": "Public Key", + "received": "Received", + "received_by": "Received By", + "receivers": "Receivers", + "from": "From", + "message": "Message", + "close": "close", + "unnamed": "Unnamed", + "unnamed_node": "Unnamed Node" + }, + "time": { + "days_ago": "{{count}}d ago", + "hours_ago": "{{count}}h ago", + "minutes_ago": "{{count}}m ago", + "less_than_minute": "<1m ago", + "last_7_days": "Last 7 days", + "per_day_last_7_days": "Per day (last 7 days)", + "over_time_last_7_days": "Over time (last 7 days)", + "activity_per_day_last_7_days": "Activity per day (last 7 days)" + }, + "node_types": { + "chat": "Chat", + "repeater": "Repeater", + "room": "Room", + "unknown": "Unknown" + }, + "home": { + "welcome_default": "Welcome to the {{network_name}} mesh network dashboard. Monitor network activity, view connected nodes, and explore message history.", + "total_nodes": "Total Nodes", + "all_discovered_nodes": "All discovered nodes", + "advertisements": "Advertisements", + "messages": "Messages", + "network_info": "Network Info", + "network_activity": "Network Activity", + "meshcore_attribution": "Our local off-grid mesh network is made possible by", + "meshcore_tagline": "Connecting people and things, without using the internet", + "website": "Website", + "github": "GitHub", + "profile": "Profile", + "frequency": "Frequency", + "bandwidth": "Bandwidth", + "spreading_factor": "Spreading Factor", + "coding_rate": "Coding Rate", + "tx_power": "TX Power" + }, + "dashboard": { + "title": "Dashboard", + "total_nodes": "Total Nodes", + "all_discovered_nodes": "All discovered nodes", + "advertisements": "Advertisements", + "messages": "Messages", + "recent_advertisements": "Recent Advertisements", + "recent_channel_messages": "Recent Channel Messages", + "channel": "Channel {{number}}", + "no_advertisements_yet": "No advertisements recorded yet." + }, + "nodes": { + "title": "Nodes", + "no_nodes_found": "No nodes found.", + "node_not_found": "Node not found: {{public_key}}", + "back_to_nodes": "Back to Nodes", + "scan_to_add": "Scan to add as contact", + "recent_advertisements": "Recent Advertisements", + "no_advertisements": "No advertisements recorded.", + "tags": "Tags", + "no_tags": "No tags defined.", + "edit_tags": "Edit Tags", + "add_tags": "Add Tags" + }, + "advertisements": { + "title": "Advertisements", + "no_advertisements_found": "No advertisements found." + }, + "messages": { + "title": "Messages", + "no_messages_found": "No messages found.", + "type_direct": "Direct", + "type_channel": "Channel", + "type_contact": "Contact", + "type_public": "Public" + }, + "map": { + "title": "Map", + "show_labels": "Show Labels", + "infrastructure_only": "Infrastructure Only", + "legend": "Legend:", + "infrastructure": "Infrastructure", + "public": "Public", + "nodes_on_map": "{{count}} nodes on map", + "no_nodes_in_database": "No nodes in database", + "nodes_none_have_coordinates": "{{count}} nodes (none have coordinates)", + "gps_description": "Nodes are placed on the map based on GPS coordinates from node reports or manual tags.", + "owner": "Owner:", + "role": "Role:", + "select_destination_node": "-- Select destination node --" + }, + "members": { + "title": "Members", + "count": "{{count}} members", + "no_members_configured": "No members configured", + "no_members_description": "To display network members, create a members.yaml file in your seed directory.", + "members_file_format": "Members File Format", + "members_file_description": "Create a YAML file at $SEED_HOME/members.yaml with the following structure:", + "members_import_instructions": "Run meshcore-hub collector seed to import members.
To associate nodes with members, add a member_id tag to nodes in node_tags.yaml." + }, + "not_found": { + "title": "Page Not Found", + "description": "The page you're looking for doesn't exist or has been moved.", + "browse_nodes": "Browse Nodes" + }, + "custom_page": { + "page_not_found": "Page not found", + "failed_to_load": "Failed to load page" + }, + "admin": { + "title": "Admin", + "access_denied": "Access Denied", + "admin_not_enabled": "The admin interface is not enabled.", + "admin_enable_hint": "Set WEB_ADMIN_ENABLED=true to enable admin features.", + "auth_required": "Authentication Required", + "auth_required_description": "You must sign in to access the admin interface.", + "welcome": "Welcome to the admin panel.", + "members_title": "Members", + "members_description": "Manage network members and operators.", + "node_tags_title": "Node Tags", + "node_tags_description": "Manage custom tags and metadata for network nodes." + }, + "admin_members": { + "title": "Members", + "network_members": "Network Members ({{count}})", + "add_member": "Add Member", + "add_new_member": "Add New Member", + "edit_member": "Edit Member", + "delete_member": "Delete Member", + "member_id": "Member ID", + "member_id_hint": "Unique identifier (letters, numbers, underscore)", + "no_members_yet": "No members configured yet.", + "no_members_hint": "Click \"Add Member\" to create the first member.", + "delete_confirm": "Are you sure you want to delete member {{name}}?", + "cannot_be_undone": "This action cannot be undone.", + "member_added": "Member added successfully", + "member_updated": "Member updated successfully", + "member_deleted": "Member deleted successfully" + }, + "admin_node_tags": { + "title": "Node Tags", + "select_node": "Select Node", + "select_node_placeholder": "-- Select a node --", + "load_tags": "Load Tags", + "tags_count": "Tags ({{count}})", + "add_new_tag": "Add New Tag", + "add_tag": "Add Tag", + "edit_tag": "Edit Tag", + "move_tag": "Move Tag", + "move_tag_title": "Move Tag to Another Node", + "move_tag_warning": "This will move the tag from the current node to the destination node.", + "delete_tag": "Delete Tag", + "delete_tag_confirm": "Are you sure you want to delete the tag \"{{key}}\"?", + "cannot_be_undone": "This action cannot be undone.", + "copy_all": "Copy All", + "copy_all_title": "Copy All Tags to Another Node", + "copy_all_description": "Copy all {{count}} tag(s) from {{name}} to another node.", + "copy_all_info": "Tags that already exist on the destination node will be skipped. Original tags remain on this node.", + "copy_tags": "Copy Tags", + "delete_all": "Delete All", + "delete_all_title": "Delete All Tags", + "delete_all_confirm": "Are you sure you want to delete all {{count}} tag(s) from {{name}}?", + "delete_all_warning": "This action cannot be undone. All tags will be permanently deleted.", + "delete_all_tags": "Delete All Tags", + "view_node": "View Node", + "destination_node": "Destination Node", + "tag_key": "Tag Key", + "no_tags_found": "No tags found for this node.", + "no_tags_hint": "Add a new tag below.", + "select_a_node": "Select a Node", + "select_a_node_description": "Choose a node from the dropdown above to view and manage its tags.", + "tag_added": "Tag added successfully", + "tag_updated": "Tag updated successfully", + "tag_moved": "Tag moved successfully", + "tag_deleted": "Tag deleted successfully", + "all_tags_deleted": "All tags deleted successfully", + "copied_tags": "Copied {{copied}} tag(s), skipped {{skipped}}" + }, + "footer": { + "powered_by": "Powered by", + "discord": "Discord", + "github": "GitHub", + "youtube": "YouTube" + } +} diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index ad5a1a6..37ad867 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -60,24 +60,24 @@
` + ? html`` : advertisements.map(ad => { const emoji = typeEmoji(ad.adv_type); const adName = ad.node_tag_name || ad.node_name || ad.name; diff --git a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js index a0862ff..3f5257b 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/custom-page.js +++ b/src/meshcore_hub/web/static/js/spa/pages/custom-page.js @@ -20,7 +20,7 @@ export async function render(container, params, router) { } catch (e) { if (e.message && e.message.includes('404')) { - litRender(errorAlert(t('custom_page.page_not_found')), container); + litRender(errorAlert(t('common.page_not_found')), container); } else { litRender(errorAlert(e.message || t('custom_page.failed_to_load')), container); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index 91f9729..78a6344 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -43,7 +43,7 @@ function formatTimeShort(isoString) { function renderRecentAds(ads) { if (!ads || ads.length === 0) { - return html`

${t('dashboard.no_advertisements_yet')}

`; + return html`

${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}

`; } const rows = ads.slice(0, 5).map(ad => { const friendlyName = ad.tag_name || ad.name; diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index 8525762..966272b 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -309,7 +309,7 @@ export async function render(container, params, router) { } if (debug.total_nodes === 0) { - container.querySelector('#node-count').textContent = t('map.no_nodes_in_database'); + container.querySelector('#node-count').textContent = t('common.no_entity_in_database', { entity: t('entities.nodes').toLowerCase() }); return () => map.remove(); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/members.js b/src/meshcore_hub/web/static/js/spa/pages/members.js index 03da14e..c8adde0 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/members.js +++ b/src/meshcore_hub/web/static/js/spa/pages/members.js @@ -92,8 +92,8 @@ export async function render(container, params, router) {
${iconInfo('stroke-current shrink-0 h-6 w-6')}
-

${t('members.no_members_configured')}

-

${t('members.no_members_description')}

+

${t('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}

+

${t('members.empty_state_description')}

diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index b9e0a23..342d3c2 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -41,7 +41,7 @@ ${content}`, container); const totalPages = Math.ceil(total / limit); const mobileCards = messages.length === 0 - ? html`
${t('messages.no_messages_found')}
` + ? html`
${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}
` : messages.map(msg => { const isChannel = msg.message_type === 'channel'; const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; @@ -95,7 +95,7 @@ ${content}`, container); }); const tableRows = messages.length === 0 - ? html`` + ? html`` : messages.map(msg => { const isChannel = msg.message_type === 'channel'; const typeIcon = isChannel ? '\u{1F4FB}' : '\u{1F464}'; diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index 4ce40b9..af69632 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -101,7 +101,7 @@ export async function render(container, params, router) {
NodeLast SeenTags${t('common.node')}${t('common.last_seen')}${t('common.tags')}
${t('advertisements.no_advertisements_found')}
${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
${t('messages.no_messages_found')}
${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}
` - : html`

${t('nodes.no_advertisements')}

`; + : html`

${t('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}

`; const tags = node.tags || []; const tagsTableHtml = tags.length > 0 @@ -123,7 +123,7 @@ export async function render(container, params, router) {
` - : html`

${t('nodes.no_tags')}

`; + : html`

${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}

`; const adminTagsHtml = (config.admin_enabled && config.is_authenticated) ? html`
@@ -154,7 +154,7 @@ ${heroHtml} ${node.public_key}
-
${t('common.first_seen')} ${formatDateTime(node.first_seen)}
+
${t('common.first_seen_label')} ${formatDateTime(node.first_seen)}
${t('common.last_seen_label')} ${formatDateTime(node.last_seen)}
${coordsHtml}
@@ -239,12 +239,12 @@ function renderNotFound(publicKey) {
${iconError('stroke-current shrink-0 h-6 w-6')} - ${t('nodes.node_not_found', { public_key: publicKey })} + ${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}
${t('common.view_entity', { entity: t('entities.nodes') })}`; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index b7dced4..3c4598e 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -61,7 +61,7 @@ ${content}`, container); : nothing; const mobileCards = nodes.length === 0 - ? html`
${t('nodes.no_nodes_found')}
` + ? html`
${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}
` : nodes.map(node => { const tagName = node.tags?.find(tag => tag.key === 'name')?.value; const displayName = tagName || node.name; @@ -97,7 +97,7 @@ ${content}`, container); }); const tableRows = nodes.length === 0 - ? html`${t('nodes.no_nodes_found')}` + ? html`${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}` : nodes.map(node => { const tagName = node.tags?.find(tag => tag.key === 'name')?.value; const displayName = tagName || node.name; diff --git a/src/meshcore_hub/web/static/js/spa/pages/not-found.js b/src/meshcore_hub/web/static/js/spa/pages/not-found.js index b9f8eef..8d3cfd1 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/not-found.js +++ b/src/meshcore_hub/web/static/js/spa/pages/not-found.js @@ -7,7 +7,7 @@ export async function render(container, params, router) {
404
-

${t('not_found.title')}

+

${t('common.page_not_found')}

${t('not_found.description')}

diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 0483b4d..0b1ec39 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -34,11 +34,30 @@ "delete_entity": "Delete {{entity}}", "delete_all_entity": "Delete All {{entity}}", "move_entity": "Move {{entity}}", + "move_entity_to_another_node": "Move {{entity}} to Another Node", "copy_entity": "Copy {{entity}}", + "copy_all_entity_to_another_node": "Copy All {{entity}} to Another Node", "view_entity": "View {{entity}}", "recent_entity": "Recent {{entity}}", "total_entity": "Total {{entity}}", "all_entity": "All {{entity}}", + "no_entity_found": "No {{entity}} found", + "no_entity_recorded": "No {{entity}} recorded", + "no_entity_defined": "No {{entity}} defined", + "no_entity_in_database": "No {{entity}} in database", + "no_entity_configured": "No {{entity}} configured", + "no_entity_yet": "No {{entity}} yet", + "entity_not_found_details": "{{entity}} not found: {{details}}", + "page_not_found": "Page not found", + "delete_entity_confirm": "Are you sure you want to delete {{entity}} {{name}}?", + "delete_all_entity_confirm": "Are you sure you want to delete all {{count}} {{entity}} from {{name}}?", + "cannot_be_undone": "This action cannot be undone.", + "entity_added_success": "{{entity}} added successfully", + "entity_updated_success": "{{entity}} updated successfully", + "entity_deleted_success": "{{entity}} deleted successfully", + "entity_moved_success": "{{entity}} moved successfully", + "all_entity_deleted_success": "All {{entity}} deleted successfully", + "copy_all_entity_description": "Copy all {{count}} {{entity}} from {{name}} to another node.", "previous": "Previous", "next": "Next", "go_home": "Go Home", @@ -67,7 +86,7 @@ "callsign": "Callsign", "tags": "Tags", "last_seen": "Last Seen", - "first_seen": "First seen:", + "first_seen_label": "First seen:", "last_seen_label": "Last seen:", "location": "Location", "public_key": "Public Key", @@ -117,21 +136,13 @@ "dashboard": { "all_discovered_nodes": "All discovered nodes", "recent_channel_messages": "Recent Channel Messages", - "channel": "Channel {{number}}", - "no_advertisements_yet": "No advertisements recorded yet." + "channel": "Channel {{number}}" }, "nodes": { - "no_nodes_found": "No nodes found.", - "node_not_found": "Node not found: {{public_key}}", - "scan_to_add": "Scan to add as contact", - "no_advertisements": "No advertisements recorded.", - "no_tags": "No tags defined." - }, - "advertisements": { - "no_advertisements_found": "No advertisements found." + "scan_to_add": "Scan to add as contact" }, + "advertisements": {}, "messages": { - "no_messages_found": "No messages found.", "type_direct": "Direct", "type_channel": "Channel", "type_contact": "Contact", @@ -144,7 +155,6 @@ "infrastructure": "Infrastructure", "public": "Public", "nodes_on_map": "{{count}} nodes on map", - "no_nodes_in_database": "No nodes in database", "nodes_none_have_coordinates": "{{count}} nodes (none have coordinates)", "gps_description": "Nodes are placed on the map based on GPS coordinates from node reports or manual tags.", "owner": "Owner:", @@ -152,18 +162,15 @@ "select_destination_node": "-- Select destination node --" }, "members": { - "no_members_configured": "No members configured", - "no_members_description": "To display network members, create a members.yaml file in your seed directory.", + "empty_state_description": "To display network members, create a members.yaml file in your seed directory.", "members_file_format": "Members File Format", "members_file_description": "Create a YAML file at $SEED_HOME/members.yaml with the following structure:", "members_import_instructions": "Run meshcore-hub collector seed to import members.
To associate nodes with members, add a member_id tag to nodes in node_tags.yaml." }, "not_found": { - "title": "Page Not Found", "description": "The page you're looking for doesn't exist or has been moved." }, "custom_page": { - "page_not_found": "Page not found", "failed_to_load": "Failed to load page" }, "admin": { @@ -180,40 +187,23 @@ "network_members": "Network Members ({{count}})", "member_id": "Member ID", "member_id_hint": "Unique identifier (letters, numbers, underscore)", - "no_members_yet": "No members configured yet.", - "no_members_hint": "Click \"Add Member\" to create the first member.", - "delete_confirm": "Are you sure you want to delete member {{name}}?", - "cannot_be_undone": "This action cannot be undone.", - "entity_added": "Member added successfully", - "entity_updated": "Member updated successfully", - "entity_deleted": "Member deleted successfully" + "empty_state_hint": "Click \"Add Member\" to create the first member." }, "admin_node_tags": { "select_node": "Select Node", "select_node_placeholder": "-- Select a node --", "load_tags": "Load Tags", - "move_to_another_node": "Move Tag to Another Node", "move_warning": "This will move the tag from the current node to the destination node.", - "delete_tag_confirm": "Are you sure you want to delete the tag \"{{key}}\"?", - "cannot_be_undone": "This action cannot be undone.", "copy_all": "Copy All", - "copy_all_title": "Copy All Tags to Another Node", - "copy_all_description": "Copy all {{count}} tag(s) from {{name}} to another node.", "copy_all_info": "Tags that already exist on the destination node will be skipped. Original tags remain on this node.", "delete_all": "Delete All", - "delete_all_confirm": "Are you sure you want to delete all {{count}} tag(s) from {{name}}?", - "delete_all_warning": "This action cannot be undone. All tags will be permanently deleted.", + "delete_all_warning": "All tags will be permanently deleted.", "destination_node": "Destination Node", "tag_key": "Tag Key", - "no_tags_found": "No tags found for this node.", - "no_tags_hint": "Add a new tag below.", + "for_this_node": "for this node", + "empty_state_hint": "Add a new tag below.", "select_a_node": "Select a Node", "select_a_node_description": "Choose a node from the dropdown above to view and manage its tags.", - "entity_added": "Tag added successfully", - "entity_updated": "Tag updated successfully", - "entity_moved": "Tag moved successfully", - "entity_deleted": "Tag deleted successfully", - "all_entities_deleted": "All tags deleted successfully", "copied_entities": "Copied {{copied}} tag(s), skipped {{skipped}}" }, "footer": { diff --git a/src/meshcore_hub/web/static/locales/languages.md b/src/meshcore_hub/web/static/locales/languages.md new file mode 100644 index 0000000..d314060 --- /dev/null +++ b/src/meshcore_hub/web/static/locales/languages.md @@ -0,0 +1,417 @@ +# Translation Reference Guide + +This document provides a comprehensive reference for translating the MeshCore Hub web dashboard. + +## File Structure + +Translation files are JSON files named by language code (e.g., `en.json`, `es.json`, `fr.json`) and located in `/src/meshcore_hub/web/static/locales/`. + +## Variable Interpolation + +Many translations use `{{variable}}` syntax for dynamic content. These must be preserved exactly: + +```json +"total": "{{count}} total" +``` + +When translating, keep the variable names unchanged: +```json +"total": "{{count}} au total" // French example +``` + +## Translation Sections + +### 1. `entities` + +Core entity names used throughout the application. These are referenced by other translations for composition. + +| Key | English | Context | +|-----|---------|---------| +| `home` | Home | Homepage/breadcrumb navigation | +| `dashboard` | Dashboard | Main dashboard page | +| `nodes` | Nodes | Mesh network nodes (plural) | +| `node` | Node | Single mesh network node | +| `node_detail` | Node Detail | Node details page | +| `advertisements` | Advertisements | Network advertisements (plural) | +| `advertisement` | Advertisement | Single advertisement | +| `messages` | Messages | Network messages (plural) | +| `message` | Message | Single message | +| `map` | Map | Network map page | +| `members` | Members | Network members (plural) | +| `member` | Member | Single network member | +| `admin` | Admin | Admin panel | +| `tags` | Tags | Node metadata tags (plural) | +| `tag` | Tag | Single tag | + +**Usage:** These are used with composite patterns. For example, `t('common.add_entity', { entity: t('entities.node') })` produces "Add Node". + +### 2. `common` + +Reusable patterns and UI elements used across multiple pages. + +#### Actions + +| Key | English | Context | +|-----|---------|---------| +| `filter` | Filter | Filter button/action | +| `clear` | Clear | Clear action | +| `clear_filters` | Clear Filters | Reset all filters | +| `search` | Search | Search button/action | +| `cancel` | Cancel | Cancel button in dialogs | +| `delete` | Delete | Delete button | +| `edit` | Edit | Edit button | +| `move` | Move | Move button | +| `save` | Save | Save button | +| `save_changes` | Save Changes | Save changes button | +| `add` | Add | Add button | +| `close` | close | Close button (lowercase for accessibility) | +| `sign_in` | Sign In | Authentication sign in | +| `sign_out` | Sign Out | Authentication sign out | +| `go_home` | Go Home | Return to homepage button | + +#### Composite Patterns with Entity + +These patterns use `{{entity}}` variable - the entity name is provided dynamically: + +| Key | English | Example Output | +|-----|---------|----------------| +| `add_entity` | Add {{entity}} | "Add Node", "Add Tag" | +| `add_new_entity` | Add New {{entity}} | "Add New Member" | +| `edit_entity` | Edit {{entity}} | "Edit Tag" | +| `delete_entity` | Delete {{entity}} | "Delete Member" | +| `delete_all_entity` | Delete All {{entity}} | "Delete All Tags" | +| `move_entity` | Move {{entity}} | "Move Tag" | +| `move_entity_to_another_node` | Move {{entity}} to Another Node | "Move Tag to Another Node" | +| `copy_entity` | Copy {{entity}} | "Copy Tags" | +| `copy_all_entity_to_another_node` | Copy All {{entity}} to Another Node | "Copy All Tags to Another Node" | +| `view_entity` | View {{entity}} | "View Node" | +| `recent_entity` | Recent {{entity}} | "Recent Advertisements" | +| `total_entity` | Total {{entity}} | "Total Nodes" | +| `all_entity` | All {{entity}} | "All Messages" | + +#### Empty State Patterns + +These patterns indicate when data is absent. Use `{{entity}}` in lowercase (e.g., "nodes", not "Nodes"): + +| Key | English | Context | +|-----|---------|---------| +| `no_entity_found` | No {{entity}} found | Search/filter returned no results | +| `no_entity_recorded` | No {{entity}} recorded | No historical records exist | +| `no_entity_defined` | No {{entity}} defined | No configuration/definitions exist | +| `no_entity_in_database` | No {{entity}} in database | Database is empty | +| `no_entity_configured` | No {{entity}} configured | System not configured | +| `no_entity_yet` | No {{entity}} yet | Empty state, expecting data later | +| `entity_not_found_details` | {{entity}} not found: {{details}} | Specific item not found with details | +| `page_not_found` | Page not found | 404 error message | + +#### Confirmation Patterns + +Used in delete/move dialogs. Variables: `{{entity}}`, `{{name}}`, `{{count}}`: + +| Key | English | Context | +|-----|---------|---------| +| `delete_entity_confirm` | Are you sure you want to delete {{entity}} {{name}}? | Single item delete confirmation | +| `delete_all_entity_confirm` | Are you sure you want to delete all {{count}} {{entity}} from {{name}}? | Bulk delete confirmation | +| `cannot_be_undone` | This action cannot be undone. | Warning in delete dialogs | + +#### Success Messages + +Toast/flash messages after successful operations: + +| Key | English | Context | +|-----|---------|---------| +| `entity_added_success` | {{entity}} added successfully | After creating new item | +| `entity_updated_success` | {{entity}} updated successfully | After updating item | +| `entity_deleted_success` | {{entity}} deleted successfully | After deleting item | +| `entity_moved_success` | {{entity}} moved successfully | After moving tag to another node | +| `all_entity_deleted_success` | All {{entity}} deleted successfully | After bulk delete | +| `copy_all_entity_description` | Copy all {{count}} {{entity}} from {{name}} to another node. | Copy operation description | + +#### Navigation & Status + +| Key | English | Context | +|-----|---------|---------| +| `previous` | Previous | Pagination previous | +| `next` | Next | Pagination next | +| `loading` | Loading... | Loading indicator | +| `error` | Error | Error state | +| `failed_to_load_page` | Failed to load page | Page load error | + +#### Counts & Metrics + +| Key | English | Context | +|-----|---------|---------| +| `total` | {{count}} total | Total count display | +| `shown` | {{count}} shown | Filtered count display | +| `count_entity` | {{count}} {{entity}} | Generic count with entity | + +#### Form Fields & Labels + +| Key | English | Context | +|-----|---------|---------| +| `type` | Type | Type field/column header | +| `name` | Name | Name field/column header | +| `key` | Key | Key field (for tags) | +| `value` | Value | Value field (for tags) | +| `time` | Time | Time column header | +| `actions` | Actions | Actions column header | +| `updated` | Updated | Last updated timestamp | +| `view_details` | View Details | View details link | +| `all_types` | All Types | "All types" filter option | +| `node_type` | Node Type | Node type field | +| `show` | Show | Show/display action | +| `search_placeholder` | Search by name, ID, or public key... | Search input placeholder | +| `contact` | Contact | Contact information field | +| `description` | Description | Description field | +| `callsign` | Callsign | Amateur radio callsign field | +| `tags` | Tags | Tags label/header | +| `last_seen` | Last Seen | Last seen timestamp (table header) | +| `first_seen_label` | First seen: | First seen label (inline with colon) | +| `last_seen_label` | Last seen: | Last seen label (inline with colon) | +| `location` | Location | Geographic location | +| `public_key` | Public Key | Node public key | +| `received` | Received | Received timestamp | +| `received_by` | Received By | Received by field | +| `receivers` | Receivers | Multiple receivers | +| `from` | From | Message sender | +| `unnamed` | Unnamed | Fallback for unnamed items | +| `unnamed_node` | Unnamed Node | Fallback for unnamed nodes | + +**Note:** Keys ending in `_label` have colons and are used inline. Keys without `_label` are for table headers. + +### 3. `links` + +Platform and external link labels: + +| Key | English | Context | +|-----|---------|---------| +| `website` | Website | Website link label | +| `github` | GitHub | GitHub link label (preserve capitalization) | +| `discord` | Discord | Discord link label | +| `youtube` | YouTube | YouTube link label (preserve capitalization) | +| `profile` | Profile | Radio profile label | + +### 4. `time` + +Time-related labels and formats: + +| Key | English | Context | +|-----|---------|---------| +| `days_ago` | {{count}}d ago | Days ago (abbreviated) | +| `hours_ago` | {{count}}h ago | Hours ago (abbreviated) | +| `minutes_ago` | {{count}}m ago | Minutes ago (abbreviated) | +| `less_than_minute` | <1m ago | Less than one minute ago | +| `last_7_days` | Last 7 days | Last 7 days label | +| `per_day_last_7_days` | Per day (last 7 days) | Per day over last 7 days | +| `over_time_last_7_days` | Over time (last 7 days) | Over time last 7 days | +| `activity_per_day_last_7_days` | Activity per day (last 7 days) | Activity chart label | + +### 5. `node_types` + +Mesh network node type labels: + +| Key | English | Context | +|-----|---------|---------| +| `chat` | Chat | Chat node type | +| `repeater` | Repeater | Repeater/relay node type | +| `room` | Room | Room/group node type | +| `unknown` | Unknown | Unknown node type fallback | + +### 6. `home` + +Homepage-specific content: + +| Key | English | Context | +|-----|---------|---------| +| `welcome_default` | Welcome to the {{network_name}} mesh network dashboard. Monitor network activity, view connected nodes, and explore message history. | Default welcome message | +| `all_discovered_nodes` | All discovered nodes | Stat description | +| `network_info` | Network Info | Network info card title | +| `network_activity` | Network Activity | Activity chart title | +| `meshcore_attribution` | Our local off-grid mesh network is made possible by | Attribution text before MeshCore logo | +| `frequency` | Frequency | Radio frequency label | +| `bandwidth` | Bandwidth | Radio bandwidth label | +| `spreading_factor` | Spreading Factor | LoRa spreading factor label | +| `coding_rate` | Coding Rate | LoRa coding rate label | +| `tx_power` | TX Power | Transmit power label | +| `advertisements` | Advertisements | Homepage stat label | +| `messages` | Messages | Homepage stat label | + +**Note:** MeshCore tagline "Connecting people and things, without using the internet" is hardcoded in English and should not be translated (trademark). + +### 7. `dashboard` + +Dashboard page content: + +| Key | English | Context | +|-----|---------|---------| +| `all_discovered_nodes` | All discovered nodes | Stat label | +| `recent_channel_messages` | Recent Channel Messages | Recent messages card title | +| `channel` | Channel {{number}} | Channel label with number | + +### 8. `nodes` + +Node-specific labels: + +| Key | English | Context | +|-----|---------|---------| +| `scan_to_add` | Scan to add as contact | QR code instruction | + +### 9. `advertisements` + +Currently empty - advertisements page uses common patterns. + +### 10. `messages` + +Message type labels: + +| Key | English | Context | +|-----|---------|---------| +| `type_direct` | Direct | Direct message type | +| `type_channel` | Channel | Channel message type | +| `type_contact` | Contact | Contact message type | +| `type_public` | Public | Public message type | + +### 11. `map` + +Map page content: + +| Key | English | Context | +|-----|---------|---------| +| `show_labels` | Show Labels | Toggle to show node labels | +| `infrastructure_only` | Infrastructure Only | Toggle to show only infrastructure nodes | +| `legend` | Legend: | Map legend header | +| `infrastructure` | Infrastructure | Infrastructure node category | +| `public` | Public | Public node category | +| `nodes_on_map` | {{count}} nodes on map | Status text with coordinates | +| `nodes_none_have_coordinates` | {{count}} nodes (none have coordinates) | Status text without coordinates | +| `gps_description` | Nodes are placed on the map based on GPS coordinates from node reports or manual tags. | Map data source explanation | +| `owner` | Owner: | Node owner label | +| `role` | Role: | Member role label | +| `select_destination_node` | -- Select destination node -- | Dropdown placeholder | + +### 12. `members` + +Members page content: + +| Key | English | Context | +|-----|---------|---------| +| `empty_state_description` | To display network members, create a members.yaml file in your seed directory. | Empty state instructions | +| `members_file_format` | Members File Format | Documentation section title | +| `members_file_description` | Create a YAML file at $SEED_HOME/members.yaml with the following structure: | File creation instructions | +| `members_import_instructions` | Run meshcore-hub collector seed to import members.
To associate nodes with members, add a member_id tag to nodes in node_tags.yaml. | Import instructions (HTML allowed) | + +### 13. `not_found` + +404 page content: + +| Key | English | Context | +|-----|---------|---------| +| `description` | The page you're looking for doesn't exist or has been moved. | 404 description | + +### 14. `custom_page` + +Custom markdown page errors: + +| Key | English | Context | +|-----|---------|---------| +| `failed_to_load` | Failed to load page | Page load error | + +### 15. `admin` + +Admin panel content: + +| Key | English | Context | +|-----|---------|---------| +| `access_denied` | Access Denied | Access denied heading | +| `admin_not_enabled` | The admin interface is not enabled. | Admin disabled message | +| `admin_enable_hint` | Set WEB_ADMIN_ENABLED=true to enable admin features. | Configuration hint (HTML allowed) | +| `auth_required` | Authentication Required | Auth required heading | +| `auth_required_description` | You must sign in to access the admin interface. | Auth required description | +| `welcome` | Welcome to the admin panel. | Admin welcome message | +| `members_description` | Manage network members and operators. | Members card description | +| `tags_description` | Manage custom tags and metadata for network nodes. | Tags card description | + +### 16. `admin_members` + +Admin members page: + +| Key | English | Context | +|-----|---------|---------| +| `network_members` | Network Members ({{count}}) | Page heading with count | +| `member_id` | Member ID | Member ID field label | +| `member_id_hint` | Unique identifier (letters, numbers, underscore) | Member ID input hint | +| `empty_state_hint` | Click "Add Member" to create the first member. | Empty state hint | + +**Note:** Confirmation and success messages use `common.*` patterns. + +### 17. `admin_node_tags` + +Admin node tags page: + +| Key | English | Context | +|-----|---------|---------| +| `select_node` | Select Node | Section heading | +| `select_node_placeholder` | -- Select a node -- | Dropdown placeholder | +| `load_tags` | Load Tags | Load button | +| `move_warning` | This will move the tag from the current node to the destination node. | Move operation warning | +| `copy_all` | Copy All | Copy all button | +| `copy_all_info` | Tags that already exist on the destination node will be skipped. Original tags remain on this node. | Copy operation info | +| `delete_all` | Delete All | Delete all button | +| `delete_all_warning` | All tags will be permanently deleted. | Delete all warning | +| `destination_node` | Destination Node | Destination node field | +| `tag_key` | Tag Key | Tag key field | +| `for_this_node` | for this node | Suffix for "No tags found for this node" | +| `empty_state_hint` | Add a new tag below. | Empty state hint | +| `select_a_node` | Select a Node | Empty state heading | +| `select_a_node_description` | Choose a node from the dropdown above to view and manage its tags. | Empty state description | +| `copied_entities` | Copied {{copied}} tag(s), skipped {{skipped}} | Copy operation result message | + +**Note:** Titles, confirmations, and success messages use `common.*` patterns. + +### 18. `footer` + +Footer content: + +| Key | English | Context | +|-----|---------|---------| +| `powered_by` | Powered by | "Powered by" attribution | + +## Translation Tips + +1. **Preserve HTML tags:** Some strings contain ``, ``, or `
` tags - keep these intact. + +2. **Preserve variables:** Keep `{{variable}}` placeholders exactly as-is, only translate surrounding text. + +3. **Entity composition:** Many translations reference `entities.*` keys. When translating entities, consider how they'll work in composite patterns (e.g., "Add {{entity}}" should make sense with "Node", "Tag", etc.). + +4. **Capitalization:** + - Entity names should follow your language's capitalization rules for UI elements + - Inline labels (with colons) typically use sentence case + - Table headers typically use title case + - Action buttons can vary by language convention + +5. **Colons:** Keys ending in `_label` include colons in English. Adjust punctuation to match your language's conventions for inline labels. + +6. **Plurals:** Some languages have complex plural rules. You may need to add plural variants for `{{count}}` patterns. Consult the i18n library documentation for plural support. + +7. **Length:** UI space is limited. Try to keep translations concise, especially for button labels and table headers. + +8. **Brand names:** Preserve "MeshCore", "GitHub", "YouTube" capitalization. + +## Testing Your Translation + +1. Create your translation file: `locales/xx.json` (where `xx` is your language code) +2. Copy the structure from `en.json` +3. Translate all values, preserving all variables and HTML +4. Test in the application by setting the language +5. Check all pages for: + - Text overflow/truncation + - Proper variable interpolation + - Natural phrasing in context + +## Getting Help + +If you're unsure about the context of a translation key, check: +1. The "Context" column in this reference +2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa/pages/` +3. Grep for the key: `grep -r "t('section.key')" src/` diff --git a/tests/test_common/test_i18n.py b/tests/test_common/test_i18n.py index bc95dc0..178ec0c 100644 --- a/tests/test_common/test_i18n.py +++ b/tests/test_common/test_i18n.py @@ -118,6 +118,15 @@ class TestEnJsonCompleteness: for section in required: assert section in data, f"Missing section: {section}" + def test_common_no_entity_patterns(self): + """Test that common 'no entity' patterns exist.""" + assert t("common.no_entity_found", entity="test") == "No test found" + assert t("common.no_entity_recorded", entity="test") == "No test recorded" + assert t("common.no_entity_defined", entity="test") == "No test defined" + assert t("common.no_entity_configured", entity="test") == "No test configured" + assert t("common.no_entity_yet", entity="test") == "No test yet" + assert t("common.page_not_found") == "Page not found" + def test_entity_keys(self): """Entity keys are all present.""" assert t("entities.home") != "entities.home" From 1f55d912ea3dcd758c941b821e57f739391ffc4f Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 13 Feb 2026 22:31:59 +0000 Subject: [PATCH 6/7] Fix translation key references across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes critical issue where translation keys were displaying as literal text instead of translations. Changes: - home.js: Fix stat headers (home.* → entities.*) - dashboard.js: Fix stat headers, chart labels, table columns - nodes.js: Fix table columns and filter labels (common.* → entities.*) - advertisements.js: Fix filter widgets and table headers - messages.js: Fix table column header - map.js: Fix filter label and dropdown - admin/node-tags.js: Fix node label reference All translation keys now correctly reference entities.* section. Used common.all_entity pattern instead of non-existent common.all_members. Co-Authored-By: Claude Sonnet 4.5 --- .../web/static/js/spa/pages/admin/node-tags.js | 2 +- .../web/static/js/spa/pages/advertisements.js | 8 ++++---- src/meshcore_hub/web/static/js/spa/pages/dashboard.js | 10 +++++----- src/meshcore_hub/web/static/js/spa/pages/home.js | 4 ++-- src/meshcore_hub/web/static/js/spa/pages/map.js | 4 ++-- src/meshcore_hub/web/static/js/spa/pages/messages.js | 2 +- src/meshcore_hub/web/static/js/spa/pages/nodes.js | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js index 469a2a6..c7db27a 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js +++ b/src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js @@ -321,7 +321,7 @@ ${flashHtml}

${t('admin_node_tags.select_node')}

- + @@ -70,10 +70,10 @@ ${content}`, container); ? html`
` @@ -187,7 +187,7 @@ ${content}`, container); - + diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index 78a6344..c7bb0fd 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -67,7 +67,7 @@ function renderRecentAds(ads) {
${t('common.node')}${t('entities.node')} ${t('common.time')} ${t('common.receivers')}
- + @@ -162,7 +162,7 @@ ${topCount > 0 ? html`
${iconAdvertisements('h-8 w-8')}
-
${t('dashboard.advertisements')}
+
${t('entities.advertisements')}
${stats.advertisements_7d}
${t('time.last_7_days')}
` : nothing} @@ -172,7 +172,7 @@ ${topCount > 0 ? html`
${iconMessages('h-8 w-8')}
-
${t('dashboard.messages')}
+
${t('entities.messages')}
${stats.messages_7d}
${t('time.last_7_days')}
` : nothing} @@ -198,7 +198,7 @@ ${topCount > 0 ? html`

${iconAdvertisements('h-5 w-5')} - ${t('dashboard.advertisements')} + ${t('entities.advertisements')}

${t('time.per_day_last_7_days')}

@@ -212,7 +212,7 @@ ${topCount > 0 ? html`

${iconMessages('h-5 w-5')} - ${t('dashboard.messages')} + ${t('entities.messages')}

${t('time.per_day_last_7_days')}

diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index f752e1e..9a1a2da 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -124,7 +124,7 @@ export async function render(container, params, router) {
${iconAdvertisements('h-8 w-8')}
-
${t('home.advertisements')}
+
${t('entities.advertisements')}
${stats.advertisements_7d}
${t('time.last_7_days')}
` : nothing} @@ -134,7 +134,7 @@ export async function render(container, params, router) {
${iconMessages('h-8 w-8')}
-
${t('home.messages')}
+
${t('entities.messages')}
${stats.messages_7d}
${t('time.last_7_days')}
` : nothing} diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index 966272b..b2cf5a1 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -206,10 +206,10 @@ export async function render(container, params, router) {
- + diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index 3c4598e..623ae0c 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -51,10 +51,10 @@ ${content}`, container); ? html`
` @@ -170,7 +170,7 @@ ${content}`, container);
${t('common.node')}${t('entities.node')} ${t('common.type')} ${t('common.received')}
${t('common.type')} ${t('common.time')} ${t('common.from')}${t('common.message')}${t('entities.message')} ${t('common.receivers')}
- + From 19bb06953ef6e2dbaaa099d9a69da132a4049dfe Mon Sep 17 00:00:00 2001 From: Louis King Date: Fri, 13 Feb 2026 22:36:17 +0000 Subject: [PATCH 7/7] Fix remaining translation key: common.all_nodes Replaced non-existent common.all_nodes key with common.all_entity pattern. - advertisements.js: Use common.all_entity with entities.nodes - map.js: Use common.all_entity with entities.nodes All translation keys now properly resolve across the entire dashboard. Co-Authored-By: Claude Sonnet 4.5 --- src/meshcore_hub/web/static/js/spa/pages/advertisements.js | 2 +- src/meshcore_hub/web/static/js/spa/pages/map.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 9b3d8fc..328dcc5 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -60,7 +60,7 @@ ${content}`, container); ${t('entities.node')} ` diff --git a/src/meshcore_hub/web/static/js/spa/pages/map.js b/src/meshcore_hub/web/static/js/spa/pages/map.js index b2cf5a1..b72ee52 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/map.js +++ b/src/meshcore_hub/web/static/js/spa/pages/map.js @@ -189,7 +189,7 @@ export async function render(container, params, router) { ${t('common.show')}
${t('common.node')}${t('entities.node')} ${t('common.last_seen')} ${t('common.tags')}