diff --git a/.env.example b/.env.example index 3127bfc..2fd3396 100644 --- a/.env.example +++ b/.env.example @@ -198,11 +198,24 @@ API_ADMIN_KEY= # External web port WEB_PORT=8080 +# API endpoint URL for the web dashboard +# Default: http://localhost:8000 +# API_BASE_URL=http://localhost:8000 + +# API key for web dashboard queries (optional) +# If API_READ_KEY is set on the API, provide it here +# API_KEY= + # Default theme for the web dashboard (dark or light) # Users can override via the theme toggle; their preference is saved in localStorage # Default: dark # WEB_THEME=dark +# Locale/language for the web dashboard +# Default: en +# Supported: en (see src/meshcore_hub/web/static/locales/ for available translations) +# WEB_LOCALE=en + # Enable admin interface at /a/ (requires auth proxy in front) # Default: false # WEB_ADMIN_ENABLED=false @@ -221,6 +234,9 @@ TZ=UTC # ------------------- # Displayed on the web dashboard homepage +# Network domain name (optional) +# NETWORK_DOMAIN= + # Network display name NETWORK_NAME=MeshCore Network diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml index 66e1668..af99a84 100644 --- a/.github/workflows/code-review.yml +++ b/.github/workflows/code-review.yml @@ -3,21 +3,9 @@ name: Claude Code Review on: pull_request: types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - runs-on: ubuntu-latest permissions: contents: read @@ -39,5 +27,3 @@ jobs: plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/AGENTS.md b/AGENTS.md index 7e57fd1..5d29768 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,13 +453,103 @@ The web dashboard is a Single Page Application. Pages are ES modules loaded by t - Use `pageColors` from `components.js` for section-specific colors (reads CSS custom properties from `app.css`) - Return a cleanup function if the page creates resources (e.g., Leaflet maps, Chart.js instances) +### Internationalization (i18n) + +The web dashboard supports internationalization via JSON translation files. The default language is English. + +**Translation files location:** `src/meshcore_hub/web/static/locales/` + +**Key files:** +- `en.json` - English translations (reference implementation) +- `languages.md` - Comprehensive translation reference guide for translators + +**Using translations in JavaScript:** + +Import the `t()` function from `components.js`: + +```javascript +import { t } from '../components.js'; + +// Simple translation +const label = t('common.save'); // "Save" + +// Translation with variable interpolation +const title = t('common.add_entity', { entity: t('entities.node') }); // "Add Node" + +// Composed patterns for consistency +const emptyMsg = t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() }); // "No nodes found" +``` + +**Translation architecture:** + +1. **Entity-based composition:** Core entity names (`entities.*`) are referenced by composite patterns for consistency +2. **Reusable patterns:** Common UI patterns (`common.*`) use `{{variable}}` interpolation for dynamic content +3. **Separation of concerns:** + - Keys without `_label` suffix = table headers (title case, no colon) + - Keys with `_label` suffix = inline labels (sentence case, with colon) + +**When adding/modifying translations:** + +1. **Add new keys** to `en.json` following existing patterns: + - Use composition when possible (reference `entities.*` in `common.*` patterns) + - Group related keys by section (e.g., `admin_members.*`, `admin_node_tags.*`) + - Use `{{variable}}` syntax for dynamic content + +2. **Update `languages.md`** with: + - Key name, English value, and usage context + - Variable descriptions if using interpolation + - Notes about HTML content or special formatting + +3. **Add tests** in `tests/test_common/test_i18n.py`: + - Test new interpolation patterns + - Test required sections if adding new top-level sections + - Test composed patterns with entity references + +4. **Run i18n tests:** + ```bash + pytest tests/test_common/test_i18n.py -v + ``` + +**Best practices:** + +- **Avoid duplication:** Use `common.*` patterns instead of duplicating similar strings +- **Compose with entities:** Reference `entities.*` keys in patterns rather than hardcoding entity names +- **Preserve variables:** Keep `{{variable}}` placeholders unchanged when translating +- **Test composition:** Verify patterns work with all entity types (singular/plural, lowercase/uppercase) +- **Document context:** Always update `languages.md` so translators understand usage + +**Example - adding a new entity and patterns:** + +```javascript +// 1. Add entity to en.json +"entities": { + "sensor": "Sensor" +} + +// 2. Use with existing common patterns +t('common.add_entity', { entity: t('entities.sensor') }) // "Add Sensor" +t('common.no_entity_found', { entity: t('entities.sensors').toLowerCase() }) // "No sensors found" + +// 3. Update languages.md with context +// 4. Add test to test_i18n.py +``` + +**Translation loading:** + +The i18n system (`src/meshcore_hub/common/i18n.py`) loads translations on startup: +- Defaults to English (`en`) +- Falls back to English for missing keys +- Returns the key itself if translation not found + +For full translation guidelines, see `src/meshcore_hub/web/static/locales/languages.md`. + ### Adding a New Database Model 1. Create model in `common/models/` 2. Export in `common/models/__init__.py` -3. Create Alembic migration: `alembic revision --autogenerate -m "description"` +3. Create Alembic migration: `meshcore-hub db revision --autogenerate -m "description"` 4. Review and adjust migration file -5. Test migration: `alembic upgrade head` +5. Test migration: `meshcore-hub db upgrade` ### Running the Development Environment @@ -481,7 +571,7 @@ pytest # Run specific component meshcore-hub api --reload meshcore-hub collector -meshcore-hub interface --mode receiver --mock +meshcore-hub interface receiver --mock ``` ## Environment Variables diff --git a/README.md b/README.md index db8c72f..bb53493 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,23 @@ Python 3.14+ platform for managing and orchestrating MeshCore mesh networks. ![MeshCore Hub Web Dashboard](docs/images/web.png) +## 🌍 Help Translate MeshCore Hub + +**We need volunteers to translate the web dashboard into other languages!** + +MeshCore Hub includes full internationalization (i18n) support with a composable translation system. We're looking for open source volunteers to contribute and maintain language packs for their native languages. + +**Current translations:** +- 🇬🇧 English (complete) + +**How to contribute:** +1. Check out the [Translation Reference Guide](src/meshcore_hub/web/static/locales/languages.md) for complete documentation +2. Copy `src/meshcore_hub/web/static/locales/en.json` to your language code (e.g., `es.json`, `fr.json`, `de.json`) +3. Translate all values while preserving variables (`{{variable}}`) and HTML tags +4. Submit a pull request with your translation + +Even partial translations are welcome! The system falls back to English for missing keys, so you can translate incrementally. + ## Overview MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. It consists of multiple components that work together: @@ -70,6 +87,7 @@ flowchart LR - **Command Dispatch**: Send messages and advertisements via the API - **Node Tagging**: Add custom metadata to nodes for organization - **Web Dashboard**: Visualize network status, node locations, and message history +- **Internationalization**: Full i18n support with composable translation patterns - **Docker Ready**: Single image with all components, easy deployment ## Getting Started @@ -248,7 +266,7 @@ pip install -e ".[dev]" meshcore-hub db upgrade # Start components (in separate terminals) -meshcore-hub interface --mode receiver --port /dev/ttyUSB0 +meshcore-hub interface receiver --port /dev/ttyUSB0 meshcore-hub collector meshcore-hub api meshcore-hub web @@ -339,9 +357,12 @@ The collector automatically cleans up old event data and inactive nodes: | `WEB_HOST` | `0.0.0.0` | Web server bind address | | `WEB_PORT` | `8080` | Web server port | | `API_BASE_URL` | `http://localhost:8000` | API endpoint URL | +| `API_KEY` | *(none)* | API key for web dashboard queries (optional) | | `WEB_THEME` | `dark` | Default theme (`dark` or `light`). Users can override via theme toggle in navbar. | +| `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) | | `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) | | `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) | +| `NETWORK_DOMAIN` | *(none)* | Network domain name (optional) | | `NETWORK_NAME` | `MeshCore Network` | Display name for the network | | `NETWORK_CITY` | *(none)* | City where network is located | | `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) | @@ -632,7 +653,9 @@ meshcore-hub/ │ ├── api/ # REST API │ └── web/ # Web dashboard │ ├── templates/ # Jinja2 templates (SPA shell) -│ └── static/js/spa/ # SPA frontend (ES modules, lit-html) +│ └── static/ +│ ├── js/spa/ # SPA frontend (ES modules, lit-html) +│ └── locales/ # Translation files (en.json, languages.md) ├── tests/ # Test suite ├── alembic/ # Database migrations ├── etc/ # Configuration files (mosquitto.conf) diff --git a/docker-compose.yml b/docker-compose.yml index c633a57..29e41cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -251,6 +251,8 @@ services: - API_KEY=${API_ADMIN_KEY:-${API_READ_KEY:-}} - WEB_HOST=0.0.0.0 - WEB_PORT=8080 + - WEB_THEME=${WEB_THEME:-dark} + - WEB_LOCALE=${WEB_LOCALE:-en} - WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false} - NETWORK_NAME=${NETWORK_NAME:-MeshCore Network} - NETWORK_CITY=${NETWORK_CITY:-} 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..d971e3a 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('entities.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('entities.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('common.total_entity', { entity: t('entities.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('entities.advertisements')) || 'Advertisements', ChartColors.adverts, ChartColors.advertsFill, true @@ -222,7 +222,7 @@ function initDashboardCharts(nodeData, advertData, messageData) { createLineChart( 'messageChart', messageData, - 'Messages', + (window.t && window.t('entities.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..0ccc89b 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')}
`; } }; @@ -124,33 +126,43 @@ function updateNavActiveState(pathname) { } } +/** + * Compose a page title from entity name and network name. + * @param {string} entityKey - Translation key for entity (e.g., 'entities.dashboard') + * @returns {string} + */ +function composePageTitle(entityKey) { + const networkName = config.network_name || 'MeshCore Network'; + const entity = t(entityKey); + return `${entity} - ${networkName}`; +} + /** * Update the page title based on the current route. * @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': composePageTitle('entities.admin'), + '/a/': composePageTitle('entities.admin'), + '/a/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`, + '/a/members': `${t('entities.members')} - ${t('entities.admin')} - ${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'] = composePageTitle('entities.dashboard'); + if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes'); + if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages'); + if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements'); + if (features.map !== false) titles['/map'] = composePageTitle('entities.map'); + if (features.members !== false) titles['/members'] = composePageTitle('entities.members'); if (titles[pathname]) { document.title = titles[pathname]; } else if (pathname.startsWith('/nodes/')) { - document.title = `Node Detail - ${networkName}`; + document.title = composePageTitle('entities.node_detail'); } else if (pathname.startsWith('/pages/')) { // Custom pages set their own title in the page module document.title = networkName; @@ -165,5 +177,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..65d8dbe --- /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('entities.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..9196572 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('entities.admin')}

- 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('entities.members')}

-

Manage network members and operators.

+

${t('admin.members_description')}

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

-

Manage custom tags and metadata for network nodes.

+

${t('admin.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..f03b42f 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('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}

+

${t('admin_members.empty_state_hint')}

`; litRender(html`
-

Members

+

${t('entities.members')}

- 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('common.entity_added_success', { entity: t('entities.member') }))); } 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('common.entity_updated_success', { entity: t('entities.member') }))); } catch (err) { container.querySelector('#editModal').close(); router.navigate('/a/members?error=' + encodeURIComponent(err.message)); @@ -301,7 +301,12 @@ ${flashHtml} btn.addEventListener('click', () => { const row = btn.closest('tr'); activeDeleteId = row.dataset.memberId; - container.querySelector('#delete_member_name').textContent = row.dataset.memberName; + const memberName = row.dataset.memberName; + const confirmMsg = t('common.delete_entity_confirm', { + entity: t('entities.member').toLowerCase(), + name: memberName + }); + container.querySelector('#delete_confirm_message').innerHTML = confirmMsg; container.querySelector('#deleteModal').showModal(); }); }); @@ -314,7 +319,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('common.entity_deleted_success', { entity: t('entities.member') }))); } catch (err) { container.querySelector('#deleteModal').close(); router.navigate('/a/members?error=' + encodeURIComponent(err.message)); @@ -322,6 +327,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..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 @@ -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('common.no_entity_found', { entity: t('entities.tags').toLowerCase() }) + ' ' + t('admin_node_tags.for_this_node')}

+

${t('admin_node_tags.empty_state_hint')}

`; const bulkButtons = tags.length > 0 ? html` - - ` + + ` : nothing; contentHtml = html` @@ -116,7 +116,7 @@ export async function render(container, params, router) {
${bulkButtons} - View Node + ${t('common.view_entity', { entity: t('entities.node') })}
@@ -124,25 +124,25 @@ export async function render(container, params, router) {
-

Tags (${tags.length})

+

${t('entities.tags')} (${tags.length})

${tagsTableHtml}
-

Add New Tag

+

${t('common.add_new_entity', { entity: t('entities.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('entities.tags')}

- 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('common.entity_added_success', { entity: t('entities.tag') }))); } 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('common.entity_updated_success', { entity: t('entities.tag') }))); } 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('common.entity_moved_success', { entity: t('entities.tag') }))); } catch (err) { container.querySelector('#moveModal').close(); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); @@ -447,7 +447,11 @@ ${contentHtml}`, container); btn.addEventListener('click', () => { const row = btn.closest('tr'); activeTagKey = row.dataset.tagKey; - container.querySelector('#deleteKeyDisplay').textContent = activeTagKey; + const confirmMsg = t('common.delete_entity_confirm', { + entity: t('entities.tag').toLowerCase(), + name: `"${activeTagKey}"` + }); + container.querySelector('#delete_tag_confirm_message').innerHTML = confirmMsg; container.querySelector('#deleteModal').showModal(); }); }); @@ -460,7 +464,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('common.entity_deleted_success', { entity: t('entities.tag') }))); } catch (err) { container.querySelector('#deleteModal').close(); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); @@ -487,7 +491,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_entities', { 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 +515,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('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() }))); } catch (err) { container.querySelector('#deleteAllModal').close(); router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message)); @@ -521,6 +525,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..328dcc5 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('entities.advertisements')}

${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('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
` : 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('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
- ${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..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 @@ -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('common.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..c7bb0fd 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('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}

`; } const rows = ads.slice(0, 5).map(ad => { const friendlyName = ad.tag_name || ad.name; @@ -67,9 +67,9 @@ function renderRecentAds(ads) {
NodeTimeReceivers${t('entities.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('entities.dashboard')}

${topCount > 0 ? html` @@ -152,9 +152,9 @@ ${topCount > 0 ? html`
${iconNodes('h-8 w-8')}
-
Total Nodes
+
${t('common.total_entity', { entity: t('entities.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('entities.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('entities.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('common.total_entity', { entity: t('entities.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('entities.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('entities.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('common.recent_entity', { entity: t('entities.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..9a1a2da 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('links.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('entities.dashboard')} ` : nothing} ${features.nodes !== false ? html` ${iconNodes('h-5 w-5 mr-2')} - Nodes + ${t('entities.nodes')} ` : nothing} ${features.advertisements !== false ? html` ${iconAdvertisements('h-5 w-5 mr-2')} - Adverts + ${t('entities.advertisements')} ` : nothing} ${features.messages !== false ? html` ${iconMessages('h-5 w-5 mr-2')} - Messages + ${t('entities.messages')} ` : nothing} ${features.map !== false ? html` ${iconMap('h-5 w-5 mr-2')} - Map + ${t('entities.map')} ` : nothing} ${customPageButtons}
@@ -115,9 +114,9 @@ export async function render(container, params, router) {
${iconNodes('h-8 w-8')}
-
Total Nodes
+
${t('common.total_entity', { entity: t('entities.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('entities.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('entities.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,7 +156,7 @@ export async function render(container, params, router) {
-

Our local off-grid mesh network is made possible by

+

${t('home.meshcore_attribution')}

@@ -165,11 +164,11 @@ export async function render(container, params, router) {
@@ -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..b72ee52 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('entities.map')}

${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('common.no_entity_in_database', { entity: t('entities.nodes').toLowerCase() }); 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..c8adde0 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('entities.members')}

+ ${t('common.count_entity', { count: 0, entity: t('entities.members').toLowerCase() })}
${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('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}

+

${t('members.empty_state_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('entities.members')}

+ ${t('common.count_entity', { count: members.length, entity: t('entities.members').toLowerCase() })}
@@ -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..612a744 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('entities.messages')}

${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('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}'; - 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('entities.node')}${t('common.type')}${t('common.received')}
No messages found.
${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}
- - - - - + + + + + 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..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 @@ -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('entities.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('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}

`; 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('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}

`; 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_label')} ${formatDateTime(node.first_seen)}
+
${t('common.last_seen_label')} ${formatDateTime(node.last_seen)}
${coordsHtml}
@@ -164,14 +164,14 @@ ${heroHtml}
-

Recent Advertisements

+

${t('common.recent_entity', { entity: t('entities.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('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}
-Back to Nodes`; +${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 1cdc936..623ae0c 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('entities.nodes')}

${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('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}
` : 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('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}
- ${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..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 @@ -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('common.page_not_found')}

- 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..0b1ec39 --- /dev/null +++ b/src/meshcore_hub/web/static/locales/en.json @@ -0,0 +1,212 @@ +{ + "entities": { + "home": "Home", + "dashboard": "Dashboard", + "nodes": "Nodes", + "node": "Node", + "node_detail": "Node Detail", + "advertisements": "Advertisements", + "advertisement": "Advertisement", + "messages": "Messages", + "message": "Message", + "map": "Map", + "members": "Members", + "member": "Member", + "admin": "Admin", + "tags": "Tags", + "tag": "Tag" + }, + "common": { + "filter": "Filter", + "clear": "Clear", + "clear_filters": "Clear Filters", + "search": "Search", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "move": "Move", + "save": "Save", + "save_changes": "Save Changes", + "add": "Add", + "add_entity": "Add {{entity}}", + "add_new_entity": "Add New {{entity}}", + "edit_entity": "Edit {{entity}}", + "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", + "loading": "Loading...", + "error": "Error", + "failed_to_load_page": "Failed to load page", + "total": "{{count}} total", + "shown": "{{count}} shown", + "count_entity": "{{count}} {{entity}}", + "type": "Type", + "name": "Name", + "key": "Key", + "value": "Value", + "time": "Time", + "actions": "Actions", + "updated": "Updated", + "sign_in": "Sign In", + "sign_out": "Sign Out", + "view_details": "View Details", + "all_types": "All Types", + "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_label": "First seen:", + "last_seen_label": "Last seen:", + "location": "Location", + "public_key": "Public Key", + "received": "Received", + "received_by": "Received By", + "receivers": "Receivers", + "from": "From", + "close": "close", + "unnamed": "Unnamed", + "unnamed_node": "Unnamed Node" + }, + "links": { + "website": "Website", + "github": "GitHub", + "discord": "Discord", + "youtube": "YouTube", + "profile": "Profile" + }, + "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.", + "all_discovered_nodes": "All discovered nodes", + "network_info": "Network Info", + "network_activity": "Network Activity", + "meshcore_attribution": "Our local off-grid mesh network is made possible by", + "frequency": "Frequency", + "bandwidth": "Bandwidth", + "spreading_factor": "Spreading Factor", + "coding_rate": "Coding Rate", + "tx_power": "TX Power" + }, + "dashboard": { + "all_discovered_nodes": "All discovered nodes", + "recent_channel_messages": "Recent Channel Messages", + "channel": "Channel {{number}}" + }, + "nodes": { + "scan_to_add": "Scan to add as contact" + }, + "advertisements": {}, + "messages": { + "type_direct": "Direct", + "type_channel": "Channel", + "type_contact": "Contact", + "type_public": "Public" + }, + "map": { + "show_labels": "Show Labels", + "infrastructure_only": "Infrastructure Only", + "legend": "Legend:", + "infrastructure": "Infrastructure", + "public": "Public", + "nodes_on_map": "{{count}} nodes on map", + "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": { + "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": { + "description": "The page you're looking for doesn't exist or has been moved." + }, + "custom_page": { + "failed_to_load": "Failed to load page" + }, + "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_description": "Manage network members and operators.", + "tags_description": "Manage custom tags and metadata for network nodes." + }, + "admin_members": { + "network_members": "Network Members ({{count}})", + "member_id": "Member ID", + "member_id_hint": "Unique identifier (letters, numbers, underscore)", + "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_warning": "This will move the tag from the current node to the destination node.", + "copy_all": "Copy All", + "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_warning": "All tags will be permanently deleted.", + "destination_node": "Destination Node", + "tag_key": "Tag Key", + "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.", + "copied_entities": "Copied {{copied}} tag(s), skipped {{skipped}}" + }, + "footer": { + "powered_by": "Powered by" + } +} 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/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index ad5a1a6..0ecad3c 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -60,24 +60,24 @@
NodeLast SeenTags${t('entities.node')}${t('common.last_seen')}${t('common.tags')}