improved translation page UX
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-12 20:33:52 +02:00
parent d807f753b1
commit cceec525c1
4 changed files with 488 additions and 76 deletions

View File

@ -1586,9 +1586,30 @@ body.modal-open {
.trans-hint { padding: 24px 0; text-align: center; font-size: .9rem; } .trans-hint { padding: 24px 0; text-align: center; font-size: .9rem; }
.text-muted { color: var(--text-secondary); } .text-muted { color: var(--text-secondary); }
.required-mark { color: var(--danger); font-weight: 600; } .required-mark { color: var(--danger); font-weight: 600; }
.trans-sheet {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
display: flex;
flex-direction: column;
max-height: calc(100vh - 420px);
overflow: hidden;
}
.trans-sheet-body {
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
}
.trans-sheet .trans-list-header {
flex-shrink: 0;
border: none;
border-bottom: 1px solid var(--border);
border-radius: 0;
margin: 0;
}
.trans-list-header { .trans-list-header {
display: grid; display: grid;
grid-template-columns: minmax(100px, 0.75fr) 1fr 1fr; grid-template-columns: minmax(140px, 1.1fr) 1fr 1fr;
gap: 12px; gap: 12px;
padding: 8px 14px; padding: 8px 14px;
font-size: .75rem; font-size: .75rem;
@ -1597,10 +1618,51 @@ body.modal-open {
letter-spacing: .04em; letter-spacing: .04em;
color: var(--text-secondary); color: var(--text-secondary);
background: var(--table-header-bg); background: var(--table-header-bg);
border: 1px solid var(--border);
border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
} }
.trans-list-header-sticky {
position: sticky;
top: 0;
z-index: 2;
box-shadow: 0 1px 0 var(--border);
}
.trans-top-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
margin-bottom: 12px;
padding: 12px 14px;
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.trans-top-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
font-size: .88rem;
color: var(--text-secondary);
}
.trans-top-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.trans-save-status {
font-size: .82rem;
color: var(--text-secondary);
}
.trans-save-status[data-state="saving"] { color: var(--primary); }
.trans-save-status[data-state="error"] { color: var(--danger); }
.trans-toolbar-field {
min-width: 180px;
max-width: 280px;
margin: 0;
}
.trans-empty-hint { margin-top: 12px; }
.trans-qn-block { .trans-qn-block {
margin-bottom: 28px; margin-bottom: 28px;
padding-bottom: 8px; padding-bottom: 8px;
@ -1628,31 +1690,108 @@ body.modal-open {
padding-bottom: 6px; padding-bottom: 6px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.trans-groups-wrap .trans-list-header { margin-top: 0; } .trans-groups-flat { display: block; }
.trans-list-in-group { .trans-group-rows { display: block; }
.trans-scope-block .trans-group-details:first-child { border-top: none; }
.trans-group-details {
border: none;
border-top: 1px solid var(--border);
border-radius: 0;
margin: 0;
background: transparent;
}
.trans-group-details.trans-group-hidden { display: none !important; }
.trans-group-summary {
cursor: pointer;
font-weight: 600;
font-size: .8rem;
padding: 10px 14px;
list-style: none;
user-select: none;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .04em;
background: var(--surface-muted);
}
.trans-group-summary::-webkit-details-marker { display: none; }
.trans-group-summary::before {
content: '▸ ';
color: var(--text-secondary);
}
.trans-group-details[open] > .trans-group-summary::before {
content: '▾ ';
}
.trans-qn-section {
border: none;
border-top: 1px solid var(--border);
border-radius: 0;
margin: 0;
background: transparent;
}
.trans-qn-section.trans-qn-hidden { display: none !important; }
.trans-qn-section:first-child { border-top: none; }
.trans-qn-summary {
cursor: pointer;
font-weight: 600;
font-size: .92rem;
padding: 12px 14px;
list-style: none;
user-select: none;
color: var(--text);
background: var(--surface-muted);
}
.trans-qn-summary::-webkit-details-marker { display: none; }
.trans-qn-summary::before {
content: '▸ ';
color: var(--text-secondary);
}
.trans-qn-section[open] > .trans-qn-summary::before {
content: '▾ ';
}
.trans-sheet-body-stack .trans-group-summary {
font-size: .78rem;
padding: 8px 14px 8px 22px;
background: transparent;
}
.trans-list {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
} }
#transGroupedRoot {
max-height: calc(100vh - 360px);
overflow-y: auto;
}
.trans-list {
border: 1px solid var(--border);
border-radius: 0 0 var(--radius) var(--radius);
max-height: calc(100vh - 360px);
overflow-y: auto;
}
.trans-list-item { .trans-list-item {
display: grid; display: grid;
grid-template-columns: minmax(100px, 0.75fr) 1fr 1fr; grid-template-columns: minmax(140px, 1.1fr) 1fr 1fr;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
padding: 10px 14px; padding: 10px 14px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.trans-list-item:last-child { border-bottom: none; } .trans-key-labels {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.trans-key-primary {
font-size: .88rem;
font-weight: 500;
color: var(--text);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
}
.trans-key-secondary {
font-size: .72rem;
font-family: var(--font-mono, ui-monospace, monospace);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trans-sheet-body > .trans-scope-block .trans-group-details:last-child .trans-list-item:last-child,
.trans-sheet-body > .trans-groups-flat .trans-group-details:last-child .trans-list-item:last-child {
border-bottom: none;
}
.trans-list-item:hover { background: var(--row-hover-bg); } .trans-list-item:hover { background: var(--row-hover-bg); }
.trans-list-item-missing { .trans-list-item-missing {
background: var(--trans-missing-bg); background: var(--trans-missing-bg);
@ -1710,7 +1849,7 @@ body.modal-open {
} }
.trans-list-header { display: none; } .trans-list-header { display: none; }
.trans-list-item .trans-list-key::before { .trans-list-item .trans-list-key::before {
content: 'Key'; content: 'Label';
display: block; display: block;
font-size: .7rem; font-size: .7rem;
font-weight: 600; font-weight: 600;

View File

@ -9,7 +9,7 @@ import {
buildTranslationGroups, buildTranslationGroups,
sourceLanguageLabel, sourceLanguageLabel,
translationListHeaderHTML, translationListHeaderHTML,
renderGroupedTranslationsHTML, renderCollapsibleGroupedTranslationsHTML,
escHtml, escHtml,
} from '../translations-helpers.js'; } from '../translations-helpers.js';
import { import {
@ -1726,12 +1726,9 @@ function renderEditorTransList(qnEntries, editable) {
const pct = qnEntries.length ? Math.round(((qnEntries.length - missingCount) / qnEntries.length) * 100) : 0; const pct = qnEntries.length ? Math.round(((qnEntries.length - missingCount) / qnEntries.length) * 100) : 0;
const qnGroups = buildTranslationGroups(qnEntries, editorTransLang, 0); const qnGroups = buildTranslationGroups(qnEntries, editorTransLang, 0);
const qnName = transData.questionnaire?.name || questionnaire?.name || 'Questionnaire'; const bodyHtml = renderCollapsibleGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, {
const bodyHtml = ` openGroupsWithMissing: true,
<div class="trans-qn-block" data-qn="${escHtml(questionnaire.questionnaireID)}"> });
<h2 class="trans-qn-title">${escHtml(qnName)}</h2>
${renderGroupedTranslationsHTML(qnGroups, editorTransLang, sourceLabel, editable, { showColumnHeader: false })}
</div>`;
return ` return `
<div class="trans-toolbar"> <div class="trans-toolbar">
@ -1743,8 +1740,12 @@ function renderEditorTransList(qnEntries, editable) {
<option value="answer_option">Answer options</option> <option value="answer_option">Answer options</option>
</select> </select>
</div> </div>
<div class="trans-sheet">
${translationListHeaderHTML(langLabel, sourceLabel)} ${translationListHeaderHTML(langLabel, sourceLabel)}
<div id="transGroupedRoot">${bodyHtml}</div> <div id="transGroupedRoot" class="trans-sheet-body">
<div class="trans-scope-block">${bodyHtml}</div>
</div>
</div>
<div class="trans-stats"> <div class="trans-stats">
<span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys</span> <span id="transStats">${missingCount ? `${missingCount} missing · ` : ''}${qnEntries.length} keys</span>
<span class="trans-stats-progress"> <span class="trans-stats-progress">
@ -1826,11 +1827,11 @@ function bindEditorTransFiltering(entries) {
if (row.dataset.missing === '1') visibleMissing++; if (row.dataset.missing === '1') visibleMissing++;
} }
}); });
root.querySelectorAll('.trans-group').forEach(group => { root.querySelectorAll('.trans-group-details').forEach(group => {
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible); group.classList.toggle('trans-group-hidden', !anyVisible);
}); });
root.querySelectorAll('.trans-qn-block').forEach(block => { root.querySelectorAll('.trans-scope-block').forEach(block => {
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
block.classList.toggle('trans-qn-hidden', !anyVisible); block.classList.toggle('trans-qn-hidden', !anyVisible);
}); });

View File

@ -8,15 +8,25 @@ import {
sourceLanguageLabel, sourceLanguageLabel,
translationListHeaderHTML, translationListHeaderHTML,
buildTranslationGroups, buildTranslationGroups,
renderGroupedTranslationsHTML, renderCollapsibleGroupedTranslationsHTML,
flattenAllQuestionnaires, flattenAllQuestionnaires,
countMissing,
sectionEntries,
escHtml as esc, escHtml as esc,
} from '../translations-helpers.js'; } from '../translations-helpers.js';
const STORAGE_LANG = 'qdb_trans_lang';
const STORAGE_SCOPE = 'qdb_trans_scope';
const STORAGE_SHOW = 'qdb_trans_show_mode';
let transData = null; let transData = null;
let allEntries = []; let allEntries = [];
let selectedLang = ''; let selectedLang = '';
let selectedScope = '';
/** @type {'missing' | 'all' | 'complete'} */
let showMode = 'missing';
let saveTimers = {}; let saveTimers = {};
let savesInFlight = 0;
export async function translationsPage() { export async function translationsPage() {
const app = document.getElementById('app'); const app = document.getElementById('app');
@ -61,7 +71,21 @@ function renderShell() {
</details> </details>
<div class="trans-toolbar"> <div class="trans-toolbar">
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search…" disabled> <label class="trans-control trans-toolbar-field">
<span>Content</span>
<select id="transScopeSelect" class="trans-select" disabled>
<option value="">Loading…</option>
</select>
</label>
<label class="trans-control trans-toolbar-field">
<span>Show</span>
<select id="transShowMode" class="trans-select trans-type-select" disabled>
<option value="missing">Missing only</option>
<option value="all">All</option>
<option value="complete">Complete only</option>
</select>
</label>
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search in scope…" disabled>
<select id="transTypeFilter" class="trans-select trans-type-select" disabled> <select id="transTypeFilter" class="trans-select trans-type-select" disabled>
<option value="">All types</option> <option value="">All types</option>
<option value="app_string">App strings</option> <option value="app_string">App strings</option>
@ -79,12 +103,75 @@ function renderShell() {
function pickDefaultTargetLang(languages) { function pickDefaultTargetLang(languages) {
const targets = targetLanguages(languages); const targets = targetLanguages(languages);
if (!targets.length) return ''; if (!targets.length) return '';
const stored = localStorage.getItem(STORAGE_LANG);
if (stored && targets.some(l => l.languageCode === stored)) {
return stored;
}
if (selectedLang && targets.some(l => l.languageCode === selectedLang)) { if (selectedLang && targets.some(l => l.languageCode === selectedLang)) {
return selectedLang; return selectedLang;
} }
return targets[0].languageCode; return targets[0].languageCode;
} }
function pickDefaultShowMode() {
const stored = localStorage.getItem(STORAGE_SHOW);
if (stored === 'missing' || stored === 'all' || stored === 'complete') {
return stored;
}
return 'missing';
}
function pickDefaultScope(sections, entries, lang) {
const stored = localStorage.getItem(STORAGE_SCOPE);
if (stored === '__all__' || sections.some(s => s.questionnaireID === stored)) {
return stored;
}
for (const sec of sections) {
const slice = sectionEntries(entries, sec);
if (countMissing(slice, lang) > 0) {
return sec.questionnaireID;
}
}
return sections[0]?.questionnaireID || '__app__';
}
function scopeLabel(sec, entries, lang) {
const slice = sectionEntries(entries, sec);
const missing = countMissing(slice, lang);
const suffix = missing ? ` (${missing} missing)` : ' (complete)';
return `${sec.name}${suffix}`;
}
function scopedSections() {
const sections = transData?.sections || [];
if (!selectedScope || selectedScope === '__all__') {
return sections;
}
return sections.filter(s => s.questionnaireID === selectedScope);
}
function entriesInScope() {
const sections = scopedSections();
const out = [];
for (const sec of sections) {
out.push(...sectionEntries(allEntries, sec));
}
return out;
}
function setSaveStatus(state) {
const el = document.getElementById('transSaveStatus');
if (!el) return;
el.dataset.state = state;
if (state === 'saving') {
el.textContent = 'Saving…';
} else if (state === 'error') {
el.textContent = 'Save failed';
} else {
el.textContent = 'All changes saved';
}
}
async function ensureGermanLanguage() { async function ensureGermanLanguage() {
try { try {
await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' }); await apiPut('translations.php', { type: 'language', languageCode: SOURCE_LANG, name: 'German' });
@ -112,6 +199,8 @@ async function loadTranslations() {
transData.sections = flat.sections; transData.sections = flat.sections;
selectedLang = pickDefaultTargetLang(languages); selectedLang = pickDefaultTargetLang(languages);
showMode = pickDefaultShowMode();
selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang);
if (langSelect) { if (langSelect) {
const targets = targetLanguages(languages); const targets = targetLanguages(languages);
@ -124,16 +213,40 @@ async function loadTranslations() {
: '<option value="">Add a language below</option>'; : '<option value="">Add a language below</option>';
langSelect.onchange = () => { langSelect.onchange = () => {
selectedLang = langSelect.value; selectedLang = langSelect.value;
localStorage.setItem(STORAGE_LANG, selectedLang);
populateScopeSelect();
renderEntryList(); renderEntryList();
}; };
} }
const scopeSelect = document.getElementById('transScopeSelect');
const showSelect = document.getElementById('transShowMode');
if (scopeSelect) {
scopeSelect.disabled = false;
populateScopeSelect();
scopeSelect.onchange = () => {
selectedScope = scopeSelect.value;
localStorage.setItem(STORAGE_SCOPE, selectedScope);
renderEntryList();
};
}
if (showSelect) {
showSelect.disabled = false;
showSelect.value = showMode;
showSelect.onchange = () => {
showMode = showSelect.value;
localStorage.setItem(STORAGE_SHOW, showMode);
applyFilters();
};
}
if (filter) filter.disabled = false; if (filter) filter.disabled = false;
if (typeFilter) typeFilter.disabled = false; if (typeFilter) typeFilter.disabled = false;
renderLanguagePanel(languages); renderLanguagePanel(languages);
renderEntryList(); renderEntryList();
bindFilters(); bindFilters();
setSaveStatus('saved');
} catch (e) { } catch (e) {
if (listArea) listArea.innerHTML = `<p class="error-text">${esc(e.message)}</p>`; if (listArea) listArea.innerHTML = `<p class="error-text">${esc(e.message)}</p>`;
showToast(e.message, 'error'); showToast(e.message, 'error');
@ -208,13 +321,31 @@ function renderLanguagePanel(languages) {
}); });
} }
function populateScopeSelect() {
const scopeSelect = document.getElementById('transScopeSelect');
if (!scopeSelect || !transData) return;
const sections = transData.sections || [];
const options = sections.map(sec =>
`<option value="${esc(sec.questionnaireID)}"${sec.questionnaireID === selectedScope ? ' selected' : ''}>${esc(scopeLabel(sec, allEntries, selectedLang))}</option>`
);
options.push(`<option value="__all__"${selectedScope === '__all__' ? ' selected' : ''}>All content</option>`);
scopeSelect.innerHTML = options.join('');
if (!selectedScope || (selectedScope !== '__all__' && !sections.some(s => s.questionnaireID === selectedScope))) {
selectedScope = pickDefaultScope(sections, allEntries, selectedLang);
scopeSelect.value = selectedScope;
localStorage.setItem(STORAGE_SCOPE, selectedScope);
}
}
function renderEntryList() { function renderEntryList() {
const listArea = document.getElementById('transListArea'); const listArea = document.getElementById('transListArea');
if (!listArea || !transData) return; if (!listArea || !transData) return;
const languages = transData.languages || []; const languages = transData.languages || [];
const targets = targetLanguages(languages); const targets = targetLanguages(languages);
const sections = transData.sections || []; const sections = scopedSections();
if (!targets.length) { if (!targets.length) {
listArea.innerHTML = ` listArea.innerHTML = `
@ -227,7 +358,7 @@ function renderEntryList() {
return; return;
} }
if (!sections.length) { if (!transData.sections?.length) {
listArea.innerHTML = ` listArea.innerHTML = `
<p class="text-muted trans-hint">No translatable content yet. Add questionnaires and questions in the editor first.</p>`; <p class="text-muted trans-hint">No translatable content yet. Add questionnaires and questions in the editor first.</p>`;
return; return;
@ -235,44 +366,97 @@ function renderEntryList() {
const sourceLabel = sourceLanguageLabel(languages); const sourceLabel = sourceLanguageLabel(languages);
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase(); const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase();
const scopeEntries = entriesInScope();
const missingCount = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length; const missingCount = countMissing(scopeEntries, selectedLang);
const pct = allEntries.length ? Math.round(((allEntries.length - missingCount) / allEntries.length) * 100) : 0; const pct = scopeEntries.length
? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100)
: 0;
const headerOnce = translationListHeaderHTML(langLabel, sourceLabel); const headerOnce = translationListHeaderHTML(langLabel, sourceLabel);
const useQnDetails = selectedScope === '__all__';
const sectionsHtml = sections.map(sec => { const sectionsHtml = sections.map(sec => {
let entries = allEntries.slice(sec.startIdx, sec.startIdx + sec.entryCount); const entries = sectionEntries(allEntries, sec);
if (sec.isApp) {
entries = entries.filter(e => e.type === 'app_string');
}
const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx); const groups = buildTranslationGroups(entries, selectedLang, sec.startIdx);
const secMissing = countMissing(entries, selectedLang);
const inner = renderCollapsibleGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, {
openGroupsWithMissing: selectedScope !== '__all__',
});
if (useQnDetails) {
const open = secMissing > 0 && sections.filter(s =>
countMissing(sectionEntries(allEntries, s), selectedLang) > 0
)[0]?.questionnaireID === sec.questionnaireID;
const summary = secMissing
? `${sec.name} · ${secMissing} missing`
: `${sec.name} · complete`;
return ` return `
<div class="trans-qn-block" data-qn="${esc(sec.questionnaireID)}"> <details class="trans-qn-section"${open ? ' open' : ''} data-qn="${esc(sec.questionnaireID)}">
<h2 class="trans-qn-title">${esc(sec.name)}</h2> <summary class="trans-qn-summary">${esc(summary)}</summary>
${renderGroupedTranslationsHTML(groups, selectedLang, sourceLabel, true, { ${inner}
showColumnHeader: false, </details>`;
})} }
</div>
`; return `<div class="trans-scope-block" data-qn="${esc(sec.questionnaireID)}">${inner}</div>`;
}).join(''); }).join('');
listArea.innerHTML = ` listArea.innerHTML = `
${headerOnce} <div class="trans-top-bar">
<div id="transGroupedRoot">${sectionsHtml}</div> <div class="trans-top-stats">
<div class="trans-stats"> <span id="transVisibleCount">${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} in scope</span>
<span id="transVisibleCount">${missingCount ? `${missingCount} missing · ` : ''}${allEntries.length} keys</span>
<span class="trans-stats-progress"> <span class="trans-stats-progress">
<span class="trans-progress-bar"><span class="trans-progress-fill" style="width:${pct}%"></span></span> <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 class="trans-progress-label">${pct}% in ${esc(selectedLang.toUpperCase())}</span>
</span> </span>
</div> </div>
<div class="trans-top-actions">
<span id="transSaveStatus" class="trans-save-status" data-state="saved">All changes saved</span>
<button type="button" class="btn btn-sm" id="transJumpMissingBtn">Jump to next missing</button>
</div>
</div>
<div class="trans-sheet">
${headerOnce}
<div id="transGroupedRoot" class="trans-sheet-body${useQnDetails ? ' trans-sheet-body-stack' : ''}">${sectionsHtml}</div>
</div>
`; `;
document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing);
bindEntryEditing(allEntries); bindEntryEditing(allEntries);
applyFilters(); applyFilters();
} }
function openAncestors(el) {
let node = el;
while (node) {
if (node.tagName === 'DETAILS') node.open = true;
node = node.parentElement;
}
}
function jumpToNextMissing() {
const rows = [...document.querySelectorAll('#transGroupedRoot .trans-list-item')]
.filter(r => !r.classList.contains('trans-row-hidden') && r.dataset.missing === '1');
if (!rows.length) {
showToast(showMode === 'missing'
? 'All translations in this scope are complete'
: 'No missing translations in view', 'info');
return;
}
const activeRow = document.activeElement?.closest?.('.trans-list-item');
let start = 0;
if (activeRow) {
const idx = rows.indexOf(activeRow);
if (idx >= 0) start = idx + 1;
}
const target = rows[start] || rows[0];
openAncestors(target);
const inp = target.querySelector('[data-field="translation"]');
inp?.focus({ preventScroll: false });
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function bindEntryEditing(entries) { function bindEntryEditing(entries) {
document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => { document.querySelectorAll('#transGroupedRoot .trans-cell-input').forEach(inp => {
inp.addEventListener('input', () => { inp.addEventListener('input', () => {
@ -303,11 +487,14 @@ function bindEntryEditing(entries) {
const timerKey = isGerman ? `${idx}_de` : `${idx}_${selectedLang}`; const timerKey = isGerman ? `${idx}_de` : `${idx}_${selectedLang}`;
clearTimeout(saveTimers[timerKey]); clearTimeout(saveTimers[timerKey]);
saveTimers[timerKey] = setTimeout(() => saveTranslation(entry, inp, isGerman), 500); saveTimers[timerKey] = setTimeout(() => saveTranslation(entry, inp, isGerman), 500);
setSaveStatus('saving');
}); });
}); });
} }
async function saveTranslation(entry, inp, isGerman = false) { async function saveTranslation(entry, inp, isGerman = false) {
savesInFlight++;
setSaveStatus('saving');
try { try {
await apiPut('translations.php', { await apiPut('translations.php', {
type: entry.type, type: entry.type,
@ -319,23 +506,48 @@ async function saveTranslation(entry, inp, isGerman = false) {
setTimeout(() => inp.classList.remove('save-flash'), 400); setTimeout(() => inp.classList.remove('save-flash'), 400);
updateProgress(); updateProgress();
} catch (e) { } catch (e) {
setSaveStatus('error');
showToast(e.message, 'error'); showToast(e.message, 'error');
} finally {
savesInFlight = Math.max(0, savesInFlight - 1);
if (savesInFlight === 0) {
setSaveStatus('saved');
populateScopeSelect();
}
} }
} }
function updateProgress() { function updateProgress() {
const missing = allEntries.filter(e => !translationFor(e, selectedLang).trim()).length; const scopeEntries = entriesInScope();
const pct = allEntries.length ? Math.round(((allEntries.length - missing) / allEntries.length) * 100) : 0; const missing = countMissing(scopeEntries, selectedLang);
const pct = scopeEntries.length
? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100)
: 0;
const fill = document.querySelector('.trans-progress-fill'); const fill = document.querySelector('.trans-progress-fill');
if (fill) fill.style.width = `${pct}%`; if (fill) fill.style.width = `${pct}%`;
const label = document.querySelector('.trans-progress-label'); const label = document.querySelector('.trans-progress-label');
if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`; if (label) label.textContent = `${pct}% in ${selectedLang.toUpperCase()}`;
const countEl = document.getElementById('transVisibleCount'); if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
if (countEl && !document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) { updateVisibleCountSummary();
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${allEntries.length} keys`;
} }
} }
function updateVisibleCountSummary(visible = null, visibleMissing = null) {
const countEl = document.getElementById('transVisibleCount');
if (!countEl) return;
const scopeEntries = entriesInScope();
const text = document.getElementById('transFilter')?.value || '';
const type = document.getElementById('transTypeFilter')?.value || '';
if (text || type || showMode !== 'missing') {
if (visible != null) {
countEl.textContent = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}`;
}
return;
}
const missing = countMissing(scopeEntries, selectedLang);
countEl.textContent = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} in scope`;
}
let filtersBound = false; let filtersBound = false;
function bindFilters() { function bindFilters() {
@ -364,8 +576,11 @@ function applyFilters() {
const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase() const german = row.querySelector('.trans-source-text')?.textContent?.toLowerCase()
|| row.querySelector('[data-field="german"]')?.value?.toLowerCase() || ''; || row.querySelector('[data-field="german"]')?.value?.toLowerCase() || '';
const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || ''; const val = row.querySelector('[data-field="translation"]')?.value?.toLowerCase() || '';
const show = (!type || rowType === type) && const isMissing = row.dataset.missing === '1';
let show = (!type || rowType === type) &&
(!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text)); (!text || key.includes(text) || label.includes(text) || german.includes(text) || val.includes(text));
if (showMode === 'missing' && !isMissing) show = false;
if (showMode === 'complete' && isMissing) show = false;
row.classList.toggle('trans-row-hidden', !show); row.classList.toggle('trans-row-hidden', !show);
if (show) { if (show) {
visible++; visible++;
@ -373,26 +588,33 @@ function applyFilters() {
} }
}); });
document.querySelectorAll('#transGroupedRoot .trans-group').forEach(group => { document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => {
const rows = group.querySelectorAll('.trans-list-item'); const rows = group.querySelectorAll('.trans-list-item');
const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden')); const anyVisible = [...rows].some(r => !r.classList.contains('trans-row-hidden'));
group.classList.toggle('trans-group-hidden', !anyVisible); group.classList.toggle('trans-group-hidden', !anyVisible);
}); });
document.querySelectorAll('#transGroupedRoot .trans-qn-block').forEach(block => { document.querySelectorAll('#transGroupedRoot .trans-scope-block, #transGroupedRoot .trans-qn-section').forEach(block => {
const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden')); const anyVisible = [...block.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
block.classList.toggle('trans-qn-hidden', !anyVisible); block.classList.toggle('trans-qn-hidden', !anyVisible);
}); });
const countEl = document.getElementById('transVisibleCount'); const emptyEl = document.getElementById('transEmptyHint');
if (countEl) { if (emptyEl) emptyEl.remove();
if (text || type) { if (visible === 0 && items.length > 0) {
countEl.textContent = `Showing ${visible} (${visibleMissing} missing)`; const root = document.getElementById('transGroupedRoot');
} else { if (root) {
const totalMissing = [...items].filter(r => r.dataset.missing === '1').length; const hint = document.createElement('p');
countEl.textContent = `${totalMissing ? `${totalMissing} missing · ` : ''}${items.length} keys`; hint.id = 'transEmptyHint';
hint.className = 'text-muted trans-hint trans-empty-hint';
hint.textContent = showMode === 'missing'
? 'All translations in this scope are complete for the selected language.'
: 'No rows match the current filters.';
root.insertAdjacentElement('afterend', hint);
} }
} }
updateVisibleCountSummary(visible, visibleMissing);
} }
function bindTranslationBundleActions() { function bindTranslationBundleActions() {

View File

@ -49,6 +49,22 @@ export function translationFor(entry, lang) {
return normalizeTranslations(entry.translations)[lang] || ''; return normalizeTranslations(entry.translations)[lang] || '';
} }
export function isEntryMissing(entry, lang) {
return !translationFor(entry, lang).trim();
}
export function countMissing(entries, lang) {
return entries.filter(e => isEntryMissing(e, lang)).length;
}
export function sectionEntries(allEntries, section) {
let entries = allEntries.slice(section.startIdx, section.startIdx + section.entryCount);
if (section.isApp) {
entries = entries.filter(e => e.type === 'app_string');
}
return entries;
}
export function targetLanguages(languages) { export function targetLanguages(languages) {
return (languages || []).filter(l => l.languageCode !== SOURCE_LANG); return (languages || []).filter(l => l.languageCode !== SOURCE_LANG);
} }
@ -197,11 +213,11 @@ export function sourceLanguageLabel(languages) {
return row?.name ? `${row.name} (${SOURCE_LANG})` : 'German'; return row?.name ? `${row.name} (${SOURCE_LANG})` : 'German';
} }
/** Column headers: Key | main language (German) | target language */ /** Column headers: label | main language (German) | target language */
export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') { export function translationListHeaderHTML(targetLangLabel, sourceLabel = 'German') {
return ` return `
<div class="trans-list-header"> <div class="trans-list-header trans-list-header-sticky">
<span class="trans-col-key">Key</span> <span class="trans-col-key">Label</span>
<span class="trans-col-source">${escHtml(sourceLabel)}</span> <span class="trans-col-source">${escHtml(sourceLabel)}</span>
<span class="trans-col-target">${escHtml(targetLangLabel)}</span> <span class="trans-col-target">${escHtml(targetLangLabel)}</span>
</div>`; </div>`;
@ -220,12 +236,17 @@ export function translationRowHTML(entry, idx, { targetLang, editable = true })
value="${escHtml(german)}" placeholder="German text…" autocomplete="off">` value="${escHtml(german)}" placeholder="German text…" autocomplete="off">`
: `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`; : `<span class="trans-source-text" title="${escHtml(german)}">${escHtml(german)}</span>`;
const germanPreview = german.length > 72 ? `${german.slice(0, 72)}` : german;
return ` return `
<div class="trans-list-item ${missing ? 'trans-list-item-missing' : ''}" <div class="trans-list-item ${missing ? 'trans-list-item-missing' : ''}"
data-idx="${idx}" data-type="${entry.type}" data-key="${escHtml(lookupKey)}" data-label="${escHtml(label)}" data-missing="${missing ? '1' : '0'}"> data-idx="${idx}" data-type="${entry.type}" data-key="${escHtml(lookupKey)}" data-label="${escHtml(label)}" data-missing="${missing ? '1' : '0'}">
<div class="trans-list-key"> <div class="trans-list-key">
${typeBadge(entry.type)} ${typeBadge(entry.type)}
<span class="trans-key-text" title="${escHtml(lookupKey)}">${escHtml(label)}</span> <div class="trans-key-labels">
<span class="trans-key-primary" title="${escHtml(german)}">${escHtml(germanPreview || label)}</span>
<span class="trans-key-secondary" title="${escHtml(lookupKey)}">${escHtml(lookupKey)}</span>
</div>
</div> </div>
<div class="trans-list-source">${sourceCell}</div> <div class="trans-list-source">${sourceCell}</div>
<input type="text" class="trans-cell-input ${missing ? 'trans-input-missing' : ''}" <input type="text" class="trans-cell-input ${missing ? 'trans-input-missing' : ''}"
@ -233,3 +254,32 @@ export function translationRowHTML(entry, idx, { targetLang, editable = true })
placeholder="Translation…" ${disabled} autocomplete="off"> placeholder="Translation…" ${disabled} autocomplete="off">
</div>`; </div>`;
} }
/**
* Grouped rows with collapsible sections and missing counts in summaries.
*/
export function renderCollapsibleGroupedTranslationsHTML(groups, targetLang, sourceLabel, editable, options = {}) {
const { openGroupsWithMissing = false } = options;
if (!groups.length) return '';
const body = groups.map(g => {
const missing = g.items.filter(({ entry }) => isEntryMissing(entry, targetLang)).length;
const total = g.items.length;
const open = openGroupsWithMissing && missing > 0;
const summary = missing
? `${g.title} · ${missing} missing`
: `${g.title} · complete (${total})`;
return `
<details class="trans-group-details"${open ? ' open' : ''}>
<summary class="trans-group-summary">${escHtml(summary)}</summary>
<div class="trans-group-rows">
${g.items.map(({ entry, idx }) =>
translationRowHTML(entry, idx, { targetLang, editable })
).join('')}
</div>
</details>`;
}).join('');
return `<div class="trans-groups-flat">${body}</div>`;
}