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

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