mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Merge pull request #112 from ipnet-mesh/claude/add-i18n-support-1duUx
Add i18n support for web dashboard
This commit is contained in:
16
.env.example
16
.env.example
@@ -198,11 +198,24 @@ API_ADMIN_KEY=
|
||||
# External web port
|
||||
WEB_PORT=8080
|
||||
|
||||
# API endpoint URL for the web dashboard
|
||||
# Default: http://localhost:8000
|
||||
# API_BASE_URL=http://localhost:8000
|
||||
|
||||
# API key for web dashboard queries (optional)
|
||||
# If API_READ_KEY is set on the API, provide it here
|
||||
# API_KEY=
|
||||
|
||||
# Default theme for the web dashboard (dark or light)
|
||||
# Users can override via the theme toggle; their preference is saved in localStorage
|
||||
# Default: dark
|
||||
# WEB_THEME=dark
|
||||
|
||||
# Locale/language for the web dashboard
|
||||
# Default: en
|
||||
# Supported: en (see src/meshcore_hub/web/static/locales/ for available translations)
|
||||
# WEB_LOCALE=en
|
||||
|
||||
# Enable admin interface at /a/ (requires auth proxy in front)
|
||||
# Default: false
|
||||
# WEB_ADMIN_ENABLED=false
|
||||
@@ -221,6 +234,9 @@ TZ=UTC
|
||||
# -------------------
|
||||
# Displayed on the web dashboard homepage
|
||||
|
||||
# Network domain name (optional)
|
||||
# NETWORK_DOMAIN=
|
||||
|
||||
# Network display name
|
||||
NETWORK_NAME=MeshCore Network
|
||||
|
||||
|
||||
14
.github/workflows/code-review.yml
vendored
14
.github/workflows/code-review.yml
vendored
@@ -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
|
||||
|
||||
96
AGENTS.md
96
AGENTS.md
@@ -453,13 +453,103 @@ The web dashboard is a Single Page Application. Pages are ES modules loaded by t
|
||||
- Use `pageColors` from `components.js` for section-specific colors (reads CSS custom properties from `app.css`)
|
||||
- Return a cleanup function if the page creates resources (e.g., Leaflet maps, Chart.js instances)
|
||||
|
||||
### Internationalization (i18n)
|
||||
|
||||
The web dashboard supports internationalization via JSON translation files. The default language is English.
|
||||
|
||||
**Translation files location:** `src/meshcore_hub/web/static/locales/`
|
||||
|
||||
**Key files:**
|
||||
- `en.json` - English translations (reference implementation)
|
||||
- `languages.md` - Comprehensive translation reference guide for translators
|
||||
|
||||
**Using translations in JavaScript:**
|
||||
|
||||
Import the `t()` function from `components.js`:
|
||||
|
||||
```javascript
|
||||
import { t } from '../components.js';
|
||||
|
||||
// Simple translation
|
||||
const label = t('common.save'); // "Save"
|
||||
|
||||
// Translation with variable interpolation
|
||||
const title = t('common.add_entity', { entity: t('entities.node') }); // "Add Node"
|
||||
|
||||
// Composed patterns for consistency
|
||||
const emptyMsg = t('common.no_entity_found', { entity: t('entities.nodes').toLowerCase() }); // "No nodes found"
|
||||
```
|
||||
|
||||
**Translation architecture:**
|
||||
|
||||
1. **Entity-based composition:** Core entity names (`entities.*`) are referenced by composite patterns for consistency
|
||||
2. **Reusable patterns:** Common UI patterns (`common.*`) use `{{variable}}` interpolation for dynamic content
|
||||
3. **Separation of concerns:**
|
||||
- Keys without `_label` suffix = table headers (title case, no colon)
|
||||
- Keys with `_label` suffix = inline labels (sentence case, with colon)
|
||||
|
||||
**When adding/modifying translations:**
|
||||
|
||||
1. **Add new keys** to `en.json` following existing patterns:
|
||||
- Use composition when possible (reference `entities.*` in `common.*` patterns)
|
||||
- Group related keys by section (e.g., `admin_members.*`, `admin_node_tags.*`)
|
||||
- Use `{{variable}}` syntax for dynamic content
|
||||
|
||||
2. **Update `languages.md`** with:
|
||||
- Key name, English value, and usage context
|
||||
- Variable descriptions if using interpolation
|
||||
- Notes about HTML content or special formatting
|
||||
|
||||
3. **Add tests** in `tests/test_common/test_i18n.py`:
|
||||
- Test new interpolation patterns
|
||||
- Test required sections if adding new top-level sections
|
||||
- Test composed patterns with entity references
|
||||
|
||||
4. **Run i18n tests:**
|
||||
```bash
|
||||
pytest tests/test_common/test_i18n.py -v
|
||||
```
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- **Avoid duplication:** Use `common.*` patterns instead of duplicating similar strings
|
||||
- **Compose with entities:** Reference `entities.*` keys in patterns rather than hardcoding entity names
|
||||
- **Preserve variables:** Keep `{{variable}}` placeholders unchanged when translating
|
||||
- **Test composition:** Verify patterns work with all entity types (singular/plural, lowercase/uppercase)
|
||||
- **Document context:** Always update `languages.md` so translators understand usage
|
||||
|
||||
**Example - adding a new entity and patterns:**
|
||||
|
||||
```javascript
|
||||
// 1. Add entity to en.json
|
||||
"entities": {
|
||||
"sensor": "Sensor"
|
||||
}
|
||||
|
||||
// 2. Use with existing common patterns
|
||||
t('common.add_entity', { entity: t('entities.sensor') }) // "Add Sensor"
|
||||
t('common.no_entity_found', { entity: t('entities.sensors').toLowerCase() }) // "No sensors found"
|
||||
|
||||
// 3. Update languages.md with context
|
||||
// 4. Add test to test_i18n.py
|
||||
```
|
||||
|
||||
**Translation loading:**
|
||||
|
||||
The i18n system (`src/meshcore_hub/common/i18n.py`) loads translations on startup:
|
||||
- Defaults to English (`en`)
|
||||
- Falls back to English for missing keys
|
||||
- Returns the key itself if translation not found
|
||||
|
||||
For full translation guidelines, see `src/meshcore_hub/web/static/locales/languages.md`.
|
||||
|
||||
### Adding a New Database Model
|
||||
|
||||
1. Create model in `common/models/`
|
||||
2. Export in `common/models/__init__.py`
|
||||
3. Create Alembic migration: `alembic revision --autogenerate -m "description"`
|
||||
3. Create Alembic migration: `meshcore-hub db revision --autogenerate -m "description"`
|
||||
4. Review and adjust migration file
|
||||
5. Test migration: `alembic upgrade head`
|
||||
5. Test migration: `meshcore-hub db upgrade`
|
||||
|
||||
### Running the Development Environment
|
||||
|
||||
@@ -481,7 +571,7 @@ pytest
|
||||
# Run specific component
|
||||
meshcore-hub api --reload
|
||||
meshcore-hub collector
|
||||
meshcore-hub interface --mode receiver --mock
|
||||
meshcore-hub interface receiver --mock
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
27
README.md
27
README.md
@@ -8,6 +8,23 @@ Python 3.14+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
|
||||

|
||||
|
||||
## 🌍 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)
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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,
|
||||
|
||||
81
src/meshcore_hub/common/i18n.py
Normal file
81
src/meshcore_hub/common/i18n.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
76
src/meshcore_hub/web/static/js/spa/i18n.js
Normal file
76
src/meshcore_hub/web/static/js/spa/i18n.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"> </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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
212
src/meshcore_hub/web/static/locales/en.json
Normal file
212
src/meshcore_hub/web/static/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
417
src/meshcore_hub/web/static/locales/languages.md
Normal file
417
src/meshcore_hub/web/static/locales/languages.md
Normal 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/`
|
||||
@@ -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>
|
||||
|
||||
|
||||
139
tests/test_common/test_i18n.py
Normal file
139
tests/test_common/test_i18n.py
Normal 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"
|
||||
Reference in New Issue
Block a user