This commit is contained in:
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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>`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user