Merge pull request #112 from ipnet-mesh/claude/add-i18n-support-1duUx

Add i18n support for web dashboard
This commit is contained in:
JingleManSweep
2026-02-13 22:38:49 +00:00
committed by GitHub
29 changed files with 1505 additions and 420 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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:-}

View File

@@ -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,

View File

@@ -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 ``<locale>.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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = `
<div class="flex flex-col items-center justify-center py-20">
<h1 class="text-4xl font-bold mb-4">Error</h1>
<p class="text-lg opacity-70 mb-6">Failed to load page</p>
<h1 class="text-4xl font-bold mb-4">${t('common.error')}</h1>
<p class="text-lg opacity-70 mb-6">${t('common.failed_to_load_page')}</p>
<p class="text-sm opacity-50 mb-6">${e.message || 'Unknown error'}</p>
<a href="/" class="btn btn-primary">Go Home</a>
<a href="/" class="btn btn-primary">${t('common.go_home')}</a>
</div>`;
}
};
@@ -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();

View File

@@ -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`<div class="flex justify-center mt-6"><div class="join">
${page > 1
? html`<a href=${pageUrl(page - 1)} class="join-item btn btn-sm">Previous</a>`
: html`<button class="join-item btn btn-sm btn-disabled" disabled>Previous</button>`}
? html`<a href=${pageUrl(page - 1)} class="join-item btn btn-sm">${t('common.previous')}</a>`
: html`<button class="join-item btn btn-sm btn-disabled" disabled>${t('common.previous')}</button>`}
${pageNumbers}
${page < totalPages
? html`<a href=${pageUrl(page + 1)} class="join-item btn btn-sm">Next</a>`
: html`<button class="join-item btn btn-sm btn-disabled" disabled>Next</button>`}
? html`<a href=${pageUrl(page + 1)} class="join-item btn btn-sm">${t('common.next')}</a>`
: html`<button class="join-item btn btn-sm btn-disabled" disabled>${t('common.next')}</button>`}
</div></div>`;
}

View File

@@ -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;

View File

@@ -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`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
<p class="opacity-70">The admin interface is not enabled.</p>
<p class="text-sm opacity-50 mt-2">Set <code>WEB_ADMIN_ENABLED=true</code> to enable admin features.</p>
<a href="/" class="btn btn-primary mt-6">Go Home</a>
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<p class="text-sm opacity-50 mt-2">${unsafeHTML(t('admin.admin_enable_hint'))}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
</div>`, container);
return;
}
@@ -21,9 +21,9 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
<p class="opacity-70">You must sign in to access the admin interface.</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
<h1 class="text-3xl font-bold mb-2">${t('admin.auth_required')}</h1>
<p class="opacity-70">${t('admin.auth_required_description')}</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.sign_in')}</a>
</div>`, container);
return;
}
@@ -31,20 +31,20 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-3xl font-bold">Admin</h1>
<h1 class="text-3xl font-bold">${t('entities.admin')}</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li>Admin</li>
<li><a href="/">${t('entities.home')}</a></li>
<li>${t('entities.admin')}</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">Sign Out</a>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm opacity-70 mb-6">
<span class="flex items-center gap-1.5">
Welcome to the admin panel.
${t('admin.welcome')}
</span>
</div>
@@ -53,23 +53,23 @@ export async function render(container, params, router) {
<div class="card-body">
<h2 class="card-title">
${iconUsers('h-6 w-6')}
Members
${t('entities.members')}
</h2>
<p>Manage network members and operators.</p>
<p>${t('admin.members_description')}</p>
</div>
</a>
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconTag('h-6 w-6')}
Node Tags
${t('entities.tags')}
</h2>
<p>Manage custom tags and metadata for network nodes.</p>
<p>${t('admin.tags_description')}</p>
</div>
</a>
</div>`, container);
} catch (e) {
litRender(errorAlert(e.message || 'Failed to load admin page'), container);
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -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`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
<p class="opacity-70">The admin interface is not enabled.</p>
<a href="/" class="btn btn-primary mt-6">Go Home</a>
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
</div>`, container);
return;
}
@@ -24,9 +24,9 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
<p class="opacity-70">You must sign in to access the admin interface.</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
<h1 class="text-3xl font-bold mb-2">${t('admin.auth_required')}</h1>
<p class="opacity-70">${t('admin.auth_required_description')}</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.sign_in')}</a>
</div>`, container);
return;
}
@@ -45,11 +45,11 @@ export async function render(container, params, router) {
<table class="table table-zebra">
<thead>
<tr>
<th>Member ID</th>
<th>Name</th>
<th>Callsign</th>
<th>Contact</th>
<th class="w-32">Actions</th>
<th>${t('admin_members.member_id')}</th>
<th>${t('common.name')}</th>
<th>${t('common.callsign')}</th>
<th>${t('common.contact')}</th>
<th class="w-32">${t('common.actions')}</th>
</tr>
</thead>
<tbody>${members.map(m => html`
@@ -69,8 +69,8 @@ export async function render(container, params, router) {
<td class="max-w-xs truncate" title=${m.contact || ''}>${m.contact || '-'}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs btn-edit">Edit</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">Delete</button>
<button class="btn btn-ghost btn-xs btn-edit">${t('common.edit')}</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
</div>
</td>
</tr>`)}</tbody>
@@ -78,23 +78,23 @@ export async function render(container, params, router) {
</div>`
: html`
<div class="text-center py-8 text-base-content/60">
<p>No members configured yet.</p>
<p class="text-sm mt-2">Click "Add Member" to create the first member.</p>
<p>${t('common.no_entity_yet', { entity: t('entities.members').toLowerCase() })}</p>
<p class="text-sm mt-2">${t('admin_members.empty_state_hint')}</p>
</div>`;
litRender(html`
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">Members</h1>
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a/">Admin</a></li>
<li>Members</li>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li>${t('entities.members')}</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">Sign Out</a>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
</div>
${flashHtml}
@@ -102,8 +102,8 @@ ${flashHtml}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex justify-between items-center">
<h2 class="card-title">Network Members (${members.length})</h2>
<button id="btn-add-member" class="btn btn-primary btn-sm">Add Member</button>
<h2 class="card-title">${t('admin_members.network_members', { count: members.length })}</h2>
<button id="btn-add-member" class="btn btn-primary btn-sm">${t('common.add_entity', { entity: t('entities.member') })}</button>
</div>
${tableHtml}
</div>
@@ -111,62 +111,62 @@ ${flashHtml}
<dialog id="addModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">Add New Member</h3>
<h3 class="font-bold text-lg">${t('common.add_new_entity', { entity: t('entities.member') })}</h3>
<form id="add-member-form" class="py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Member ID <span class="text-error">*</span></span>
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" class="input input-bordered"
placeholder="walshie86" required maxlength="50"
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only">
<label class="label">
<span class="label-text-alt">Unique identifier (letters, numbers, underscore)</span>
<span class="label-text-alt">${t('admin_members.member_id_hint')}</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Name <span class="text-error">*</span></span>
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
</label>
<input type="text" name="name" class="input input-bordered"
placeholder="John Smith" required maxlength="255">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Callsign</span></label>
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
<input type="text" name="callsign" class="input input-bordered"
placeholder="VK4ABC" maxlength="20">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Contact</span></label>
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
<input type="text" name="contact" class="input input-bordered"
placeholder="john@example.com or phone number" maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">Description</span></label>
<label class="label"><span class="label-text">${t('common.description')}</span></label>
<textarea name="description" rows="3" class="textarea textarea-bordered"
placeholder="Brief description of member's role and responsibilities..."></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" id="addCancel">Cancel</button>
<button type="submit" class="btn btn-primary">Add Member</button>
<button type="button" class="btn" id="addCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.member') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="editModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg">Edit Member</h3>
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.member') })}</h3>
<form id="edit-member-form" class="py-4">
<input type="hidden" name="id" id="edit_id">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Member ID <span class="text-error">*</span></span>
<span class="label-text">${t('admin_members.member_id')} <span class="text-error">*</span></span>
</label>
<input type="text" name="member_id" id="edit_member_id" class="input input-bordered"
required maxlength="50" pattern="[a-zA-Z0-9_]+"
@@ -174,52 +174,52 @@ ${flashHtml}
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Name <span class="text-error">*</span></span>
<span class="label-text">${t('common.name')} <span class="text-error">*</span></span>
</label>
<input type="text" name="name" id="edit_name" class="input input-bordered"
required maxlength="255">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Callsign</span></label>
<label class="label"><span class="label-text">${t('common.callsign')}</span></label>
<input type="text" name="callsign" id="edit_callsign" class="input input-bordered"
maxlength="20">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Contact</span></label>
<label class="label"><span class="label-text">${t('common.contact')}</span></label>
<input type="text" name="contact" id="edit_contact" class="input input-bordered"
maxlength="255">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">Description</span></label>
<label class="label"><span class="label-text">${t('common.description')}</span></label>
<textarea name="description" id="edit_description" rows="3"
class="textarea textarea-bordered"></textarea>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" id="editCancel">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete Member</h3>
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.member') })}</h3>
<div class="py-4">
<p class="py-4">Are you sure you want to delete member <strong id="delete_member_name"></strong>?</p>
<p class="py-4" id="delete_confirm_message"></p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>This action cannot be undone.</span>
<span>${t('common.cannot_be_undone')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteCancel">Cancel</button>
<button type="button" class="btn btn-error" id="deleteConfirm">Delete</button>
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`, 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);
}
}

View File

@@ -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`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
<p class="opacity-70">The admin interface is not enabled.</p>
<a href="/" class="btn btn-primary mt-6">Go Home</a>
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
</div>`, container);
return;
}
@@ -25,9 +25,9 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">Authentication Required</h1>
<p class="opacity-70">You must sign in to access the admin interface.</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">Sign In</a>
<h1 class="text-3xl font-bold mb-2">${t('admin.auth_required')}</h1>
<p class="opacity-70">${t('admin.auth_required_description')}</p>
<a href="/oauth2/start?rd=${encodeURIComponent(window.location.pathname)}" class="btn btn-primary mt-6">${t('common.sign_in')}</a>
</div>`, 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) {
<table class="table table-zebra">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
<th>Updated</th>
<th class="w-48">Actions</th>
<th>${t('common.key')}</th>
<th>${t('common.value')}</th>
<th>${t('common.type')}</th>
<th>${t('common.updated')}</th>
<th class="w-48">${t('common.actions')}</th>
</tr>
</thead>
<tbody>${tags.map(tag => html`
@@ -83,9 +83,9 @@ export async function render(container, params, router) {
<td class="text-sm opacity-70">${formatDateTimeShort(tag.updated_at)}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs btn-edit">Edit</button>
<button class="btn btn-ghost btn-xs btn-move">Move</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">Delete</button>
<button class="btn btn-ghost btn-xs btn-edit">${t('common.edit')}</button>
<button class="btn btn-ghost btn-xs btn-move">${t('common.move')}</button>
<button class="btn btn-ghost btn-xs text-error btn-delete">${t('common.delete')}</button>
</div>
</td>
</tr>`)}</tbody>
@@ -93,14 +93,14 @@ export async function render(container, params, router) {
</div>`
: html`
<div class="text-center py-8 text-base-content/60">
<p>No tags found for this node.</p>
<p class="text-sm mt-2">Add a new tag below.</p>
<p>${t('common.no_entity_found', { entity: t('entities.tags').toLowerCase() }) + ' ' + t('admin_node_tags.for_this_node')}</p>
<p class="text-sm mt-2">${t('admin_node_tags.empty_state_hint')}</p>
</div>`;
const bulkButtons = tags.length > 0
? html`
<button id="btn-copy-all" class="btn btn-outline btn-sm">Copy All</button>
<button id="btn-delete-all" class="btn btn-outline btn-error btn-sm">Delete All</button>`
<button id="btn-copy-all" class="btn btn-outline btn-sm">${t('admin_node_tags.copy_all')}</button>
<button id="btn-delete-all" class="btn btn-outline btn-error btn-sm">${t('admin_node_tags.delete_all')}</button>`
: nothing;
contentHtml = html`
@@ -116,7 +116,7 @@ export async function render(container, params, router) {
</div>
<div class="flex gap-2">
${bulkButtons}
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">View Node</a>
<a href="/nodes/${encodeURIComponent(selectedPublicKey)}" class="btn btn-ghost btn-sm">${t('common.view_entity', { entity: t('entities.node') })}</a>
</div>
</div>
</div>
@@ -124,25 +124,25 @@ export async function render(container, params, router) {
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Tags (${tags.length})</h2>
<h2 class="card-title">${t('entities.tags')} (${tags.length})</h2>
${tagsTableHtml}
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Add New Tag</h2>
<h2 class="card-title">${t('common.add_new_entity', { entity: t('entities.tag') })}</h2>
<form id="add-tag-form" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Key</span></label>
<label class="label"><span class="label-text">${t('common.key')}</span></label>
<input type="text" name="key" class="input input-bordered" placeholder="tag_name" required maxlength="100">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Value</span></label>
<label class="label"><span class="label-text">${t('common.value')}</span></label>
<input type="text" name="value" class="input input-bordered" placeholder="tag value">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Type</span></label>
<label class="label"><span class="label-text">${t('common.type')}</span></label>
<select name="value_type" class="select select-bordered">
<option value="string">string</option>
<option value="number">number</option>
@@ -151,7 +151,7 @@ export async function render(container, params, router) {
</div>
<div class="form-control">
<label class="label"><span class="label-text">&nbsp;</span></label>
<button type="submit" class="btn btn-primary">Add Tag</button>
<button type="submit" class="btn btn-primary">${t('common.add_entity', { entity: t('entities.tag') })}</button>
</div>
</form>
</div>
@@ -159,18 +159,18 @@ export async function render(container, params, router) {
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Edit Tag</h3>
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.tag') })}</h3>
<form id="edit-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">Key</span></label>
<label class="label"><span class="label-text">${t('common.key')}</span></label>
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">Value</span></label>
<label class="label"><span class="label-text">${t('common.value')}</span></label>
<input type="text" id="editValue" class="input input-bordered">
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">Type</span></label>
<label class="label"><span class="label-text">${t('common.type')}</span></label>
<select id="editValueType" class="select select-bordered w-full">
<option value="string">string</option>
<option value="number">number</option>
@@ -178,28 +178,28 @@ export async function render(container, params, router) {
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" id="editCancel">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="moveModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Move Tag to Another Node</h3>
<h3 class="font-bold text-lg">${t('common.move_entity_to_another_node', { entity: t('entities.tag') })}</h3>
<form id="move-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">Tag Key</span></label>
<label class="label"><span class="label-text">${t('admin_node_tags.tag_key')}</span></label>
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">Destination Node</span></label>
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="moveDestination" class="select select-bordered w-full" required>
<option value="">-- Select destination node --</option>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || 'Unnamed';
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
@@ -207,46 +207,46 @@ export async function render(container, params, router) {
</div>
<div class="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>This will move the tag from the current node to the destination node.</span>
<span>${t('admin_node_tags.move_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="moveCancel">Cancel</button>
<button type="submit" class="btn btn-warning">Move Tag</button>
<button type="button" class="btn" id="moveCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-warning">${t('common.move_entity', { entity: t('entities.tag') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete Tag</h3>
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.tag') })}</h3>
<div class="py-4">
<p class="py-4">Are you sure you want to delete the tag "<span id="deleteKeyDisplay" class="font-mono font-semibold"></span>"?</p>
<p class="py-4" id="delete_tag_confirm_message"></p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>This action cannot be undone.</span>
<span>${t('common.cannot_be_undone')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteCancel">Cancel</button>
<button type="button" class="btn btn-error" id="deleteConfirm">Delete</button>
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="copyAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Copy All Tags to Another Node</h3>
<h3 class="font-bold text-lg">${t('common.copy_all_entity_to_another_node', { entity: t('entities.tags') })}</h3>
<form id="copy-all-form" class="py-4">
<p class="mb-4">Copy all ${tags.length} tag(s) from <strong>${nodeName}</strong> to another node.</p>
<p class="mb-4">${unsafeHTML(t('common.copy_all_entity_description', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</p>
<div class="form-control mb-4">
<label class="label"><span class="label-text">Destination Node</span></label>
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="copyAllDestination" class="select select-bordered w-full" required>
<option value="">-- Select destination node --</option>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || 'Unnamed';
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
@@ -254,33 +254,33 @@ export async function render(container, params, router) {
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
<span>${t('admin_node_tags.copy_all_info')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="copyAllCancel">Cancel</button>
<button type="submit" class="btn btn-primary">Copy Tags</button>
<button type="button" class="btn" id="copyAllCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.copy_entity', { entity: t('entities.tags') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete All Tags</h3>
<h3 class="font-bold text-lg">${t('common.delete_all_entity', { entity: t('entities.tags') })}</h3>
<div class="py-4">
<p class="mb-4">Are you sure you want to delete all ${tags.length} tag(s) from <strong>${nodeName}</strong>?</p>
<p class="mb-4">${unsafeHTML(t('common.delete_all_entity_confirm', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: nodeName }))}</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>This action cannot be undone. All tags will be permanently deleted.</span>
<span>${t('admin_node_tags.delete_all_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteAllCancel">Cancel</button>
<button type="button" class="btn btn-error" id="deleteAllConfirm">Delete All Tags</button>
<button type="button" class="btn" id="deleteAllCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteAllConfirm">${t('common.delete_all_entity', { entity: t('entities.tags') })}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
} else if (selectedPublicKey && !selectedNode) {
contentHtml = html`
@@ -293,8 +293,8 @@ export async function render(container, params, router) {
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-12">
${iconTag('h-16 w-16 mx-auto mb-4 opacity-30')}
<h2 class="text-xl font-semibold mb-2">Select a Node</h2>
<p class="opacity-70">Choose a node from the dropdown above to view and manage its tags.</p>
<h2 class="text-xl font-semibold mb-2">${t('admin_node_tags.select_a_node')}</h2>
<p class="opacity-70">${t('admin_node_tags.select_a_node_description')}</p>
</div>
</div>`;
}
@@ -302,36 +302,36 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">Node Tags</h1>
<h1 class="text-3xl font-bold">${t('entities.tags')}</h1>
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/a/">Admin</a></li>
<li>Node Tags</li>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li>${t('entities.tags')}</li>
</ul>
</div>
</div>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">Sign Out</a>
<a href="/oauth2/sign_out" target="_blank" class="btn btn-outline btn-sm">${t('common.sign_out')}</a>
</div>
${flashHtml}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Select Node</h2>
<h2 class="card-title">${t('admin_node_tags.select_node')}</h2>
<div class="flex gap-4 items-end">
<div class="form-control flex-1">
<label class="label"><span class="label-text">Node</span></label>
<label class="label"><span class="label-text">${t('entities.node')}</span></label>
<select id="node-selector" class="select select-bordered w-full">
<option value="">-- Select a node --</option>
<option value="">${t('admin_node_tags.select_node_placeholder')}</option>
${allNodes.map(n => {
const name = n.name || 'Unnamed';
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key} ?selected=${n.public_key === selectedPublicKey}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<button id="load-tags-btn" class="btn btn-primary">Load Tags</button>
<button id="load-tags-btn" class="btn btn-primary">${t('admin_node_tags.load_tags')}</button>
</div>
</div>
</div>
@@ -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: `"<span class="font-mono font-semibold">${activeTagKey}</span>"`
});
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);
}
}

View File

@@ -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`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Advertisements</h1>
<h1 class="text-3xl font-bold">${t('entities.advertisements')}</h1>
<div class="flex items-center gap-2">
${tzBadge}
${total !== null ? html`<span class="badge badge-lg">${total} total</span>` : nothing}
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
</div>
</div>
${content}`, container);
@@ -57,10 +57,10 @@ ${content}`, container);
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">Node</span>
<span class="label-text">${t('entities.node')}</span>
</label>
<select name="public_key" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">All Nodes</option>
<option value="">${t('common.all_entity', { entity: t('entities.nodes') })}</option>
${sortedNodes.map(n => html`<option value=${n.public_key} ?selected=${public_key === n.public_key}>${n._displayName}</option>`)}
</select>
</div>`
@@ -70,17 +70,17 @@ ${content}`, container);
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
<span class="label-text">${t('entities.member')}</span>
</label>
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">All Members</option>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
</select>
</div>`
: nothing;
const mobileCards = advertisements.length === 0
? html`<div class="text-center py-8 opacity-70">No advertisements found.</div>`
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
: 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);
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title=${ad.adv_type || 'Unknown'}>${emoji}</span>
<span class="text-lg flex-shrink-0" title=${ad.adv_type || t('node_types.unknown')}>${emoji}</span>
<div class="min-w-0">
${nameBlock}
</div>
@@ -119,7 +119,7 @@ ${content}`, container);
});
const tableRows = advertisements.length === 0
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">No advertisements found.</td></tr>`
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</td></tr>`
: 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`<tr class="hover">
<td>
<a href="/nodes/${ad.public_key}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title=${ad.adv_type || 'Unknown'}>${emoji}</span>
<span class="text-lg" title=${ad.adv_type || t('node_types.unknown')}>${emoji}</span>
<div>
${nameBlock}
</div>
@@ -165,15 +165,15 @@ ${content}`, container);
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/advertisements', navigate)}>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Search</span>
<span class="label-text">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>
${nodesFilter}
${membersFilter}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
@@ -187,9 +187,9 @@ ${content}`, container);
<table class="table table-zebra">
<thead>
<tr>
<th>Node</th>
<th>Time</th>
<th>Receivers</th>
<th>${t('entities.node')}</th>
<th>${t('common.time')}</th>
<th>${t('common.receivers')}</th>
</tr>
</thead>
<tbody>

View File

@@ -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);
}
}
}

View File

@@ -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`<p class="text-sm opacity-70">No advertisements recorded yet.</p>`;
return html`<p class="text-sm opacity-70">${t('common.no_entity_yet', { entity: t('entities.advertisements').toLowerCase() })}</p>`;
}
const rows = ads.slice(0, 5).map(ad => {
const friendlyName = ad.tag_name || ad.name;
@@ -67,9 +67,9 @@ function renderRecentAds(ads) {
<table class="table table-compact w-full">
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th class="text-right">Received</th>
<th>${t('entities.node')}</th>
<th>${t('common.type')}</th>
<th class="text-right">${t('common.received')}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
@@ -90,7 +90,7 @@ function renderChannelMessages(channelMessages) {
return html`<div>
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
<span class="badge badge-info badge-sm">CH${String(channel)}</span>
Channel ${String(channel)}
${t('dashboard.channel', { number: String(channel) })}
</h3>
<div class="space-y-1 pl-2 border-l-2 border-base-300">
${msgLines}
@@ -102,7 +102,7 @@ function renderChannelMessages(channelMessages) {
<div class="card-body">
<h2 class="card-title">
${iconChannel('h-6 w-6')}
Recent Channel Messages
${t('dashboard.recent_channel_messages')}
</h2>
<div class="space-y-4">
${channels}
@@ -142,7 +142,7 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Dashboard</h1>
<h1 class="text-3xl font-bold">${t('entities.dashboard')}</h1>
</div>
${topCount > 0 ? html`
@@ -152,9 +152,9 @@ ${topCount > 0 ? html`
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-title">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">All discovered nodes</div>
<div class="stat-desc">${t('dashboard.all_discovered_nodes')}</div>
</div>` : nothing}
${showAdverts ? html`
@@ -162,9 +162,9 @@ ${topCount > 0 ? html`
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-title">${t('entities.advertisements')}</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">Last 7 days</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
${showMessages ? html`
@@ -172,9 +172,9 @@ ${topCount > 0 ? html`
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">Messages</div>
<div class="stat-title">${t('entities.messages')}</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">Last 7 days</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
</div>
@@ -184,9 +184,9 @@ ${topCount > 0 ? html`
<div class="card-body">
<h2 class="card-title text-base">
${iconNodes('h-5 w-5')}
Total Nodes
${t('common.total_entity', { entity: t('entities.nodes') })}
</h2>
<p class="text-xs opacity-70">Over time (last 7 days)</p>
<p class="text-xs opacity-70">${t('time.over_time_last_7_days')}</p>
<div class="h-32">
<canvas id="nodeChart"></canvas>
</div>
@@ -198,9 +198,9 @@ ${topCount > 0 ? html`
<div class="card-body">
<h2 class="card-title text-base">
${iconAdvertisements('h-5 w-5')}
Advertisements
${t('entities.advertisements')}
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
<p class="text-xs opacity-70">${t('time.per_day_last_7_days')}</p>
<div class="h-32">
<canvas id="advertChart"></canvas>
</div>
@@ -212,9 +212,9 @@ ${topCount > 0 ? html`
<div class="card-body">
<h2 class="card-title text-base">
${iconMessages('h-5 w-5')}
Messages
${t('entities.messages')}
</h2>
<p class="text-xs opacity-70">Per day (last 7 days)</p>
<p class="text-xs opacity-70">${t('time.per_day_last_7_days')}</p>
<div class="h-32">
<canvas id="messageChart"></canvas>
</div>
@@ -229,7 +229,7 @@ ${bottomCount > 0 ? html`
<div class="card-body">
<h2 class="card-title">
${iconAdvertisements('h-6 w-6')}
Recent Advertisements
${t('common.recent_entity', { entity: t('entities.advertisements') })}
</h2>
${renderRecentAds(stats.recent_advertisements)}
</div>
@@ -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);
}
}

View File

@@ -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`<p class="py-4 max-w-[70%]">${config.network_welcome_text}</p>`
: html`<p class="py-4 max-w-[70%]">
Welcome to the ${networkName} mesh network dashboard.
Monitor network activity, view connected nodes, and explore message history.
${t('home.welcome_default', { network_name: networkName })}
</p>`;
const customPageButtons = features.pages !== false
@@ -82,27 +81,27 @@ export async function render(container, params, router) {
${features.dashboard !== false ? html`
<a href="/dashboard" class="btn btn-outline btn-info">
${iconDashboard('h-5 w-5 mr-2')}
Dashboard
${t('entities.dashboard')}
</a>` : nothing}
${features.nodes !== false ? html`
<a href="/nodes" class="btn btn-outline btn-primary">
${iconNodes('h-5 w-5 mr-2')}
Nodes
${t('entities.nodes')}
</a>` : nothing}
${features.advertisements !== false ? html`
<a href="/advertisements" class="btn btn-outline btn-secondary">
${iconAdvertisements('h-5 w-5 mr-2')}
Adverts
${t('entities.advertisements')}
</a>` : nothing}
${features.messages !== false ? html`
<a href="/messages" class="btn btn-outline btn-accent">
${iconMessages('h-5 w-5 mr-2')}
Messages
${t('entities.messages')}
</a>` : nothing}
${features.map !== false ? html`
<a href="/map" class="btn btn-outline btn-warning">
${iconMap('h-5 w-5 mr-2')}
Map
${t('entities.map')}
</a>` : nothing}
${customPageButtons}
</div>
@@ -115,9 +114,9 @@ export async function render(container, params, router) {
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-title">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">All discovered nodes</div>
<div class="stat-desc">${t('home.all_discovered_nodes')}</div>
</div>` : nothing}
${features.advertisements !== false ? html`
@@ -125,9 +124,9 @@ export async function render(container, params, router) {
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-title">${t('entities.advertisements')}</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">Last 7 days</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
${features.messages !== false ? html`
@@ -135,9 +134,9 @@ export async function render(container, params, router) {
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">Messages</div>
<div class="stat-title">${t('entities.messages')}</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">Last 7 days</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
</div>` : nothing}
</div>
@@ -147,7 +146,7 @@ export async function render(container, params, router) {
<div class="card-body">
<h2 class="card-title">
${iconInfo('h-6 w-6')}
Network Info
${t('home.network_info')}
</h2>
<div class="space-y-2">
${renderRadioConfig(rc)}
@@ -157,7 +156,7 @@ export async function render(container, params, router) {
<div class="card bg-base-100 shadow-xl">
<div class="card-body flex flex-col items-center justify-center">
<p class="text-sm opacity-70 mb-4 text-center">Our local off-grid mesh network is made possible by</p>
<p class="text-sm opacity-70 mb-4 text-center">${t('home.meshcore_attribution')}</p>
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo h-8" />
</a>
@@ -165,11 +164,11 @@ export async function render(container, params, router) {
<div class="flex gap-2 mt-4">
<a href="https://meshcore.co.uk/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
${iconGlobe('h-4 w-4 mr-1')}
Website
${t('links.website')}
</a>
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
${iconGithub('h-4 w-4 mr-1')}
GitHub
${t('links.github')}
</a>
</div>
</div>
@@ -180,9 +179,9 @@ export async function render(container, params, router) {
<div class="card-body">
<h2 class="card-title">
${iconChart('h-6 w-6')}
Network Activity
${t('home.network_activity')}
</h2>
<p class="text-sm opacity-70 mb-2">Activity per day (last 7 days)</p>
<p class="text-sm opacity-70 mb-2">${t('time.activity_per_day_last_7_days')}</p>
<div class="h-48">
<canvas id="activityChart"></canvas>
</div>
@@ -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);
}
}

View File

@@ -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 = '<p><span class="opacity-70">Owner:</span> ' + ownerDisplay + '</p>';
ownerHtml = '<p><span class="opacity-70">' + ((window.t && window.t('map.owner')) || 'Owner:') + '</span> ' + ownerDisplay + '</p>';
}
let roleHtml = '';
if (node.role) {
roleHtml = '<p><span class="opacity-70">Role:</span> <span class="badge badge-xs badge-ghost">' + escapeHtml(node.role) + '</span></p>';
roleHtml = '<p><span class="opacity-70">' + ((window.t && window.t('map.role')) || 'Role:') + '</span> <span class="badge badge-xs badge-ghost">' + escapeHtml(node.role) + '</span></p>';
}
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 = ' <span style="display: inline-block; width: 10px; height: 10px; background: ' + dotColor + '; border: 2px solid ' + borderColor + '; border-radius: 50%; vertical-align: middle;" title="' + title + '"></span>';
}
const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:';
const lastSeenHtml = node.last_seen
? '<p><span class="opacity-70">Last seen:</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
? '<p><span class="opacity-70">' + lastSeenLabel + '</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
: '';
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 '<div class="p-2">' +
'<h3 class="font-bold text-lg mb-2">' + nodeTypeEmoji + ' ' + escapeHtml(node.name || 'Unknown') + infraIndicatorHtml + '</h3>' +
'<h3 class="font-bold text-lg mb-2">' + nodeTypeEmoji + ' ' + escapeHtml(node.name || unknownLabel) + infraIndicatorHtml + '</h3>' +
'<div class="space-y-1 text-sm">' +
'<p><span class="opacity-70">Type:</span> ' + escapeHtml(typeDisplay) + '</p>' +
'<p><span class="opacity-70">' + typeLabel + '</span> ' + escapeHtml(typeDisplay) + '</p>' +
roleHtml +
ownerHtml +
'<p><span class="opacity-70">Key:</span> <code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></p>' +
'<p><span class="opacity-70">Location:</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
'<p><span class="opacity-70">' + keyLabel + '</span> <code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></p>' +
'<p><span class="opacity-70">' + locationLabel + '</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
lastSeenHtml +
'</div>' +
'<a href="/nodes/' + encodeURIComponent(node.public_key) + '" class="btn btn-outline btn-xs mt-3">View Details</a>' +
'<a href="/nodes/' + encodeURIComponent(node.public_key) + '" class="btn btn-outline btn-xs mt-3">' + viewDetailsLabel + '</a>' +
'</div>';
}
@@ -166,10 +173,10 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Map</h1>
<h1 class="text-3xl font-bold">${t('entities.map')}</h1>
<div class="flex items-center gap-2">
${timezoneIndicator()}
<span id="node-count" class="badge badge-lg">Loading...</span>
<span id="node-count" class="badge badge-lg">${t('common.loading')}</span>
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
</div>
</div>
@@ -179,30 +186,30 @@ export async function render(container, params, router) {
<div class="flex gap-4 flex-wrap items-end">
<div class="form-control">
<label class="label py-1">
<span class="label-text">Show</span>
<span class="label-text">${t('common.show')}</span>
</label>
<select id="filter-category" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">All Nodes</option>
<option value="infra">Infrastructure Only</option>
<option value="">${t('common.all_entity', { entity: t('entities.nodes') })}</option>
<option value="infra">${t('map.infrastructure_only')}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Node Type</span>
<span class="label-text">${t('common.node_type')}</span>
</label>
<select id="filter-type" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">All Types</option>
<option value="chat">Chat</option>
<option value="repeater">Repeater</option>
<option value="room">Room</option>
<option value="">${t('common.all_types')}</option>
<option value="chat">${t('node_types.chat')}</option>
<option value="repeater">${t('node_types.repeater')}</option>
<option value="room">${t('node_types.room')}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
<span class="label-text">${t('entities.member')}</span>
</label>
<select id="filter-member" class="select select-bordered select-sm" @change=${applyFilters}>
<option value="">All Members</option>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${sortedMembers
.filter(m => m.member_id)
.map(m => {
@@ -215,11 +222,11 @@ export async function render(container, params, router) {
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">Show Labels</span>
<span class="label-text">${t('map.show_labels')}</span>
<input type="checkbox" id="show-labels" class="checkbox checkbox-sm" @change=${updateLabelVisibility}>
</label>
</div>
<button id="clear-filters" class="btn btn-ghost btn-sm" @click=${clearFiltersHandler}>Clear Filters</button>
<button id="clear-filters" class="btn btn-ghost btn-sm" @click=${clearFiltersHandler}>${t('common.clear_filters')}</button>
</div>
</div>
</div>
@@ -231,19 +238,19 @@ export async function render(container, params, router) {
</div>
<div class="mt-4 flex flex-wrap gap-4 items-center text-sm">
<span class="opacity-70">Legend:</span>
<span class="opacity-70">${t('map.legend')}</span>
<div class="flex items-center gap-1">
<div style="width: 10px; height: 10px; background: #ef4444; border: 2px solid #b91c1c; border-radius: 50%;"></div>
<span>Infrastructure</span>
<span>${t('map.infrastructure')}</span>
</div>
<div class="flex items-center gap-1">
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
<span>Public</span>
<span>${t('map.public')}</span>
</div>
</div>
<div class="mt-2 text-sm opacity-70">
<p>Nodes are placed on the map based on GPS coordinates from node reports or manual tags.</p>
<p>${t('map.gps_description')}</p>
</div>`, 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);
}
}

View File

@@ -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`<p class="text-sm mt-2"><span class="opacity-70">Contact:</span> ${member.contact}</p>`
? html`<p class="text-sm mt-2"><span class="opacity-70">${t('common.contact')}:</span> ${member.contact}</p>`
: nothing;
return html`<div class="card bg-base-100 shadow-xl">
@@ -85,22 +85,22 @@ export async function render(container, params, router) {
if (members.length === 0) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Members</h1>
<span class="badge badge-lg">0 members</span>
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<span class="badge badge-lg">${t('common.count_entity', { count: 0, entity: t('entities.members').toLowerCase() })}</span>
</div>
<div class="alert alert-info">
${iconInfo('stroke-current shrink-0 h-6 w-6')}
<div>
<h3 class="font-bold">No members configured</h3>
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
<h3 class="font-bold">${t('common.no_entity_configured', { entity: t('entities.members').toLowerCase() })}</h3>
<p class="text-sm">${t('members.empty_state_description')}</p>
</div>
</div>
<div class="mt-6 card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Members File Format</h2>
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
<h2 class="card-title">${t('members.members_file_format')}</h2>
<p class="mb-4">${unsafeHTML(t('members.members_file_description'))}</p>
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>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.</code></pre>
<p class="mt-4 text-sm opacity-70">
Run <code>meshcore-hub collector seed</code> to import members.<br/>
To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>.
${unsafeHTML(t('members.members_import_instructions'))}
</p>
</div>
</div>`, container);
@@ -139,8 +138,8 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Members</h1>
<span class="badge badge-lg">${members.length} members</span>
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<span class="badge badge-lg">${t('common.count_entity', { count: members.length, entity: t('entities.members').toLowerCase() })}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
@@ -148,6 +147,6 @@ export async function render(container, params, router) {
</div>`, container);
} catch (e) {
litRender(errorAlert(e.message || 'Failed to load members'), container);
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}

View File

@@ -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`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Messages</h1>
<h1 class="text-3xl font-bold">${t('entities.messages')}</h1>
<div class="flex items-center gap-2">
${tzBadge}
${total !== null ? html`<span class="badge badge-lg">${total} total</span>` : nothing}
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
</div>
</div>
${content}`, container);
@@ -41,14 +41,14 @@ ${content}`, container);
const totalPages = Math.ceil(total / limit);
const mobileCards = messages.length === 0
? html`<div class="text-center py-8 opacity-70">No messages found.</div>`
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</div>`
: 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`<span class="opacity-60">Public</span>`;
senderBlock = html`<span class="opacity-60">${t('messages.type_public')}</span>`;
} else {
const senderName = msg.sender_tag_name || msg.sender_name;
if (senderName) {
@@ -95,14 +95,14 @@ ${content}`, container);
});
const tableRows = messages.length === 0
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">No messages found.</td></tr>`
? html`<tr><td colspan="5" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</td></tr>`
: 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`<span class="opacity-60">Public</span>`;
senderBlock = html`<span class="opacity-60">${t('messages.type_public')}</span>`;
} else {
const senderName = msg.sender_tag_name || msg.sender_name;
if (senderName) {
@@ -144,17 +144,17 @@ ${content}`, container);
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/messages', navigate)}>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Type</span>
<span class="label-text">${t('common.type')}</span>
</label>
<select name="message_type" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">All Types</option>
<option value="contact" ?selected=${message_type === 'contact'}>Direct</option>
<option value="channel" ?selected=${message_type === 'channel'}>Channel</option>
<option value="">${t('common.all_types')}</option>
<option value="contact" ?selected=${message_type === 'contact'}>${t('messages.type_direct')}</option>
<option value="channel" ?selected=${message_type === 'channel'}>${t('messages.type_channel')}</option>
</select>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/messages" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
@@ -168,11 +168,11 @@ ${content}`, container);
<table class="table table-zebra">
<thead>
<tr>
<th>Type</th>
<th>Time</th>
<th>From</th>
<th>Message</th>
<th>Receivers</th>
<th>${t('common.type')}</th>
<th>${t('common.time')}</th>
<th>${t('common.from')}</th>
<th>${t('entities.message')}</th>
<th>${t('common.receivers')}</th>
</tr>
</thead>
<tbody>

View File

@@ -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) {
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body flex-row items-center gap-4">
<div id="qr-code" class="bg-white p-1 rounded"></div>
<p class="text-sm opacity-70">Scan to add as contact</p>
<p class="text-sm opacity-70">${t('nodes.scan_to_add')}</p>
</div>
</div>`;
const coordsHtml = hasCoords
? html`<div><span class="opacity-70">Location:</span> ${lat}, ${lon}</div>`
? html`<div><span class="opacity-70">${t('common.location')}:</span> ${lat}, ${lon}</div>`
: nothing;
const adsTableHtml = advertisements.length > 0
@@ -70,9 +70,9 @@ export async function render(container, params, router) {
<table class="table table-compact w-full">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Received By</th>
<th>${t('common.time')}</th>
<th>${t('common.type')}</th>
<th>${t('common.received_by')}</th>
</tr>
</thead>
<tbody>
@@ -101,7 +101,7 @@ export async function render(container, params, router) {
</tbody>
</table>
</div>`
: html`<p class="opacity-70">No advertisements recorded.</p>`;
: html`<p class="opacity-70">${t('common.no_entity_recorded', { entity: t('entities.advertisements').toLowerCase() })}</p>`;
const tags = node.tags || [];
const tagsTableHtml = tags.length > 0
@@ -109,9 +109,9 @@ export async function render(container, params, router) {
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
<th>${t('common.key')}</th>
<th>${t('common.value')}</th>
<th>${t('common.type')}</th>
</tr>
</thead>
<tbody>
@@ -123,25 +123,25 @@ export async function render(container, params, router) {
</tbody>
</table>
</div>`
: html`<p class="opacity-70">No tags defined.</p>`;
: html`<p class="opacity-70">${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}</p>`;
const adminTagsHtml = (config.admin_enabled && config.is_authenticated)
? html`<div class="mt-3">
<a href="/a/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? 'Edit Tags' : 'Add Tags'}</a>
<a href="/a/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
</div>`
: nothing;
litRender(html`
<div class="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/nodes">Nodes</a></li>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/nodes">${t('entities.nodes')}</a></li>
<li>${tagName || node.name || node.public_key.slice(0, 12) + '...'}</li>
</ul>
</div>
<h1 class="text-3xl font-bold mb-6">
<span title=${node.adv_type || 'Unknown'}>${emoji}</span>
<span title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
${displayName}
</h1>
@@ -150,12 +150,12 @@ ${heroHtml}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div>
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
<h3 class="font-semibold opacity-70 mb-2">${t('common.public_key')}</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">${node.public_key}</code>
</div>
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 text-sm">
<div><span class="opacity-70">First seen:</span> ${formatDateTime(node.first_seen)}</div>
<div><span class="opacity-70">Last seen:</span> ${formatDateTime(node.last_seen)}</div>
<div><span class="opacity-70">${t('common.first_seen_label')}</span> ${formatDateTime(node.first_seen)}</div>
<div><span class="opacity-70">${t('common.last_seen_label')}</span> ${formatDateTime(node.last_seen)}</div>
${coordsHtml}
</div>
</div>
@@ -164,14 +164,14 @@ ${heroHtml}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Recent Advertisements</h2>
<h2 class="card-title">${t('common.recent_entity', { entity: t('entities.advertisements') })}</h2>
${adsTableHtml}
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Tags</h2>
<h2 class="card-title">${t('nodes.tags')}</h2>
${tagsTableHtml}
${adminTagsHtml}
</div>
@@ -237,14 +237,14 @@ function renderNotFound(publicKey) {
return html`
<div class="breadcrumbs text-sm mb-4">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/nodes">Nodes</a></li>
<li>Not Found</li>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/nodes">${t('entities.nodes')}</a></li>
<li>${t('common.page_not_found')}</li>
</ul>
</div>
<div class="alert alert-error">
${iconError('stroke-current shrink-0 h-6 w-6')}
<span>Node not found: ${publicKey}</span>
<span>${t('common.entity_not_found_details', { entity: t('entities.node'), details: publicKey })}</span>
</div>
<a href="/nodes" class="btn btn-primary mt-4">Back to Nodes</a>`;
<a href="/nodes" class="btn btn-primary mt-4">${t('common.view_entity', { entity: t('entities.nodes') })}</a>`;
}

View File

@@ -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`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Nodes</h1>
<h1 class="text-3xl font-bold">${t('entities.nodes')}</h1>
<div class="flex items-center gap-2">
${tzBadge}
${total !== null ? html`<span class="badge badge-lg">${total} total</span>` : nothing}
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
</div>
</div>
${content}`, container);
@@ -51,19 +51,19 @@ ${content}`, container);
? html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">Member</span>
<span class="label-text">${t('entities.member')}</span>
</label>
<select name="member_id" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">All Members</option>
<option value="">${t('common.all_entity', { entity: t('entities.members') })}</option>
${members.map(m => html`<option value=${m.member_id} ?selected=${member_id === m.member_id}>${m.name}${m.callsign ? ` (${m.callsign})` : ''}</option>`)}
</select>
</div>`
: nothing;
const mobileCards = nodes.length === 0
? html`<div class="text-center py-8 opacity-70">No nodes found.</div>`
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</div>`
: 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`<div class="flex gap-1 justify-end mt-1">
${tags.slice(0, 2).map(t => html`<span class="badge badge-ghost badge-xs">${t.key}</span>`)}
${tags.slice(0, 2).map(tag => html`<span class="badge badge-ghost badge-xs">${tag.key}</span>`)}
${tags.length > 2 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 2}</span>` : nothing}
</div>`
: nothing;
@@ -82,7 +82,7 @@ ${content}`, container);
<div class="card-body p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-lg flex-shrink-0" title=${node.adv_type || 'Unknown'}>${emoji}</span>
<span class="text-lg flex-shrink-0" title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
<div class="min-w-0">
${nameBlock}
</div>
@@ -97,9 +97,9 @@ ${content}`, container);
});
const tableRows = nodes.length === 0
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">No nodes found.</td></tr>`
? html`<tr><td colspan="3" class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() })}</td></tr>`
: 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`<div class="flex gap-1 flex-wrap">
${tags.slice(0, 3).map(t => html`<span class="badge badge-ghost badge-xs">${t.key}</span>`)}
${tags.slice(0, 3).map(tag => html`<span class="badge badge-ghost badge-xs">${tag.key}</span>`)}
${tags.length > 3 ? html`<span class="badge badge-ghost badge-xs">+${tags.length - 3}</span>` : nothing}
</div>`
: html`<span class="opacity-50">-</span>`;
return html`<tr class="hover">
<td>
<a href="/nodes/${node.public_key}" class="link link-hover flex items-center gap-2">
<span class="text-lg" title=${node.adv_type || 'Unknown'}>${emoji}</span>
<span class="text-lg" title=${node.adv_type || t('node_types.unknown')}>${emoji}</span>
<div>
${nameBlock}
</div>
@@ -138,25 +138,25 @@ ${content}`, container);
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/nodes', navigate)}>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Search</span>
<span class="label-text">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text">Type</span>
<span class="label-text">${t('common.type')}</span>
</label>
<select name="adv_type" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">All Types</option>
<option value="chat" ?selected=${adv_type === 'chat'}>Chat</option>
<option value="repeater" ?selected=${adv_type === 'repeater'}>Repeater</option>
<option value="room" ?selected=${adv_type === 'room'}>Room</option>
<option value="">${t('common.all_types')}</option>
<option value="chat" ?selected=${adv_type === 'chat'}>${t('node_types.chat')}</option>
<option value="repeater" ?selected=${adv_type === 'repeater'}>${t('node_types.repeater')}</option>
<option value="room" ?selected=${adv_type === 'room'}>${t('node_types.room')}</option>
</select>
</div>
${membersFilter}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/nodes" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
@@ -170,9 +170,9 @@ ${content}`, container);
<table class="table table-zebra">
<thead>
<tr>
<th>Node</th>
<th>Last Seen</th>
<th>Tags</th>
<th>${t('entities.node')}</th>
<th>${t('common.last_seen')}</th>
<th>${t('common.tags')}</th>
</tr>
</thead>
<tbody>

View File

@@ -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) {
<div class="hero-content text-center">
<div class="max-w-md">
<div class="text-9xl font-bold text-primary opacity-20">404</div>
<h1 class="text-4xl font-bold -mt-8">Page Not Found</h1>
<h1 class="text-4xl font-bold -mt-8">${t('common.page_not_found')}</h1>
<p class="py-6 text-base-content/70">
The page you're looking for doesn't exist or has been moved.
${t('not_found.description')}
</p>
<div class="flex gap-4 justify-center">
<a href="/" class="btn btn-primary">
${iconHome('h-5 w-5 mr-2')}
Go Home
${t('common.go_home')}
</a>
<a href="/nodes" class="btn btn-outline">
${iconNodes('h-5 w-5 mr-2')}
Browse Nodes
${t('common.view_entity', { entity: t('entities.nodes') })}
</a>
</div>
</div>

View File

@@ -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}} <strong>{{name}}</strong>?",
"delete_all_entity_confirm": "Are you sure you want to delete all {{count}} {{entity}} from <strong>{{name}}</strong>?",
"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 <strong>{{name}}</strong> 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 <code>$SEED_HOME/members.yaml</code> with the following structure:",
"members_import_instructions": "Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>."
},
"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 <code>WEB_ADMIN_ENABLED=true</code> 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"
}
}

View File

@@ -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}} <strong>{{name}}</strong>? | Single item delete confirmation |
| `delete_all_entity_confirm` | Are you sure you want to delete all {{count}} {{entity}} from <strong>{{name}}</strong>? | 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 <strong>{{name}}</strong> 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 <code>$SEED_HOME/members.yaml</code> with the following structure: | File creation instructions |
| `members_import_instructions` | Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>. | 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 <code>WEB_ADMIN_ENABLED=true</code> 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 `<code>`, `<strong>`, or `<br/>` 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/`

View File

@@ -60,24 +60,24 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ t('entities.home') }}</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> {{ t('entities.dashboard') }}</a></li>
{% endif %}
{% if features.nodes %}
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> {{ t('entities.nodes') }}</a></li>
{% endif %}
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
{% endif %}
{% if features.members %}
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> {{ t('entities.members') }}</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
@@ -93,24 +93,24 @@
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ t('entities.home') }}</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> {{ t('entities.dashboard') }}</a></li>
{% endif %}
{% if features.nodes %}
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> {{ t('entities.nodes') }}</a></li>
{% endif %}
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
{% endif %}
{% if features.members %}
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> {{ t('entities.members') }}</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
@@ -150,18 +150,18 @@
{% endif %}
{% if network_contact_email and network_contact_discord %} | {% endif %}
{% if network_contact_discord %}
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">Discord</a>
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.discord') }}</a>
{% endif %}
{% if (network_contact_email or network_contact_discord) and network_contact_github %} | {% endif %}
{% if network_contact_github %}
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">GitHub</a>
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.github') }}</a>
{% endif %}
{% if (network_contact_email or network_contact_discord or network_contact_github) and network_contact_youtube %} | {% endif %}
{% if network_contact_youtube %}
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">YouTube</a>
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">Admin</a> | {% endif %}Powered by <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
</aside>
</footer>

View File

@@ -0,0 +1,139 @@
"""Tests for the i18n translation module."""
import json
from pathlib import Path
import pytest
from meshcore_hub.common.i18n import LOCALES_DIR, load_locale, t, get_locale
@pytest.fixture(autouse=True)
def _reset_locale():
"""Reset locale to English before each test."""
load_locale("en")
yield
class TestLoadLocale:
"""Tests for load_locale()."""
def test_load_english(self):
"""Loading 'en' should succeed and set locale."""
load_locale("en")
assert get_locale() == "en"
def test_fallback_to_english(self, tmp_path: Path):
"""Unknown locale falls back to 'en' if the directory has en.json."""
# Copy en.json into a temp directory
en_data = {"entities": {"home": "Home"}}
(tmp_path / "en.json").write_text(json.dumps(en_data))
load_locale("xx", locales_dir=tmp_path)
assert t("entities.home") == "Home"
def test_missing_locale_dir(self, tmp_path: Path):
"""Missing locale file doesn't crash."""
load_locale("zz", locales_dir=tmp_path / "nonexistent")
# Should still work, just returns keys
assert t("anything") == "anything"
class TestTranslation:
"""Tests for the t() translation function."""
def test_simple_key(self):
"""Simple dot-separated key resolves correctly."""
assert t("entities.home") == "Home"
assert t("entities.nodes") == "Nodes"
def test_nested_key(self):
"""Deeply nested keys resolve correctly."""
assert t("entities.advertisements") == "Advertisements"
def test_missing_key_returns_key(self):
"""Missing key returns the key itself as fallback."""
assert t("nonexistent.key") == "nonexistent.key"
def test_interpolation(self):
"""{{var}} placeholders are replaced."""
assert t("common.total", count=42) == "42 total"
def test_interpolation_multiple(self):
"""Multiple placeholders are all replaced."""
result = t(
"admin_node_tags.copied_entities",
copied=5,
skipped=2,
)
assert "5" in result
assert "2" in result
def test_missing_interpolation_var(self):
"""Missing interpolation variable leaves empty string."""
# total has {{count}} placeholder
result = t("common.total")
# The {{count}} should remain as-is since no var was passed
# Actually our implementation doesn't replace if key not in kwargs
assert "total" in result
class TestEnJsonCompleteness:
"""Tests to verify the en.json file is well-formed."""
def test_en_json_exists(self):
"""The en.json file exists in the expected location."""
en_path = LOCALES_DIR / "en.json"
assert en_path.exists(), f"en.json not found at {en_path}"
def test_en_json_valid(self):
"""The en.json file is valid JSON."""
en_path = LOCALES_DIR / "en.json"
data = json.loads(en_path.read_text(encoding="utf-8"))
assert isinstance(data, dict)
def test_required_sections_exist(self):
"""All required top-level sections exist."""
en_path = LOCALES_DIR / "en.json"
data = json.loads(en_path.read_text(encoding="utf-8"))
required = [
"entities",
"common",
"links",
"time",
"node_types",
"home",
"dashboard",
"nodes",
"advertisements",
"messages",
"map",
"members",
"not_found",
"custom_page",
"admin",
"admin_members",
"admin_node_tags",
"footer",
]
for section in required:
assert section in data, f"Missing section: {section}"
def test_common_no_entity_patterns(self):
"""Test that common 'no entity' patterns exist."""
assert t("common.no_entity_found", entity="test") == "No test found"
assert t("common.no_entity_recorded", entity="test") == "No test recorded"
assert t("common.no_entity_defined", entity="test") == "No test defined"
assert t("common.no_entity_configured", entity="test") == "No test configured"
assert t("common.no_entity_yet", entity="test") == "No test yet"
assert t("common.page_not_found") == "Page not found"
def test_entity_keys(self):
"""Entity keys are all present."""
assert t("entities.home") != "entities.home"
assert t("entities.dashboard") != "entities.dashboard"
assert t("entities.nodes") != "entities.nodes"
assert t("entities.advertisements") != "entities.advertisements"
assert t("entities.messages") != "entities.messages"
assert t("entities.map") != "entities.map"
assert t("entities.members") != "entities.members"
assert t("entities.admin") != "entities.admin"