added translation tab
This commit is contained in:
384
website/js/pages/translations.js
Normal file
384
website/js/pages/translations.js
Normal file
@ -0,0 +1,384 @@
|
||||
import { apiGet, apiPut, apiDelete } from '../api.js';
|
||||
import { showToast } from '../app.js';
|
||||
import {
|
||||
SOURCE_LANG,
|
||||
normalizeEntry,
|
||||
translationFor,
|
||||
targetLanguages,
|
||||
sourceLanguageLabel,
|
||||
translationListHeaderHTML,
|
||||
buildTranslationGroups,
|
||||
renderGroupedTranslationsHTML,
|
||||
flattenAllQuestionnaires,
|
||||
escHtml as esc,
|
||||
} from '../translations-helpers.js';
|
||||
|
||||
let transData = null;
|
||||
let allEntries = [];
|
||||
let selectedLang = '';
|
||||
let saveTimers = {};
|
||||
|
||||
export async function translationsPage() {
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1>Translations</h1>
|
||||
</div>
|
||||
<div id="transPageRoot"><div class="spinner"></div></div>
|
||||
`;
|
||||
|
||||
try {
|
||||
renderShell();
|
||||
await loadTranslations();
|
||||
} catch (e) {
|
||||
document.getElementById('transPageRoot').innerHTML =
|
||||
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderShell() {
|
||||
document.getElementById('transPageRoot').innerHTML = `
|
||||
<div class="card trans-page">
|
||||
<div class="trans-controls">
|
||||
<label class="trans-control">
|
||||
<span>Translate into</span>
|
||||
<select id="transLangSelect" class="trans-select" disabled>
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="trans-lang-panel">
|
||||
<summary>More languages</summary>
|
||||
<div id="transLangPanel"><span class="text-muted">Loading…</span></div>
|
||||
</details>
|
||||
|
||||
<div class="trans-toolbar">
|
||||
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…" disabled>
|
||||
<select id="transTypeFilter" class="trans-select trans-type-select" disabled>
|
||||
<option value="">All types</option>
|
||||
<option value="string">UI strings</option>
|
||||
<option value="question">Questions</option>
|
||||
<option value="answer_option">Answer options</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="transListArea"><div class="spinner"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function pickDefaultTargetLang(languages) {
|
||||
const targets = targetLanguages(languages);
|
||||
if (!targets.length) return '';
|
||||
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) {
|
||||
return selectedLang;
|
||||
}
|
||||
return targets[0].languageCode;
|
||||
}
|
||||
|
||||
async function ensureGermanLanguage() {
|
||||
try {
|
||||
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
|
||||
} catch (_) { /* already exists */ }
|
||||
}
|
||||
|
||||
async function loadTranslations() {
|
||||
const listArea = document.getElementById('transListArea');
|
||||
const langSelect = document.getElementById('transLangSelect');
|
||||
const filter = document.getElementById('transFilter');
|
||||
const typeFilter = document.getElementById('transTypeFilter');
|
||||
|
||||
if (listArea) listArea.innerHTML = '<div class="spinner"></div>';
|
||||
saveTimers = {};
|
||||
|
||||
try {
|
||||
await ensureGermanLanguage();
|
||||
const data = await apiGet('translations.php?all=1');
|
||||
transData = data;
|
||||
const languages = transData.languages || [];
|
||||
const questionnaires = transData.questionnaires || [];
|
||||
|
||||
const flat = flattenAllQuestionnaires(questionnaires);
|
||||
allEntries = flat.allEntries;
|
||||
transData.sections = flat.sections;
|
||||
|
||||
selectedLang = pickDefaultTargetLang(languages);
|
||||
|
||||
if (langSelect) {
|
||||
const targets = targetLanguages(languages);
|
||||
langSelect.disabled = !targets.length;
|
||||
langSelect.innerHTML = targets.length
|
||||
? targets.map(l => {
|
||||
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
|
||||
return `<option value="${esc(l.languageCode)}" ${l.languageCode === selectedLang ? 'selected' : ''}>${esc(label)}</option>`;
|
||||
}).join('')
|
||||
: '<option value="">Add a language below</option>';
|
||||
langSelect.onchange = () => {
|
||||
selectedLang = langSelect.value;
|
||||
renderEntryList();
|
||||
};
|
||||
}
|
||||
|
||||
if (filter) filter.disabled = false;
|
||||
if (typeFilter) typeFilter.disabled = false;
|
||||
|
||||
renderLanguagePanel(languages);
|
||||
renderEntryList();
|
||||
bindFilters();
|
||||
} catch (e) {
|
||||
if (listArea) listArea.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderLanguagePanel(languages) {
|
||||
const panel = document.getElementById('transLangPanel');
|
||||
if (!panel) return;
|
||||
|
||||
const others = targetLanguages(languages);
|
||||
|
||||
panel.innerHTML = `
|
||||
<p class="text-muted" style="font-size:.85rem;margin:0 0 10px">
|
||||
German source text is edited in the questionnaire editor. Add other languages here.
|
||||
</p>
|
||||
<div class="lang-chips">
|
||||
<span class="lang-chip lang-chip-fixed">
|
||||
<strong>DE</strong>
|
||||
<span class="lang-chip-name">German (source)</span>
|
||||
</span>
|
||||
${others.map(l => `
|
||||
<span class="lang-chip">
|
||||
<strong>${esc(l.languageCode.toUpperCase())}</strong>
|
||||
${l.name ? `<span class="lang-chip-name">${esc(l.name)}</span>` : ''}
|
||||
<button type="button" class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">×</button>
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="lang-add-row">
|
||||
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code" maxlength="8">
|
||||
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="addLangBtn">Add</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('addLangBtn')?.addEventListener('click', async () => {
|
||||
const code = document.getElementById('newLangCode').value.trim().toLowerCase();
|
||||
const name = document.getElementById('newLangName').value.trim();
|
||||
if (!code) { showToast('Language code is required', 'error'); return; }
|
||||
if (code === SOURCE_LANG) {
|
||||
showToast('German (de) is always available as the source language', 'error');
|
||||
return;
|
||||
}
|
||||
if (languages.some(l => l.languageCode === code)) {
|
||||
showToast('Language already exists', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiPut('translations.php', { type: 'language', languageCode: code, name });
|
||||
showToast(`Language "${code}" added`, 'success');
|
||||
selectedLang = code;
|
||||
await loadTranslations();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const lc = btn.dataset.lc;
|
||||
if (!confirm(`Remove "${lc}" and all its translations?`)) return;
|
||||
try {
|
||||
await apiDelete('translations.php', { type: 'language', languageCode: lc });
|
||||
showToast(`Language "${lc}" removed`, 'success');
|
||||
if (selectedLang === lc) selectedLang = '';
|
||||
await loadTranslations();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderEntryList() {
|
||||
const listArea = document.getElementById('transListArea');
|
||||
if (!listArea || !transData) return;
|
||||
|
||||
const languages = transData.languages || [];
|
||||
const targets = targetLanguages(languages);
|
||||
const sections = transData.sections || [];
|
||||
|
||||
if (!targets.length) {
|
||||
listArea.innerHTML = `
|
||||
<p class="text-muted trans-hint">Add at least one language under <strong>More languages</strong> to translate into.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedLang) {
|
||||
listArea.innerHTML = `<p class="text-muted trans-hint">Choose a language above.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sections.length) {
|
||||
listArea.innerHTML = `
|
||||
<p class="text-muted trans-hint">No translatable content yet. Add questionnaires and questions in the editor first.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceLabel = sourceLanguageLabel(languages);
|
||||
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase();
|
||||
|
||||
const missingCount = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length;
|
||||
const pct = allEntries.length ? Math.round(((allEntries.length - missingCount) / allEntries.length) * 100) : 0;
|
||||
|
||||
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
|
||||
|
||||
const sectionsHtml = sections.map(sec => {
|
||||
const entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount);
|
||||
const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx);
|
||||
return `
|
||||
<div class="trans-qn-block" data-qn="${esc(sec.questionnaireID)}">
|
||||
<h2 class="trans-qn-title">${esc(sec.name)}</h2>
|
||||
${renderGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, {
|
||||
showColumnHeader: false,
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
listArea.innerHTML = `
|
||||
${headerOnce}
|
||||
<div id="transGroupedRoot">${sectionsHtml}</div>
|
||||
<div class="trans-stats">
|
||||
<span id="transVisibleCount">${missingCount ? `${missingCount} missing · ` : ''}${allEntries.length} keys</span>
|
||||
<span class="trans-stats-progress">
|
||||
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
|
||||
<span class="trans-progress-label">${pct}% in ${esc(selectedLang.toUpperCase())}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bindEntryEditing(allEntries);
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function bindEntryEditing(entries) {
|
||||
document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => {
|
||||
inp.addEventListener('input', () => {
|
||||
const idx = parseInt(inp.dataset.idx, 10);
|
||||
const entry = entries[idx];
|
||||
if (!entry) return;
|
||||
|
||||
const isGerman = inp.dataset.field === 'german';
|
||||
if (isGerman) {
|
||||
entry.germanText = inp.value;
|
||||
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
|
||||
entry.translations[SOURCE_LANG] = inp.value;
|
||||
} else {
|
||||
if (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
|
||||
entry.translations[selectedLang] = inp.value;
|
||||
}
|
||||
|
||||
if (!isGerman) {
|
||||
const missing = !inp.value.trim();
|
||||
inp.classList.toggle('trans-input-missing', missing);
|
||||
const row = inp.closest('.trans-list-item');
|
||||
if (row) {
|
||||
row.classList.toggle('trans-list-item-missing', missing);
|
||||
row.dataset.missing = missing ? '1' : '0';
|
||||
}
|
||||
}
|
||||
|
||||
const timerKey = isGerman ? `${idx}_de` : `${idx}_${selectedLang}`;
|
||||
clearTimeout(saveTimers[timerKey]);
|
||||
saveTimers[timerKey] = setTimeout(() => saveTranslation(entry, inp, isGerman), 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveTranslation(entry, inp, isGerman = false) {
|
||||
try {
|
||||
await apiPut('translations.php', {
|
||||
type: entry.type,
|
||||
id: entry.entityId,
|
||||
languageCode: isGerman ? SOURCE_LANG : selectedLang,
|
||||
text: inp.value,
|
||||
});
|
||||
inp.classList.add('save-flash');
|
||||
setTimeout(() => inp.classList.remove('save-flash'), 400);
|
||||
updateProgress();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const missing = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length;
|
||||
const pct = allEntries.length ? Math.round(((allEntries.length - missing) / allEntries.length) * 100) : 0;
|
||||
const fill = document.querySelector('.trans-progress-fill');
|
||||
if (fill) fill.style.width = `${pct}%`;
|
||||
const label = document.querySelector('.trans-progress-label');
|
||||
if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`;
|
||||
const countEl = document.getElementById('transVisibleCount');
|
||||
if (countEl && !document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
|
||||
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${allEntries.length} keys`;
|
||||
}
|
||||
}
|
||||
|
||||
let filtersBound = false;
|
||||
|
||||
function bindFilters() {
|
||||
if (filtersBound) {
|
||||
applyFilters();
|
||||
return;
|
||||
}
|
||||
filtersBound = true;
|
||||
|
||||
document.getElementById('transFilter')?.addEventListener('input', applyFilters);
|
||||
document.getElementById('transTypeFilter')?.addEventListener('change', applyFilters);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const text = (document.getElementById('transFilter')?.value || '').toLowerCase();
|
||||
const type = document.getElementById('transTypeFilter')?.value || '';
|
||||
const items = document.querySelectorAll('#transGroupedRoot .trans-list-item');
|
||||
let visible = 0;
|
||||
let visibleMissing = 0;
|
||||
|
||||
items.forEach(row => {
|
||||
const key = row.dataset.key?.toLowerCase()
|
||||
|| row.querySelector('.trans-key-text')?.textContent?.toLowerCase() || '';
|
||||
const rowType = row.dataset.type || '';
|
||||
const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase()
|
||||
|| row.querySelector('[data-field="german"]')?.value?.toLowerCase() || '';
|
||||
const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || '';
|
||||
const show = (!type || rowType === type) &&
|
||||
(!text || key.includes(text) || german.includes(text) || val.includes(text));
|
||||
row.classList.toggle('trans-row-hidden', !show);
|
||||
if (show) {
|
||||
visible++;
|
||||
if (row.dataset.missing === '1') visibleMissing++;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('#transGroupedRoot .trans-group').forEach(group => {
|
||||
const rows = group.querySelectorAll('.trans-list-item');
|
||||
const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden'));
|
||||
group.classList.toggle('trans-group-hidden', !anyVisible);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#transGroupedRoot .trans-qn-block').forEach(block => {
|
||||
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
|
||||
block.classList.toggle('trans-qn-hidden', !anyVisible);
|
||||
});
|
||||
|
||||
const countEl = document.getElementById('transVisibleCount');
|
||||
if (countEl) {
|
||||
if (text || type) {
|
||||
countEl.textContent = `Showing ${visible} (${visibleMissing} missing)`;
|
||||
} else {
|
||||
const totalMissing = [...items].filter(r => r.dataset.missing === '1').length;
|
||||
countEl.textContent = `${totalMissing ? `${totalMissing} missing · ` : ''}${items.length} keys`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user