added translation tab

This commit is contained in:
2026-05-22 16:20:00 +02:00
parent c86768e391
commit 0291b8be7a
10 changed files with 1227 additions and 246 deletions

View File

@ -7,6 +7,7 @@ import { exportPage } from './pages/export.js';
import { usersPage } from './pages/users.js';
import { assignmentsPage } from './pages/assignments.js';
import { clientsPage } from './pages/clients.js';
import { translationsPage } from './pages/translations.js';
// Auth state
export function isLoggedIn() {
@ -88,6 +89,7 @@ addRoute('/export', authGuard(exportPage));
addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
// Nav link handling & logout
document.addEventListener('DOMContentLoaded', () => {

View File

@ -1,6 +1,17 @@
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
import { canEdit, showToast } from '../app.js';
import { navigate } from '../router.js';
import {
SOURCE_LANG,
normalizeEntry,
translationFor,
targetLanguages,
buildTranslationGroups,
sourceLanguageLabel,
translationListHeaderHTML,
renderGroupedTranslationsHTML,
escHtml,
} from '../translations-helpers.js';
const LAYOUT_TYPES = [
{ value: 'radio_question', label: 'Radio (Single Choice)' },
@ -427,8 +438,8 @@ function showAddQuestionForm() {
<h4>New Question</h4>
<div class="form-row">
<div class="form-group" style="flex:3">
<label>Question key</label>
<input type="text" id="aq_text" placeholder="e.g. consent_instruction">
<label>German text <span class="required-mark">*</span></label>
<input type="text" id="aq_text" placeholder="e.g. Haben Sie zugestimmt?" required>
</div>
<div class="form-group" style="flex:1;min-width:200px">
<label>Layout type</label>
@ -449,7 +460,7 @@ function showAddQuestionForm() {
<ul class="pending-options-list" id="aq_pending_options"></ul>
<div style="display:flex;gap:8px;margin-top:6px;align-items:flex-end">
<div class="form-group" style="flex:3;margin-bottom:0">
<input type="text" id="aq_opt_text" placeholder="Option key...">
<input type="text" id="aq_opt_text" placeholder="German option text…" required>
</div>
<div class="form-group" style="width:90px;margin-bottom:0">
<input type="number" id="aq_opt_pts" value="0" min="0" placeholder="Pts">
@ -505,7 +516,7 @@ function showAddQuestionForm() {
const text = document.getElementById('aq_opt_text').value.trim();
const pts = parseInt(document.getElementById('aq_opt_pts').value || '0', 10);
const next = document.getElementById('aq_opt_next').value;
if (!text) { showToast('Option key is required', 'error'); return; }
if (!text) { showToast('German text is required', 'error'); return; }
pendingOptions.push({ text, points: pts, nextQuestionId: next });
renderPendingOptions();
document.getElementById('aq_opt_text').value = '';
@ -529,7 +540,7 @@ function showAddQuestionForm() {
const type = document.getElementById('aq_type').value;
const isRequired = document.getElementById('aq_required').checked ? 1 : 0;
const configJson = readConfigFromForm(type, 'aq_cfg');
if (!text) { showToast('Question key is required', 'error'); return; }
if (!text) { showToast('German text is required', 'error'); return; }
if (OPTION_TYPES.has(type) && pendingOptions.length === 0) {
showToast('Add at least one answer option', 'error');
return;
@ -644,8 +655,8 @@ function renderQuestionBody(q) {
<div class="inline-form-card" style="margin-bottom:12px">
<div class="form-row">
<div class="form-group" style="flex:3">
<label>Question key</label>
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}">
<label>German text <span class="required-mark">*</span></label>
<input type="text" id="eq_text_${q.questionID}" value="${esc(q.defaultText)}" required>
</div>
<div class="form-group" style="flex:1;min-width:200px">
<label>Layout type</label>
@ -666,7 +677,7 @@ function renderQuestionBody(q) {
</div>
` : `
<div class="inline-form-card" style="margin-bottom:12px">
<p><strong>Key:</strong> ${esc(q.defaultText)}</p>
<p><strong>German:</strong> ${esc(q.defaultText)}</p>
<p><strong>Layout:</strong> ${esc(layoutLabel(layout))}</p>
${Object.keys(config).length ? `<p><strong>Config:</strong> <code>${esc(JSON.stringify(config))}</code></p>` : ''}
</div>
@ -692,7 +703,7 @@ function renderQuestionBody(q) {
const type = document.getElementById(`eq_type_${q.questionID}`).value;
const isReq = document.getElementById(`eq_req_${q.questionID}`).checked ? 1 : 0;
const cj = readConfigFromForm(type, `eq_cfg_${q.questionID}`);
if (!text) { showToast('Question key is required', 'error'); return; }
if (!text) { showToast('German text is required', 'error'); return; }
updateQuestion(q.questionID, { defaultText: text, type, isRequired: isReq, configJson: cj });
});
@ -749,8 +760,8 @@ function showAddOptionForm(q) {
<div class="inline-form-card" style="margin-top:8px">
<div class="form-row">
<div class="form-group" style="flex:3">
<label>Option key</label>
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. consent_signed">
<label>German text <span class="required-mark">*</span></label>
<input type="text" id="ao_text_${q.questionID}" placeholder="e.g. Ja, ich stimme zu" required>
</div>
<div class="form-group" style="flex:1;min-width:100px">
<label>Points</label>
@ -777,7 +788,7 @@ function showAddOptionForm(q) {
const text = document.getElementById(`ao_text_${q.questionID}`).value.trim();
const points = parseInt(document.getElementById(`ao_pts_${q.questionID}`).value || '0', 10);
const nextQ = document.getElementById(`ao_next_${q.questionID}`).value;
if (!text) { showToast('Option key is required', 'error'); return; }
if (!text) { showToast('German text is required', 'error'); return; }
const btn = document.getElementById(`ao_submit_${q.questionID}`);
btn.disabled = true;
@ -824,7 +835,8 @@ function showEditOptionForm(q, opt) {
<div class="inline-form-card" style="width:100%;padding:8px">
<div class="form-row">
<div class="form-group" style="flex:3;margin-bottom:8px">
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(opt.defaultText)}">
<label style="font-size:.8rem">German text <span class="required-mark">*</span></label>
<input type="text" id="eo_text_${opt.answerOptionID}" value="${esc(opt.defaultText)}" required>
</div>
<div class="form-group" style="flex:1;min-width:80px;margin-bottom:8px">
<input type="number" id="eo_pts_${opt.answerOptionID}" value="${opt.points}" min="0">
@ -850,7 +862,7 @@ function showEditOptionForm(q, opt) {
const text = document.getElementById(`eo_text_${opt.answerOptionID}`).value.trim();
const points = parseInt(document.getElementById(`eo_pts_${opt.answerOptionID}`).value || '0', 10);
const nextQ = document.getElementById(`eo_next_${opt.answerOptionID}`).value;
if (!text) { showToast('Option key is required', 'error'); return; }
if (!text) { showToast('German text is required', 'error'); return; }
await updateOption(q, opt.answerOptionID, { defaultText: text, points, nextQuestionId: nextQ });
});
@ -923,6 +935,8 @@ async function deleteOption(q, answerOptionID) {
// ── Translations tab ─────────────────────────────────────────────────────
let transData = null;
let editorTransLang = '';
let editorTransSaveTimers = {};
async function loadTranslationsTab() {
const container = document.getElementById('translationsContent');
@ -930,8 +944,12 @@ async function loadTranslationsTab() {
container.innerHTML = '<div class="spinner"></div>';
try {
try {
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
} catch (_) { /* already exists */ }
const data = await apiGet(`translations.php?questionnaireID=${encodeURIComponent(questionnaire.questionnaireID)}`);
transData = data;
transData.entries = (transData.entries || []).map(normalizeEntry);
renderTranslationsTab();
} catch (e) {
container.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
@ -944,99 +962,193 @@ function renderTranslationsTab() {
const editable = canEdit();
const languages = transData.languages || [];
const entries = transData.entries || [];
const langCodes = languages.map(l => l.languageCode);
const targets = targetLanguages(languages);
if (!entries.length) {
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
<p style="color:var(--text-secondary);margin-top:12px">No translatable keys found. Add questions first.</p>
<p style="color:var(--text-secondary);margin-top:12px">No translatable content yet. Add questions with German text first.</p>
`;
if (editable) bindLanguageManager(languages);
return;
}
const filterHtml = `
<div class="trans-toolbar">
<div class="trans-search-wrap">
<input type="text" id="transFilter" placeholder="Filter keys..." class="trans-search-input">
</div>
<div class="trans-filter-types">
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="question" checked> Questions</label>
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="answer_option" checked> Options</label>
<label class="checkbox-label"><input type="checkbox" class="trans-type-filter" value="string" checked> Strings</label>
</div>
</div>
`;
if (!editorTransLang || !targets.some(l => l.languageCode === editorTransLang)) {
editorTransLang = targets[0]?.languageCode || '';
}
const typeBadge = (type) => {
const map = { question: 'Q', answer_option: 'Opt', string: 'Str' };
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' };
return `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
};
const langSelectHtml = targets.length
? `<label class="trans-control" style="max-width:280px;margin-bottom:14px">
<span>Translate into</span>
<select id="editorTransLangSelect" class="trans-select">
${targets.map(l => {
const label = l.name ? `${l.name} (${l.languageCode})` : l.languageCode;
return `<option value="${escHtml(l.languageCode)}" ${l.languageCode === editorTransLang ? 'selected' : ''}>${escHtml(label)}</option>`;
}).join('')}
</select>
</label>`
: `<p class="text-muted" style="margin-bottom:14px">Add a language below (e.g. English) to translate into.</p>`;
container.innerHTML = `
${editable ? languageManagerHTML(languages) : ''}
${filterHtml}
<div class="table-wrapper trans-table-wrapper">
<table class="data-table trans-table" id="transTable">
<thead>
<tr>
<th class="col-key">Key</th>
<th class="col-type">Type</th>
${langCodes.map(lc => `<th class="col-lang">${esc(lc.toUpperCase())}</th>`).join('')}
</tr>
</thead>
<tbody>
${entries.map((entry, i) => `
<tr data-idx="${i}" data-type="${entry.type}" data-key="${esc(entry.key)}">
<td class="cell-key" title="${esc(entry.key)}">${esc(entry.key)}</td>
<td class="cell-type">${typeBadge(entry.type)}</td>
${langCodes.map(lc => {
const val = entry.translations[lc] || '';
const missing = !val;
return `<td class="cell-trans ${missing ? 'cell-missing' : ''}">
<input type="text"
class="trans-cell-input"
data-idx="${i}"
data-lang="${esc(lc)}"
value="${esc(val)}"
${editable ? '' : 'disabled'}
placeholder="${missing ? 'missing' : ''}">
</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="trans-stats" id="transStats"></div>
${langSelectHtml}
${targets.length && editorTransLang ? renderEditorTransList(entries, editable) : ''}
`;
updateTransStats(entries, langCodes);
if (editable) bindLanguageManager(languages);
if (editable) {
bindLanguageManager(languages);
bindTransCellEditing(entries);
document.getElementById('editorTransLangSelect')?.addEventListener('change', (e) => {
editorTransLang = e.target.value;
renderTranslationsTab();
});
if (targets.length && editorTransLang) {
bindEditorTransEditing(entries, editable);
bindEditorTransFiltering(entries);
}
bindTransFiltering();
}
function renderEditorTransList(entries, editable) {
const targets = targetLanguages(transData.languages || []);
const langLabel = targets.find(l => l.languageCode === editorTransLang)?.name || editorTransLang.toUpperCase();
const sourceLabel = sourceLanguageLabel(transData.languages || []);
const groups = buildTranslationGroups(entries, editorTransLang, 0);
const missingCount = entries.filter(e => !translationFor(e, editorTransLang).trim()).length;
const pct = entries.length ? Math.round(((entries.length - missingCount) / entries.length) * 100) : 0;
return `
<div class="trans-toolbar">
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…">
<select id="transTypeFilter" class="trans-select trans-type-select">
<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>
${translationListHeaderHTML(langLabel, sourceLabel)}
<div id="transGroupedRoot">
${renderGroupedTranslationsHTML(groups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
</div>
<div class="trans-stats">
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${entries.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 ${escHtml(editorTransLang.toUpperCase())}</span>
</span>
</div>
`;
}
function bindEditorTransEditing(entries, editable) {
if (!editable) return;
editorTransSaveTimers = {};
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 (!entry.translations || typeof entry.translations !== 'object') entry.translations = {};
if (isGerman) {
entry.germanText = inp.value;
entry.translations[SOURCE_LANG] = inp.value;
} else {
entry.translations[editorTransLang] = inp.value;
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}_${editorTransLang}`;
clearTimeout(editorTransSaveTimers[timerKey]);
editorTransSaveTimers[timerKey] = setTimeout(() => saveEditorTranslation(entry, inp, isGerman), 500);
});
});
}
async function saveEditorTranslation(entry, inp, isGerman) {
try {
await apiPut('translations.php', {
type: entry.type,
id: entry.entityId,
languageCode: isGerman ? SOURCE_LANG : editorTransLang,
text: inp.value,
});
inp.classList.add('save-flash');
setTimeout(() => inp.classList.remove('save-flash'), 400);
} catch (e) {
showToast(e.message, 'error');
}
}
function bindEditorTransFiltering(entries) {
const filterInput = document.getElementById('transFilter');
const typeFilter = document.getElementById('transTypeFilter');
const root = document.getElementById('transGroupedRoot');
if (!filterInput || !root) return;
function applyFilter() {
const text = filterInput.value.toLowerCase();
const type = typeFilter?.value || '';
let visible = 0;
let visibleMissing = 0;
root.querySelectorAll('.trans-list-item').forEach(row => {
const key = row.dataset.key?.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++;
}
});
root.querySelectorAll('.trans-group').forEach(group => {
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible);
});
const stats = document.getElementById('transStats');
if (stats) {
stats.textContent = text || type
? `Showing ${visible} (${visibleMissing} missing)`
: `${[...root.querySelectorAll('.trans-list-item')].filter(r => r.dataset.missing === '1').length} missing · ${entries.length} keys`;
}
}
filterInput.addEventListener('input', applyFilter);
typeFilter?.addEventListener('change', applyFilter);
}
function languageManagerHTML(languages) {
const others = targetLanguages(languages);
return `
<div class="lang-manager">
<p style="font-size:.85rem;color:var(--text-secondary);margin:0 0 10px">
German source text is edited under Questions. Add other languages here.
</p>
<div class="lang-chips">
${languages.map(l => `
<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 class="lang-chip-remove" data-lc="${esc(l.languageCode)}" title="Remove language">&times;</button>
</span>
`).join('')}
${!languages.length ? '<span style="color:var(--text-secondary);font-size:.85rem">No languages defined yet.</span>' : ''}
</div>
<div class="lang-add-row">
<input type="text" id="newLangCode" placeholder="Code (e.g. de)" class="lang-add-code">
<input type="text" id="newLangName" placeholder="Name (e.g. German)" class="lang-add-name">
<input type="text" id="newLangCode" placeholder="Code (en)" class="lang-add-code">
<input type="text" id="newLangName" placeholder="Name (English)" class="lang-add-name">
<button class="btn btn-sm btn-primary" id="addLangBtn">+ Add Language</button>
</div>
</div>
@ -1048,6 +1160,10 @@ function bindLanguageManager(languages) {
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;
@ -1055,6 +1171,7 @@ function bindLanguageManager(languages) {
try {
await apiPut('translations.php', { type: 'language', languageCode: code, name });
showToast(`Language "${code}" added`, 'success');
editorTransLang = code;
loadTranslationsTab();
} catch (e) {
showToast(e.message, 'error');
@ -1076,99 +1193,6 @@ function bindLanguageManager(languages) {
});
}
function bindTransCellEditing(entries) {
const debounceTimers = {};
document.querySelectorAll('.trans-cell-input').forEach(inp => {
inp.addEventListener('input', () => {
const idx = parseInt(inp.dataset.idx, 10);
const lang = inp.dataset.lang;
const entry = entries[idx];
if (!entry) return;
entry.translations[lang] = inp.value;
const cell = inp.closest('td');
cell.classList.toggle('cell-missing', !inp.value);
const timerKey = `${idx}_${lang}`;
clearTimeout(debounceTimers[timerKey]);
debounceTimers[timerKey] = setTimeout(async () => {
try {
await apiPut('translations.php', {
type: entry.type,
id: entry.entityId,
languageCode: lang,
text: inp.value,
});
inp.classList.add('save-flash');
setTimeout(() => inp.classList.remove('save-flash'), 400);
} catch (e) {
showToast(e.message, 'error');
}
}, 600);
});
inp.addEventListener('keydown', (e) => {
if (e.key === 'Tab' || e.key === 'Enter') return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const row = inp.closest('tr');
const cellIdx = [...row.children].indexOf(inp.closest('td'));
const targetRow = e.key === 'ArrowDown' ? row.nextElementSibling : row.previousElementSibling;
if (targetRow && !targetRow.classList.contains('trans-row-hidden')) {
const targetInput = targetRow.children[cellIdx]?.querySelector('input');
if (targetInput) targetInput.focus();
}
}
});
});
}
function bindTransFiltering() {
const filterInput = document.getElementById('transFilter');
const typeChecks = document.querySelectorAll('.trans-type-filter');
const tbody = document.querySelector('#transTable tbody');
if (!filterInput || !tbody) return;
function applyFilter() {
const text = filterInput.value.toLowerCase();
const activeTypes = new Set([...typeChecks].filter(c => c.checked).map(c => c.value));
let visible = 0;
tbody.querySelectorAll('tr').forEach(row => {
const key = row.dataset.key?.toLowerCase() || '';
const type = row.dataset.type || '';
const show = activeTypes.has(type) && (!text || key.includes(text));
row.classList.toggle('trans-row-hidden', !show);
if (show) visible++;
});
const stats = document.getElementById('transStats');
if (stats) stats.textContent = `Showing ${visible} of ${tbody.children.length} keys`;
}
filterInput.addEventListener('input', applyFilter);
typeChecks.forEach(c => c.addEventListener('change', applyFilter));
}
function updateTransStats(entries, langCodes) {
const stats = document.getElementById('transStats');
if (!stats) return;
const total = entries.length * langCodes.length;
let filled = 0;
for (const e of entries) {
for (const lc of langCodes) {
if (e.translations[lc]) filled++;
}
}
const pct = total ? Math.round((filled / total) * 100) : 0;
stats.innerHTML = `
<span>${entries.length} keys &times; ${langCodes.length} languages = ${total} cells</span>
<span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span>
<strong>${filled}/${total}</strong> (${pct}%) translated
</span>
`;
}
// ── Drag & drop (questions) ──────────────────────────────────────────────
function initDragReorder() {

View 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">&times;</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`;
}
}
}

View File

@ -0,0 +1,185 @@
/** German (de) is the canonical source language for questionnaire content. */
export const SOURCE_LANG = 'de';
export function normalizeTranslations(raw) {
if (!raw) return {};
if (Array.isArray(raw)) {
const map = {};
raw.forEach(row => {
if (row && row.languageCode) map[row.languageCode] = row.text ?? '';
});
return map;
}
return typeof raw === 'object' ? { ...raw } : {};
}
export function normalizeEntry(entry) {
const translations = normalizeTranslations(entry.translations);
const germanText = (entry.germanText || translations[SOURCE_LANG] || entry.key || '').trim()
|| entry.key
|| '';
return { ...entry, translations, germanText };
}
export function germanFor(entry) {
return entry.germanText || normalizeTranslations(entry.translations)[SOURCE_LANG] || entry.key || '';
}
export function translationFor(entry, lang) {
return normalizeTranslations(entry.translations)[lang] || '';
}
export function targetLanguages(languages) {
return (languages || []).filter(l => l.languageCode !== SOURCE_LANG);
}
/** Sort rows within a group: missing translations first, then questionnaire order */
export function sortGroupItems(items, targetLang) {
return [...items].sort((a, b) => {
const aMissing = !translationFor(a.entry, targetLang).trim();
const bMissing = !translationFor(b.entry, targetLang).trim();
if (aMissing !== bMissing) return aMissing ? -1 : 1;
const orderA = a.entry.sortOrder ?? 0;
const orderB = b.entry.sortOrder ?? 0;
if (orderA !== orderB) return orderA - orderB;
const typeCmp = (a.entry.type === 'question' ? 0 : 1) - (b.entry.type === 'question' ? 0 : 1);
if (typeCmp !== 0) return typeCmp;
return germanFor(a.entry).localeCompare(germanFor(b.entry), 'de');
});
}
/**
* Groups for one questionnaire: UI strings, then questions + options (interleaved).
* @param {number} indexOffset - global index for save handlers
*/
export function buildTranslationGroups(entries, targetLang, indexOffset = 0) {
const items = entries.map((entry, i) => ({ entry, idx: indexOffset + i }));
const strings = items.filter(({ entry }) => entry.type === 'string');
const content = items.filter(({ entry }) => entry.type !== 'string');
const groups = [];
if (strings.length) {
groups.push({
id: 'strings',
title: 'UI strings',
items: sortGroupItems(strings, targetLang),
});
}
if (content.length) {
groups.push({
id: 'content',
title: 'Questions',
items: sortGroupItems(content, targetLang),
});
}
return groups;
}
/** Render grouped sections (optional questionnaire wrapper title). */
export function renderGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) {
const { showColumnHeader = true, questionnaireTitle = null } = options;
if (!groups.length) return '';
const header = showColumnHeader ? translationListHeaderHTML(
targetLang,
sourceLabel
) : '';
const qnHeader = questionnaireTitle
? `<h2 class="trans-qn-title">${escHtml(questionnaireTitle)}</h2>`
: '';
const body = groups.map(g => `
<section class="trans-group" data-group="${escHtml(g.id)}">
<h3 class="trans-group-title">${escHtml(g.title)}</h3>
<div class="trans-list trans-list-in-group">
${g.items.map(({ entry, idx }) =>
translationRowHTML(entry, idx, { targetLang, editable })
).join('')}
</div>
</section>
`).join('');
const wrapClass = questionnaireTitle ? 'trans-qn-block' : 'trans-groups-wrap';
return `<div class="${wrapClass}">${qnHeader}${header}${body}</div>`;
}
/** Flatten questionnaires from ?all=1 into one entries array + section metadata */
export function flattenAllQuestionnaires(questionnaires) {
const allEntries = [];
const sections = [];
for (const qn of questionnaires) {
const normalized = (qn.entries || []).map(normalizeEntry);
if (!normalized.length) continue;
const startIdx = allEntries.length;
normalized.forEach(e => allEntries.push(e));
sections.push({
questionnaireID: qn.questionnaireID,
name: qn.name || 'Questionnaire',
startIdx,
entryCount: normalized.length,
});
}
return { allEntries, sections };
}
export function typeBadge(type) {
const map = { question: 'Q', answer_option: 'Opt', string: 'Str' };
const cls = { question: 'badge-q', answer_option: 'badge-opt', string: 'badge-str' };
return `<span class="trans-type-badge ${cls[type] || ''}">${map[type] || type}</span>`;
}
export function escHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function sourceLanguageLabel(languages) {
const row = (languages || []).find(l => l.languageCode === SOURCE_LANG);
return row?.name ? `${row.name} (${SOURCE_LANG})` : 'German';
}
/** Column headers: Key | main language (German) | target language */
export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') {
return `
<div class="trans-list-header">
<span class="trans-col-key">Key</span>
<span class="trans-col-source">${escHtml(sourceLabel)}</span>
<span class="trans-col-target">${escHtml(targetLangLabel)}</span>
</div>`;
}
export function translationRowHTML(entry, idx, { targetLang, editable = true }) {
const key = entry.key || '';
const german = germanFor(entry);
const val = translationFor(entry, targetLang);
const missing = !val.trim();
const disabled = editable ? '' : 'disabled';
const sourceCell = entry.type === 'string' && editable
? `<input type="text" class="trans-cell-input" data-idx="${idx}" data-field="german"
value="${escHtml(german)}" placeholder="German text…" autocomplete="off">`
: `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`;
return `
<div class="trans-list-item ${missing ? 'trans-list-item-missing' : ''}"
data-idx="${idx}" data-type="${entry.type}" data-key="${escHtml(key)}" data-missing="${missing ? '1' : '0'}">
<div class="trans-list-key">
${typeBadge(entry.type)}
<span class="trans-key-text" title="${escHtml(key)}">${escHtml(key)}</span>
</div>
<div class="trans-list-source">${sourceCell}</div>
<input type="text" class="trans-cell-input ${missing ? 'trans-input-missing' : ''}"
data-idx="${idx}" data-field="translation" value="${escHtml(val)}"
placeholder="Translation…" ${disabled} autocomplete="off">
</div>`;
}