feat(dev_tools): add translation editor for browsing and editing UI translations
Some checks failed
CI / ruff (push) Successful in 9s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

New admin page to browse, search, compare, and inline-edit translation
keys across all modules and languages from the browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:44:41 +01:00
parent 319900623a
commit aefca3115e
10 changed files with 867 additions and 4 deletions

View File

@@ -36,6 +36,7 @@ dev_tools_module = ModuleDefinition(
"test_runner", # Test execution
"violation_management", # Violation tracking and assignment
"sql_query", # Ad-hoc SQL query tool
"translation_editor", # UI translation editor
],
menu_items={
FrontendType.ADMIN: [
@@ -44,6 +45,7 @@ dev_tools_module = ModuleDefinition(
"code-quality", # Code quality dashboard
"tests", # Test runner dashboard
"sql-query", # SQL query tool
"translation-editor", # Translation editor
],
FrontendType.STORE: [], # No store menu items - internal module
},
@@ -85,6 +87,13 @@ dev_tools_module = ModuleDefinition(
route="/admin/platform-debug",
order=40,
),
MenuItemDefinition(
id="translation-editor",
label_key="dev_tools.menu.translation_editor",
icon="globe",
route="/admin/translation-editor",
order=50,
),
],
),
],

View File

@@ -14,7 +14,24 @@
"components": "Komponenten",
"icons": "Icons",
"sql_query": "SQL Abfrage",
"platform_debug": "Plattform Debug"
"platform_debug": "Plattform Debug",
"translation_editor": "Übersetzungseditor"
},
"translation_editor": {
"title": "Übersetzungseditor",
"all_modules": "Alle Module",
"search_placeholder": "Schlüssel und Werte suchen...",
"filter_all": "Alle",
"filter_missing": "Fehlende",
"filter_variables": "Variablen",
"module": "Modul",
"key_path": "Schlüsselpfad",
"missing": "fehlend",
"saving": "Speichern...",
"no_results": "Keine Übersetzungsschlüssel entsprechen Ihren Filtern.",
"loading": "Übersetzungen werden geladen...",
"previous": "Zurück",
"next": "Weiter"
},
"sql_query": {
"title": "SQL Abfrage-Werkzeug",

View File

@@ -14,7 +14,24 @@
"components": "Components",
"icons": "Icons",
"sql_query": "SQL Query",
"platform_debug": "Platform Debug"
"platform_debug": "Platform Debug",
"translation_editor": "Translation Editor"
},
"translation_editor": {
"title": "Translation Editor",
"all_modules": "All Modules",
"search_placeholder": "Search keys and values...",
"filter_all": "All",
"filter_missing": "Missing",
"filter_variables": "Variables",
"module": "Module",
"key_path": "Key Path",
"missing": "missing",
"saving": "Saving...",
"no_results": "No translation keys match your filters.",
"loading": "Loading translations...",
"previous": "Previous",
"next": "Next"
},
"sql_query": {
"title": "SQL Query Tool",

View File

@@ -14,7 +14,24 @@
"components": "Composants",
"icons": "Icônes",
"sql_query": "Requête SQL",
"platform_debug": "Debug Plateforme"
"platform_debug": "Debug Plateforme",
"translation_editor": "Éditeur de traductions"
},
"translation_editor": {
"title": "Éditeur de traductions",
"all_modules": "Tous les modules",
"search_placeholder": "Rechercher clés et valeurs...",
"filter_all": "Tous",
"filter_missing": "Manquantes",
"filter_variables": "Variables",
"module": "Module",
"key_path": "Chemin de clé",
"missing": "manquante",
"saving": "Enregistrement...",
"no_results": "Aucune clé de traduction ne correspond à vos filtres.",
"loading": "Chargement des traductions...",
"previous": "Précédent",
"next": "Suivant"
},
"sql_query": {
"title": "Outil de requête SQL",

View File

@@ -14,7 +14,24 @@
"components": "Komponenten",
"icons": "Icons",
"sql_query": "SQL Ufro",
"platform_debug": "Plattform Debug"
"platform_debug": "Plattform Debug",
"translation_editor": "Iwwersetzungseditor"
},
"translation_editor": {
"title": "Iwwersetzungseditor",
"all_modules": "All Moduler",
"search_placeholder": "Schlësselen a Wäerter sichen...",
"filter_all": "All",
"filter_missing": "Fehlend",
"filter_variables": "Variabelen",
"module": "Modul",
"key_path": "Schlësselpfad",
"missing": "fehlend",
"saving": "Späicheren...",
"no_results": "Keng Iwwersetzungsschlësselen entspriechen Ären Filteren.",
"loading": "Iwwersetzunge gi gelueden...",
"previous": "Zréck",
"next": "Weider"
},
"sql_query": {
"title": "SQL Ufro-Werkzeug",

View File

@@ -11,7 +11,11 @@ from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router,
)
from app.modules.dev_tools.routes.api.admin_sql_query import router as sql_query_router
from app.modules.dev_tools.routes.api.admin_translations import (
router as translations_router,
)
router = APIRouter()
router.include_router(sql_query_router, tags=["sql-query"])
router.include_router(platform_debug_router, tags=["platform-debug"])
router.include_router(translations_router, tags=["translations"])

View File

@@ -0,0 +1,312 @@
# app/modules/dev_tools/routes/api/admin_translations.py
"""
Translation Editor API endpoints.
Browse, search, and edit UI translation JSON files across all modules.
All endpoints require super-admin authentication.
"""
import json
import logging
import re
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.api.deps import UserContext, get_current_super_admin_api
from app.utils.i18n import (
SUPPORTED_LANGUAGES,
clear_translation_cache,
get_locales_path,
get_module_locale_dirs,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/translations", tags=["translations"])
# Variable placeholder pattern: {name}
VARIABLE_PATTERN = re.compile(r"\{(\w+)\}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _flatten_json(data: dict, prefix: str = "") -> dict[str, str]:
"""Flatten nested dict to dot-notation paths."""
result = {}
for key, value in data.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
result.update(_flatten_json(value, path))
else:
result[path] = value
return result
def _set_nested_value(data: dict, path: str, value: str) -> None:
"""Set a value in a nested dict using dot-notation path."""
keys = path.split(".")
current = data
for key in keys[:-1]:
if key not in current or not isinstance(current[key], dict):
current[key] = {}
current = current[key]
current[keys[-1]] = value
def _remove_nested_value(data: dict, path: str) -> None:
"""Remove a value from a nested dict using dot-notation path."""
keys = path.split(".")
current = data
for key in keys[:-1]:
if key not in current or not isinstance(current[key], dict):
return
current = current[key]
current.pop(keys[-1], None)
def _load_json(file_path: Path) -> dict:
"""Load JSON file, return empty dict if missing."""
if not file_path.exists():
return {}
try:
with open(file_path, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.error(f"Error loading {file_path}: {e}")
return {}
def _save_json(file_path: Path, data: dict) -> None:
"""Write dict to JSON file with pretty formatting."""
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
f.write("\n")
def _get_module_file_path(module: str, language: str) -> Path | None:
"""Get the JSON file path for a module + language, or None if not found."""
if module == "shared":
return get_locales_path() / f"{language}.json"
for mod_code, locales_dir in get_module_locale_dirs():
if mod_code == module:
return locales_dir / f"{language}.json"
return None
def _extract_variables(text: str) -> list[str]:
"""Extract {variable} placeholders from a string."""
return VARIABLE_PATTERN.findall(text)
def _load_all_keys() -> tuple[list[dict], list[dict], dict]:
"""
Load all translation keys across all modules and languages.
Returns:
(modules_list, keys_list, stats_dict)
"""
modules_info = []
all_keys: dict[tuple[str, str], dict[str, str | None]] = {}
# Global translations (static/locales/)
shared_keys_per_lang: dict[str, dict[str, str]] = {}
for lang in SUPPORTED_LANGUAGES:
file_path = get_locales_path() / f"{lang}.json"
data = _load_json(file_path)
flat = _flatten_json(data)
shared_keys_per_lang[lang] = flat
for path in flat:
key_id = ("shared", path)
if key_id not in all_keys:
all_keys[key_id] = {la: None for la in SUPPORTED_LANGUAGES}
all_keys[key_id][lang] = flat[path]
shared_all_paths = set()
for flat in shared_keys_per_lang.values():
shared_all_paths.update(flat.keys())
modules_info.append({
"code": "shared",
"label": "Shared",
"key_count": len(shared_all_paths),
})
# Module translations
for mod_code, locales_dir in get_module_locale_dirs():
mod_keys_per_lang: dict[str, dict[str, str]] = {}
for lang in SUPPORTED_LANGUAGES:
file_path = locales_dir / f"{lang}.json"
data = _load_json(file_path)
flat = _flatten_json(data)
mod_keys_per_lang[lang] = flat
for path in flat:
key_id = (mod_code, path)
if key_id not in all_keys:
all_keys[key_id] = {la: None for la in SUPPORTED_LANGUAGES}
all_keys[key_id][lang] = flat[path]
mod_all_paths = set()
for flat in mod_keys_per_lang.values():
mod_all_paths.update(flat.keys())
if mod_all_paths:
modules_info.append({
"code": mod_code,
"label": mod_code.replace("_", " ").replace("-", " ").title(),
"key_count": len(mod_all_paths),
})
# Build keys list and stats
keys_list = []
missing_counts = {lang: 0 for lang in SUPPORTED_LANGUAGES}
for (module, path), values in sorted(all_keys.items()):
# Detect variables from any non-null value
variables = []
has_variables = False
for val in values.values():
if val and isinstance(val, str):
found = _extract_variables(val)
if found:
has_variables = True
for v in found:
if v not in variables:
variables.append(v)
entry = {
"module": module,
"path": path,
"values": values,
"has_variables": has_variables,
}
if has_variables:
entry["variables"] = variables
keys_list.append(entry)
for lang in SUPPORTED_LANGUAGES:
if values[lang] is None:
missing_counts[lang] += 1
stats = {
"total_keys": len(keys_list),
"missing": missing_counts,
}
return modules_info, keys_list, stats
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class UpdateKeyRequest(BaseModel):
module: str = Field(..., min_length=1)
path: str = Field(..., min_length=1)
language: Literal["en", "fr", "de", "lb"]
value: str = Field(...)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/keys")
async def get_translation_keys(
ctx: UserContext = Depends(get_current_super_admin_api),
):
"""Load all translation keys across all modules and languages."""
modules, keys, stats = _load_all_keys()
return {
"modules": modules,
"languages": SUPPORTED_LANGUAGES,
"keys": keys,
"stats": stats,
}
@router.put("/key")
async def update_translation_key(
body: UpdateKeyRequest,
ctx: UserContext = Depends(get_current_super_admin_api),
):
"""Update a single translation value."""
file_path = _get_module_file_path(body.module, body.language)
if file_path is None:
return JSONResponse(
status_code=404,
content={"detail": f"Module '{body.module}' not found"},
)
data = _load_json(file_path)
if body.value == "":
# Empty string means delete the key
_remove_nested_value(data, body.path)
else:
_set_nested_value(data, body.path, body.value)
_save_json(file_path, data)
clear_translation_cache()
# Return updated entry
values = {}
for lang in SUPPORTED_LANGUAGES:
lang_path = _get_module_file_path(body.module, lang)
if lang_path:
lang_data = _load_json(lang_path)
flat = _flatten_json(lang_data)
values[lang] = flat.get(body.path)
else:
values[lang] = None
variables = []
has_variables = False
for val in values.values():
if val and isinstance(val, str):
found = _extract_variables(val)
if found:
has_variables = True
for v in found:
if v not in variables:
variables.append(v)
entry = {
"module": body.module,
"path": body.path,
"values": values,
"has_variables": has_variables,
}
if has_variables:
entry["variables"] = variables
return entry
@router.get("/missing")
async def get_missing_translations(
module: str | None = Query(None),
ctx: UserContext = Depends(get_current_super_admin_api),
):
"""Return only keys with missing translations in at least one language."""
_, keys, stats = _load_all_keys()
missing_keys = [
k for k in keys
if any(v is None for v in k["values"].values())
]
if module:
missing_keys = [k for k in missing_keys if k["module"] == module]
return {
"keys": missing_keys,
"stats": stats,
}

View File

@@ -149,6 +149,21 @@ async def admin_platform_debug_page(
)
@router.get("/translation-editor", response_class=HTMLResponse, include_in_schema=False)
async def admin_translation_editor_page(
request: Request,
current_user: User = Depends(
require_menu_access("translation-editor", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""Render translation editor page."""
return templates.TemplateResponse(
"dev_tools/admin/translation-editor.html",
get_admin_context(request, db, current_user),
)
@router.get("/sql-query", response_class=HTMLResponse, include_in_schema=False)
async def admin_sql_query_page(
request: Request,

View File

@@ -0,0 +1,292 @@
// app/modules/dev_tools/static/admin/js/translation-editor.js
const transLog = window.LogConfig.createLogger('TRANSLATION_EDITOR');
/**
* Translation Editor Alpine.js Component
* Browse, search, and edit UI string translations across all modules.
*/
function translationEditor() {
return {
// Inherit base layout functionality
...data(),
// Page identifier (for sidebar menu highlight)
currentPage: 'translation-editor',
// Data
modules: [],
keys: [],
languages: [],
stats: {},
// Filters
selectedModule: 'all',
searchQuery: '',
filterMode: 'all', // 'all' | 'missing' | 'has_variables'
// Editing
editingCell: null, // {module, path, language}
editValue: '',
saving: false,
// Pagination (uses pagination macro contract)
pagination: { page: 1, total: 0, per_page: 50 },
// UI
loading: true,
error: null,
// Computed-like getters
get filteredKeys() {
let result = this.keys;
// Module filter
if (this.selectedModule !== 'all') {
result = result.filter(k => k.module === this.selectedModule);
}
// Filter mode
if (this.filterMode === 'missing') {
result = result.filter(k =>
this.languages.some(lang => k.values[lang] === null)
);
} else if (this.filterMode === 'has_variables') {
result = result.filter(k => k.has_variables);
}
// Search
if (this.searchQuery.trim()) {
const q = this.searchQuery.toLowerCase();
result = result.filter(k => {
if (k.path.toLowerCase().includes(q)) return true;
if (k.module.toLowerCase().includes(q)) return true;
return this.languages.some(lang => {
const val = k.values[lang];
return val && val.toLowerCase().includes(q);
});
});
}
// Update pagination total
this.pagination.total = result.length;
return result;
},
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return ((this.pagination.page - 1) * this.pagination.per_page) + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const total = this.totalPages;
const current = this.pagination.page;
const maxVisible = 5;
if (total <= maxVisible) {
return Array.from({length: total}, (_, i) => i + 1);
}
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, current - half);
let end = Math.min(total, start + maxVisible - 1);
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
const pages = [];
if (start > 1) pages.push(1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < total - 1) pages.push('...');
if (end < total) pages.push(total);
return pages;
},
get paginatedKeys() {
const start = (this.pagination.page - 1) * this.pagination.per_page;
return this.filteredKeys.slice(start, start + this.pagination.per_page);
},
previousPage() {
if (this.pagination.page > 1) this.pagination.page--;
},
nextPage() {
if (this.pagination.page < this.totalPages) this.pagination.page++;
},
goToPage(p) {
if (typeof p === 'number') this.pagination.page = p;
},
async init() {
if (window._translationEditorInitialized) return;
window._translationEditorInitialized = true;
this.$nextTick(() => {
if (typeof this.initBase === 'function') this.initBase();
});
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadKeys();
},
async loadKeys() {
this.loading = true;
this.error = null;
try {
const resp = await apiClient.get('/admin/translations/keys');
this.modules = resp.modules;
this.keys = resp.keys;
this.languages = resp.languages;
this.stats = resp.stats;
this.pagination.total = resp.keys.length;
} catch (e) {
this.error = e.message;
transLog.error('Failed to load translations:', e);
} finally {
this.loading = false;
}
},
isEditing(entry, lang) {
return this.editingCell &&
this.editingCell.module === entry.module &&
this.editingCell.path === entry.path &&
this.editingCell.language === lang;
},
startEdit(entry, lang) {
// Save any current edit first
if (this.editingCell) {
this._doSave();
}
this.editingCell = {
module: entry.module,
path: entry.path,
language: lang,
};
this.editValue = entry.values[lang] || '';
// Focus the textarea
this.$nextTick(() => {
const ta = this.$el.querySelector('textarea[x-ref="editInput"]');
if (ta) ta.focus();
});
},
cancelEdit() {
this.editingCell = null;
this.editValue = '';
},
async saveEdit() {
await this._doSave();
},
async saveAndNext(entry, lang) {
await this._doSave();
// Move to next language column, or next row
const langIdx = this.languages.indexOf(lang);
if (langIdx < this.languages.length - 1) {
this.startEdit(entry, this.languages[langIdx + 1]);
} else {
// Move to next row, first language
const entries = this.paginatedKeys;
const rowIdx = entries.findIndex(
e => e.module === entry.module && e.path === entry.path
);
if (rowIdx < entries.length - 1) {
this.startEdit(entries[rowIdx + 1], this.languages[0]);
}
}
},
async _doSave() {
if (!this.editingCell) return;
const { module, path, language } = this.editingCell;
// Find the current entry
const entry = this.keys.find(
k => k.module === module && k.path === path
);
if (!entry) {
this.editingCell = null;
return;
}
const oldValue = entry.values[language];
const newValue = this.editValue;
// Skip if unchanged
if ((oldValue || '') === newValue) {
this.editingCell = null;
this.editValue = '';
return;
}
this.saving = true;
this.editingCell = null;
try {
const updated = await apiClient.put('/admin/translations/key', {
module,
path,
language,
value: newValue,
});
// Update the local entry
entry.values = updated.values;
entry.has_variables = updated.has_variables;
if (updated.variables) {
entry.variables = updated.variables;
}
// Recalculate missing stats
this._recalcStats();
} catch (e) {
this.error = e.message;
transLog.error('Failed to save translation:', e);
} finally {
this.saving = false;
this.editValue = '';
}
},
_recalcStats() {
const missing = {};
for (const lang of this.languages) {
missing[lang] = this.keys.filter(k => k.values[lang] === null).length;
}
this.stats = {
...this.stats,
missing,
};
},
getMissingVars(entry, value) {
if (!entry.variables || !value) return [];
return entry.variables.filter(v => !value.includes(`{${v}}`));
},
};
}

View File

@@ -0,0 +1,163 @@
{# app/modules/dev_tools/templates/dev_tools/admin/translation-editor.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Translation Editor{% endblock %}
{% block alpine_data %}translationEditor(){% endblock %}
{% block content %}
{{ page_header('Translation Editor', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
{{ loading_state('Loading translations...') }}
{{ error_state('Error loading translations') }}
<div x-show="!loading && !error" class="space-y-4">
<!-- Top filter bar -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4">
<div class="flex flex-wrap items-center gap-3">
<!-- Module filter -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">Module</label>
<select x-model="selectedModule" @change="pagination.page = 1"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="all">All Modules</option>
<template x-for="mod in modules" :key="mod.code">
<option :value="mod.code" :selected="selectedModule === mod.code"
x-text="`${mod.label} (${mod.key_count})`"></option>
</template>
</select>
</div>
<!-- Search -->
<div class="flex-1 min-w-[200px]">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input type="text" x-model="searchQuery" @input.debounce.200ms="pagination.page = 1"
placeholder="Search keys and values..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<!-- Filter mode -->
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
<button @click="filterMode = 'all'; pagination.page = 1"
:class="filterMode === 'all' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-sm font-medium transition-colors">All</button>
<button @click="filterMode = 'missing'; pagination.page = 1"
:class="filterMode === 'missing' ? 'bg-amber-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-sm font-medium border-l border-gray-300 dark:border-gray-600 transition-colors">Missing</button>
<button @click="filterMode = 'has_variables'; pagination.page = 1"
:class="filterMode === 'has_variables' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-sm font-medium border-l border-gray-300 dark:border-gray-600 transition-colors">Variables</button>
</div>
<!-- Stats badges -->
<div class="flex items-center gap-2">
<template x-for="lang in languages" :key="lang">
<span x-show="stats.missing && stats.missing[lang] > 0"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="stats.missing[lang] > 50 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'">
<span x-text="lang.toUpperCase()"></span>:
<span x-text="stats.missing[lang]" class="ml-0.5"></span>
</span>
</template>
</div>
</div>
</div>
<!-- Results info -->
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>
Showing <span x-text="filteredKeys.length" class="font-medium"></span>
of <span x-text="keys.length" class="font-medium"></span> keys
</span>
<span x-show="stats.total_keys">
Total: <span x-text="stats.total_keys" class="font-medium"></span> keys across
<span x-text="modules.length" class="font-medium"></span> modules
</span>
</div>
<!-- Translation table -->
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3 w-28">Module</th>
<th class="px-4 py-3 w-64">Key Path</th>
<template x-for="lang in languages" :key="lang">
<th class="px-4 py-3" x-text="lang.toUpperCase()"></th>
</template>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No translation keys found', icon='globe', show_condition='filteredKeys.length === 0') }}
<template x-for="entry in paginatedKeys" :key="entry.module + '.' + entry.path">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3 text-xs font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap">
<span x-text="entry.module"></span>
</td>
<td class="px-4 py-3 text-sm font-mono text-gray-900 dark:text-gray-100 whitespace-nowrap">
<span x-text="entry.path"></span>
<span x-show="entry.has_variables"
class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
:title="'Variables: ' + (entry.variables || []).join(', ')">
{ }
</span>
</td>
<template x-for="lang in languages" :key="lang">
<td class="px-4 py-2 text-sm max-w-xs relative group"
@click="startEdit(entry, lang)"
:class="entry.values[lang] === null ? 'bg-red-50/50 dark:bg-red-900/10' : ''">
<!-- Display mode -->
<div x-show="!isEditing(entry, lang)"
class="cursor-pointer min-h-[28px] flex items-center rounded px-1 -mx-1 group-hover:bg-gray-100 dark:group-hover:bg-gray-700 transition-colors">
<span x-show="entry.values[lang] !== null"
class="text-gray-900 dark:text-gray-100 truncate"
:title="entry.values[lang]"
x-text="entry.values[lang]"></span>
<span x-show="entry.values[lang] === null"
class="text-red-400 dark:text-red-500 italic text-xs">missing</span>
</div>
<!-- Edit mode -->
<div x-show="isEditing(entry, lang)" @click.stop>
<textarea x-ref="editInput"
x-model="editValue"
@keydown.enter.prevent="saveEdit()"
@keydown.tab.prevent="saveAndNext(entry, lang)"
@keydown.escape="cancelEdit()"
@blur="saveEdit()"
rows="2"
class="w-full text-sm rounded border-purple-400 dark:border-purple-500 dark:bg-gray-700 dark:text-gray-100 focus:ring-purple-500 focus:border-purple-500 resize-y p-1"></textarea>
<!-- Variable warning -->
<div x-show="entry.has_variables && editValue && getMissingVars(entry, editValue).length > 0"
class="mt-0.5 text-[10px] text-amber-600 dark:text-amber-400">
Missing: <span x-text="getMissingVars(entry, editValue).map(v => '{' + v + '}').join(', ')"></span>
</div>
</div>
</td>
</template>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination(show_condition='filteredKeys.length > 0') }}
<!-- Save indicator -->
<div x-show="saving" x-transition
class="fixed bottom-6 right-6 bg-purple-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm flex items-center gap-2">
<span x-html="$icon('spinner', 'w-4 h-4')"></span>
Saving...
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('dev_tools_static', path='admin/js/translation-editor.js') }}"></script>
{% endblock %}