385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
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`;
|
|
}
|
|
}
|
|
}
|