feat(dev_tools): add translation editor for browsing and editing UI translations
Some checks failed
Some checks failed
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:
292
app/modules/dev_tools/static/admin/js/translation-editor.js
Normal file
292
app/modules/dev_tools/static/admin/js/translation-editor.js
Normal 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}}`));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user