979 lines
40 KiB
JavaScript
979 lines
40 KiB
JavaScript
import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch, apiUrl } from '../api.js';
|
|
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
|
import { confirmAction } from '../confirm-modal.js';
|
|
import {
|
|
SOURCE_LANG,
|
|
normalizeEntry,
|
|
translationFor,
|
|
targetLanguages,
|
|
isLanguageGloballyEnabled,
|
|
sourceLanguageLabel,
|
|
translationListHeaderHTML,
|
|
buildTranslationGroups,
|
|
renderCollapsibleGroupedTranslationsHTML,
|
|
flattenAllQuestionnaires,
|
|
countMissing,
|
|
sectionEntries,
|
|
escHtml as esc,
|
|
} 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 allEntries = [];
|
|
let selectedLang = '';
|
|
let selectedScope = '';
|
|
/** @type {'missing' | 'all' | 'complete'} */
|
|
let showMode = 'missing';
|
|
let saveTimers = {};
|
|
let savesInFlight = 0;
|
|
|
|
export async function translationsPage() {
|
|
const app = document.getElementById('app');
|
|
app.innerHTML = `
|
|
${pageHeaderHTML(
|
|
'Translations',
|
|
'<span id="transHeaderActions"></span>',
|
|
{ subtitle: 'Manage languages, availability, and translated text for the coach app.' },
|
|
)}
|
|
<div id="transPageRoot"><div class="spinner"></div></div>
|
|
`;
|
|
|
|
if (canEdit()) {
|
|
document.getElementById('transHeaderActions').innerHTML = `
|
|
<input type="file" id="importTransBundleFile" accept=".json,application/json" hidden>
|
|
<button type="button" class="btn" id="importTransBundleBtn" title="Import JSON bundle">Import</button>
|
|
<button type="button" class="btn btn-primary" id="exportTransBundleBtn" title="Download JSON bundle">Export</button>
|
|
`;
|
|
bindTranslationBundleActions();
|
|
}
|
|
|
|
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="trans-layout">
|
|
<aside class="trans-sidebar" id="transSidebar">
|
|
<section class="trans-sidebar-section">
|
|
<div class="trans-sidebar-head">
|
|
<h2 class="trans-sidebar-title">Languages</h2>
|
|
<p class="trans-sidebar-hint">Select a language to translate into. Toggle to show or hide in the app.</p>
|
|
</div>
|
|
<div id="transLangSidebar" class="trans-lang-list">
|
|
<div class="spinner spinner-sm"></div>
|
|
</div>
|
|
<div id="transAddLang" class="trans-add-lang"></div>
|
|
</section>
|
|
|
|
<section class="trans-sidebar-section">
|
|
<div class="trans-sidebar-head">
|
|
<h2 class="trans-sidebar-title">Content</h2>
|
|
<p class="trans-sidebar-hint">Choose what you are translating.</p>
|
|
</div>
|
|
<nav id="transContentNav" class="trans-content-nav" aria-label="Translation content">
|
|
<div class="spinner spinner-sm"></div>
|
|
</nav>
|
|
</section>
|
|
|
|
<section class="trans-sidebar-section trans-sidebar-advanced">
|
|
<details class="trans-advanced-panel" id="transAdvancedPanel">
|
|
<summary class="trans-advanced-summary">Advanced</summary>
|
|
<div id="transAdvancedBody" class="trans-advanced-body">
|
|
<p class="text-muted trans-advanced-note">
|
|
Bulk-delete translated text. German source is never removed.
|
|
</p>
|
|
<div id="transDeleteForm"></div>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
</aside>
|
|
|
|
<main class="trans-main" id="transMain">
|
|
<div id="transWorkspaceHeader" class="trans-workspace-header"></div>
|
|
<div id="transQnAvailability" class="trans-qn-availability" hidden></div>
|
|
<div id="transListArea" class="trans-list-area">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function pickDefaultTargetLang(languages) {
|
|
const targets = targetLanguages(languages);
|
|
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)) return selectedLang;
|
|
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) {
|
|
if (countMissing(sectionEntries(entries, sec), lang) > 0) return sec.questionnaireID;
|
|
}
|
|
return sections[0]?.questionnaireID || '__app__';
|
|
}
|
|
|
|
function scopeLabel(sec, entries, lang) {
|
|
const missing = countMissing(sectionEntries(entries, sec), lang);
|
|
return { name: sec.name, missing, complete: missing === 0 };
|
|
}
|
|
|
|
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 currentScopeMeta() {
|
|
const sections = transData?.sections || [];
|
|
if (selectedScope === '__all__') return { id: '__all__', name: 'All content', isApp: false, isQuestionnaire: false };
|
|
const sec = sections.find(s => s.questionnaireID === selectedScope);
|
|
if (!sec) return { id: selectedScope, name: 'Content', isApp: false, isQuestionnaire: false };
|
|
return {
|
|
id: sec.questionnaireID,
|
|
name: sec.name,
|
|
isApp: !!sec.isApp,
|
|
isQuestionnaire: !sec.isApp && sec.questionnaireID !== '__app__',
|
|
};
|
|
}
|
|
|
|
function currentLangMeta() {
|
|
const languages = transData?.languages || [];
|
|
const row = languages.find(l => l.languageCode === selectedLang);
|
|
return {
|
|
code: selectedLang,
|
|
name: row?.name || selectedLang.toUpperCase(),
|
|
enabled: row ? isLanguageGloballyEnabled(row) : true,
|
|
};
|
|
}
|
|
|
|
function setSaveStatus(state) {
|
|
const el = document.getElementById('transSaveStatus');
|
|
if (!el) return;
|
|
el.dataset.state = state;
|
|
el.textContent = state === 'saving' ? 'Saving…' : state === 'error' ? 'Save failed' : 'All changes saved';
|
|
}
|
|
|
|
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');
|
|
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, data.appStrings || []);
|
|
allEntries = flat.allEntries;
|
|
transData.sections = flat.sections;
|
|
|
|
selectedLang = pickDefaultTargetLang(languages);
|
|
showMode = pickDefaultShowMode();
|
|
selectedScope = pickDefaultScope(flat.sections, allEntries, selectedLang);
|
|
|
|
renderLanguageSidebar(languages);
|
|
renderContentNav();
|
|
renderDeleteForm();
|
|
renderWorkspaceHeader();
|
|
renderQnAvailabilityStrip();
|
|
renderEntryList();
|
|
bindFilters();
|
|
setSaveStatus('saved');
|
|
} catch (e) {
|
|
if (listArea) listArea.innerHTML = `<p class="error-text trans-hint">${esc(e.message)}</p>`;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderLanguageSidebar(languages) {
|
|
const list = document.getElementById('transLangSidebar');
|
|
const addBox = document.getElementById('transAddLang');
|
|
if (!list || !addBox) return;
|
|
|
|
const others = targetLanguages(languages);
|
|
const source = languages.find(l => l.languageCode === SOURCE_LANG);
|
|
|
|
list.innerHTML = `
|
|
<div class="trans-lang-item trans-lang-item-source" aria-disabled="true">
|
|
<span class="trans-lang-code">DE</span>
|
|
<span class="trans-lang-meta">
|
|
<span class="trans-lang-name">${esc(source?.name || 'German')}</span>
|
|
<span class="trans-lang-sub">Source language</span>
|
|
</span>
|
|
<span class="trans-lang-badge trans-lang-badge-source">Source</span>
|
|
</div>
|
|
${others.length ? others.map(lang => {
|
|
const selected = lang.languageCode === selectedLang;
|
|
const enabled = isLanguageGloballyEnabled(lang);
|
|
const missing = countMissing(allEntries, lang.languageCode);
|
|
return `
|
|
<button type="button"
|
|
class="trans-lang-item${selected ? ' is-selected' : ''}${!enabled ? ' is-disabled' : ''}"
|
|
data-lc="${esc(lang.languageCode)}"
|
|
aria-pressed="${selected}">
|
|
<span class="trans-lang-code">${esc(lang.languageCode.toUpperCase())}</span>
|
|
<span class="trans-lang-meta">
|
|
<span class="trans-lang-name">${esc(lang.name || lang.languageCode)}</span>
|
|
<span class="trans-lang-sub">${missing ? `${missing} missing overall` : 'Fully translated'}</span>
|
|
</span>
|
|
<span class="trans-lang-actions" data-stop-select>
|
|
<label class="trans-toggle" title="Show in app language picker">
|
|
<input type="checkbox" class="trans-global-lang-toggle" data-lc="${esc(lang.languageCode)}"
|
|
${enabled ? 'checked' : ''}>
|
|
<span class="trans-toggle-ui" aria-hidden="true"></span>
|
|
</label>
|
|
${canEdit() ? `<button type="button" class="trans-lang-remove" data-lc="${esc(lang.languageCode)}" title="Remove language">×</button>` : ''}
|
|
</span>
|
|
</button>`;
|
|
}).join('') : `
|
|
<p class="trans-empty-sidebar">No target languages yet. Add one below to start translating.</p>
|
|
`}
|
|
`;
|
|
|
|
addBox.innerHTML = canEdit() ? `
|
|
<div class="trans-add-lang-form">
|
|
<div class="trans-add-lang-fields">
|
|
<input type="text" id="newLangCode" placeholder="Code (en)" class="trans-add-input" maxlength="8" aria-label="Language code">
|
|
<input type="text" id="newLangName" placeholder="Name (English)" class="trans-add-input" aria-label="Language name">
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-primary trans-add-lang-btn" id="addLangBtn">Add language</button>
|
|
</div>
|
|
` : '';
|
|
|
|
list.querySelectorAll('.trans-lang-item[data-lc]').forEach(btn => {
|
|
btn.addEventListener('click', e => {
|
|
if (e.target.closest('[data-stop-select]')) return;
|
|
selectedLang = btn.dataset.lc;
|
|
localStorage.setItem(STORAGE_LANG, selectedLang);
|
|
renderLanguageSidebar(languages);
|
|
renderContentNav();
|
|
renderWorkspaceHeader();
|
|
renderQnAvailabilityStrip();
|
|
renderEntryList();
|
|
});
|
|
});
|
|
|
|
list.querySelectorAll('.trans-global-lang-toggle').forEach(input => {
|
|
input.addEventListener('change', async e => {
|
|
e.stopPropagation();
|
|
const lc = input.dataset.lc;
|
|
const enabled = input.checked;
|
|
input.disabled = true;
|
|
try {
|
|
await apiPut('translations.php', { type: 'language_enabled', languageCode: lc, enabled });
|
|
const lang = languages.find(l => l.languageCode === lc);
|
|
if (lang) lang.enabled = enabled ? 1 : 0;
|
|
showToast(enabled ? `${lc} enabled in app` : `${lc} hidden from app`, 'success');
|
|
renderLanguageSidebar(languages);
|
|
renderQnAvailabilityStrip();
|
|
} catch (err) {
|
|
input.checked = !enabled;
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
input.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
list.querySelectorAll('.trans-lang-remove').forEach(btn => {
|
|
btn.addEventListener('click', async e => {
|
|
e.stopPropagation();
|
|
const lc = btn.dataset.lc;
|
|
if (!(await confirmAction({
|
|
title: 'Remove language',
|
|
message: `Remove "${lc}" and delete all of its translations?`,
|
|
confirmLabel: 'Remove',
|
|
variant: 'danger',
|
|
}))) return;
|
|
try {
|
|
await apiDelete('translations.php', { type: 'language', languageCode: lc });
|
|
showToast(`Language "${lc}" removed`, 'success');
|
|
if (selectedLang === lc) selectedLang = '';
|
|
await loadTranslations();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
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 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 (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderContentNav() {
|
|
const nav = document.getElementById('transContentNav');
|
|
if (!nav || !transData) return;
|
|
|
|
const sections = transData.sections || [];
|
|
const items = sections.map(sec => {
|
|
const { name, missing, complete } = scopeLabel(sec, allEntries, selectedLang);
|
|
const active = sec.questionnaireID === selectedScope;
|
|
const badge = missing
|
|
? `<span class="trans-nav-badge trans-nav-badge-warn">${missing}</span>`
|
|
: `<span class="trans-nav-badge trans-nav-badge-ok" title="Complete">✓</span>`;
|
|
const icon = sec.isApp ? '◇' : '▢';
|
|
return `
|
|
<button type="button"
|
|
class="trans-content-item${active ? ' is-active' : ''}"
|
|
data-scope="${esc(sec.questionnaireID)}"
|
|
aria-current="${active ? 'page' : 'false'}">
|
|
<span class="trans-content-icon" aria-hidden="true">${icon}</span>
|
|
<span class="trans-content-label">${esc(name)}</span>
|
|
${selectedLang ? badge : ''}
|
|
</button>`;
|
|
});
|
|
|
|
items.push(`
|
|
<button type="button"
|
|
class="trans-content-item trans-content-item-all${selectedScope === '__all__' ? ' is-active' : ''}"
|
|
data-scope="__all__"
|
|
aria-current="${selectedScope === '__all__' ? 'page' : 'false'}">
|
|
<span class="trans-content-icon" aria-hidden="true">≡</span>
|
|
<span class="trans-content-label">All content</span>
|
|
</button>
|
|
`);
|
|
|
|
nav.innerHTML = items.join('');
|
|
|
|
nav.querySelectorAll('.trans-content-item').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
selectedScope = btn.dataset.scope;
|
|
localStorage.setItem(STORAGE_SCOPE, selectedScope);
|
|
renderContentNav();
|
|
renderWorkspaceHeader();
|
|
renderQnAvailabilityStrip();
|
|
renderDeleteForm();
|
|
renderEntryList();
|
|
});
|
|
});
|
|
}
|
|
|
|
function questionnaireLanguageDisabled(questionnaireID, languageCode) {
|
|
return (transData?.questionnaireLanguageDisables || []).some(
|
|
row => row.questionnaireID === questionnaireID && row.languageCode === languageCode
|
|
);
|
|
}
|
|
|
|
function setQuestionnaireLanguageDisabled(questionnaireID, languageCode, disabled) {
|
|
if (!transData) return;
|
|
const rows = transData.questionnaireLanguageDisables || [];
|
|
const idx = rows.findIndex(
|
|
row => row.questionnaireID === questionnaireID && row.languageCode === languageCode
|
|
);
|
|
if (disabled) {
|
|
if (idx < 0) rows.push({ questionnaireID, languageCode });
|
|
} else if (idx >= 0) {
|
|
rows.splice(idx, 1);
|
|
}
|
|
transData.questionnaireLanguageDisables = rows;
|
|
}
|
|
|
|
function renderQnAvailabilityStrip() {
|
|
const strip = document.getElementById('transQnAvailability');
|
|
if (!strip || !transData) return;
|
|
|
|
const scope = currentScopeMeta();
|
|
const languages = targetLanguages(transData.languages || []).filter(isLanguageGloballyEnabled);
|
|
|
|
if (!scope.isQuestionnaire || !languages.length) {
|
|
strip.hidden = true;
|
|
strip.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
strip.hidden = false;
|
|
strip.innerHTML = `
|
|
<div class="trans-qn-availability-inner">
|
|
<span class="trans-qn-availability-label">Available in app for this questionnaire</span>
|
|
<div class="trans-qn-availability-toggles">
|
|
${languages.map(lang => {
|
|
const on = !questionnaireLanguageDisabled(scope.id, lang.languageCode);
|
|
return `
|
|
<label class="trans-qn-pill${on ? ' is-on' : ''}">
|
|
<input type="checkbox" class="trans-qn-lang-toggle"
|
|
data-qid="${esc(scope.id)}"
|
|
data-lc="${esc(lang.languageCode)}"
|
|
${on ? 'checked' : ''}>
|
|
<span>${esc(lang.languageCode.toUpperCase())}</span>
|
|
</label>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
strip.querySelectorAll('.trans-qn-lang-toggle').forEach(input => {
|
|
input.addEventListener('change', async () => {
|
|
const qid = input.dataset.qid;
|
|
const lc = input.dataset.lc;
|
|
const enabled = input.checked;
|
|
input.disabled = true;
|
|
try {
|
|
await apiPut('translations.php', {
|
|
type: 'questionnaire_language',
|
|
questionnaireID: qid,
|
|
languageCode: lc,
|
|
enabled,
|
|
});
|
|
setQuestionnaireLanguageDisabled(qid, lc, !enabled);
|
|
renderQnAvailabilityStrip();
|
|
showToast(
|
|
enabled ? `${lc} enabled for this questionnaire` : `${lc} hidden for this questionnaire`,
|
|
'success'
|
|
);
|
|
} catch (err) {
|
|
input.checked = !enabled;
|
|
showToast(err.message, 'error');
|
|
} finally {
|
|
input.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderDeleteForm() {
|
|
const form = document.getElementById('transDeleteForm');
|
|
if (!form || !transData || !canEdit()) {
|
|
if (form) form.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const languages = targetLanguages(transData.languages || []);
|
|
const sections = (transData.sections || []).filter(sec => sec.questionnaireID !== '__app__');
|
|
const defaultScope = selectedScope && selectedScope !== '__all__' ? selectedScope : '__app__';
|
|
|
|
form.innerHTML = `
|
|
<label class="trans-field">
|
|
<span>Content area</span>
|
|
<select id="transDeleteScope" class="trans-select">
|
|
<option value="__app__"${defaultScope === '__app__' ? ' selected' : ''}>App UI strings</option>
|
|
${sections.map(sec => `
|
|
<option value="${esc(sec.questionnaireID)}"${defaultScope === sec.questionnaireID ? ' selected' : ''}>
|
|
${esc(sec.name)}
|
|
</option>
|
|
`).join('')}
|
|
<option value="__all__"${defaultScope === '__all__' ? ' selected' : ''}>All content</option>
|
|
</select>
|
|
</label>
|
|
<label class="trans-field">
|
|
<span>Language</span>
|
|
<select id="transDeleteLang" class="trans-select">
|
|
<option value="">All target languages</option>
|
|
${languages.map(l => `
|
|
<option value="${esc(l.languageCode)}">${esc(l.name || l.languageCode)} (${esc(l.languageCode)})</option>
|
|
`).join('')}
|
|
</select>
|
|
</label>
|
|
<button type="button" class="btn btn-danger btn-sm trans-delete-btn" id="transDeleteBtn">Delete translations</button>
|
|
`;
|
|
|
|
document.getElementById('transDeleteBtn')?.addEventListener('click', onDeleteTranslations);
|
|
}
|
|
|
|
async function onDeleteTranslations() {
|
|
const scopeVal = document.getElementById('transDeleteScope')?.value || '__app__';
|
|
const lang = document.getElementById('transDeleteLang')?.value || '';
|
|
const scope = scopeVal === '__app__' ? 'app' : (scopeVal === '__all__' ? 'all' : 'questionnaire');
|
|
const sections = (transData?.sections || []).filter(sec => sec.questionnaireID !== '__app__');
|
|
const scopeLabel = scopeVal === '__app__'
|
|
? 'App UI strings'
|
|
: (scopeVal === '__all__' ? 'all content' : (sections.find(s => s.questionnaireID === scopeVal)?.name || 'this questionnaire'));
|
|
const langLabel = lang ? lang.toUpperCase() : 'all target languages';
|
|
|
|
if (!(await confirmAction({
|
|
title: 'Delete translations',
|
|
message: `Delete ${langLabel} translations for ${scopeLabel}? This cannot be undone.`,
|
|
confirmLabel: 'Delete',
|
|
variant: 'danger',
|
|
}))) return;
|
|
|
|
const btn = document.getElementById('transDeleteBtn');
|
|
btn.disabled = true;
|
|
try {
|
|
const body = { action: 'deleteTranslationsScope', scope };
|
|
if (scope === 'questionnaire') body.questionnaireID = scopeVal;
|
|
if (lang) body.languageCode = lang;
|
|
const result = await apiPost('translations.php', body);
|
|
showToast(`Deleted ${result.deleted ?? 0} translation row(s)`, 'success');
|
|
await loadTranslations();
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function renderWorkspaceHeader() {
|
|
const header = document.getElementById('transWorkspaceHeader');
|
|
if (!header || !transData) return;
|
|
|
|
const scope = currentScopeMeta();
|
|
const lang = currentLangMeta();
|
|
const scopeEntries = entriesInScope();
|
|
const missingCount = selectedLang ? countMissing(scopeEntries, selectedLang) : 0;
|
|
const pct = scopeEntries.length && selectedLang
|
|
? Math.round(((scopeEntries.length - missingCount) / scopeEntries.length) * 100)
|
|
: 0;
|
|
|
|
header.innerHTML = `
|
|
<div class="trans-workspace-top">
|
|
<div class="trans-workspace-title-wrap">
|
|
<h2 class="trans-workspace-title">${esc(scope.name)}</h2>
|
|
<p class="trans-workspace-subtitle">
|
|
${selectedLang
|
|
? `Translating into <strong>${esc(lang.name)}</strong> (${esc(lang.code)})`
|
|
: 'Add a target language to begin'}
|
|
${!lang.enabled && selectedLang ? ' · <span class="trans-warn">Hidden from app</span>' : ''}
|
|
</p>
|
|
</div>
|
|
<div class="trans-workspace-status">
|
|
<span id="transSaveStatus" class="trans-save-status" data-state="saved">All changes saved</span>
|
|
<button type="button" class="btn btn-sm" id="transJumpMissingBtn"${!selectedLang ? ' disabled' : ''}>
|
|
Next missing
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="trans-workspace-progress"${!selectedLang ? ' hidden' : ''}>
|
|
<div class="trans-progress-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100">
|
|
<div class="trans-progress-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
<span class="trans-progress-text" id="transVisibleCount">
|
|
${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} strings
|
|
<span class="trans-progress-pct">${pct}% complete</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="trans-workspace-filters">
|
|
<div class="trans-filter-pills" role="group" aria-label="Show">
|
|
<button type="button" class="trans-pill${showMode === 'missing' ? ' is-active' : ''}" data-show="missing">Missing</button>
|
|
<button type="button" class="trans-pill${showMode === 'all' ? ' is-active' : ''}" data-show="all">All</button>
|
|
<button type="button" class="trans-pill${showMode === 'complete' ? ' is-active' : ''}" data-show="complete">Complete</button>
|
|
</div>
|
|
<input type="search" id="transFilter" class="trans-search-input" placeholder="Search strings…" ${!selectedLang ? 'disabled' : ''}>
|
|
<select id="transTypeFilter" class="trans-select trans-type-select" ${!selectedLang ? 'disabled' : ''}>
|
|
<option value="">All types</option>
|
|
<option value="app_string">App strings</option>
|
|
<option value="string">Questionnaire UI</option>
|
|
<option value="question">Questions</option>
|
|
<option value="answer_option">Answer options</option>
|
|
</select>
|
|
</div>
|
|
`;
|
|
|
|
header.querySelectorAll('.trans-pill').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
showMode = btn.dataset.show;
|
|
localStorage.setItem(STORAGE_SHOW, showMode);
|
|
header.querySelectorAll('.trans-pill').forEach(p => p.classList.toggle('is-active', p.dataset.show === showMode));
|
|
applyFilters();
|
|
});
|
|
});
|
|
|
|
document.getElementById('transJumpMissingBtn')?.addEventListener('click', jumpToNextMissing);
|
|
|
|
filtersBound = false;
|
|
bindFilters();
|
|
}
|
|
|
|
function renderEntryList() {
|
|
const listArea = document.getElementById('transListArea');
|
|
if (!listArea || !transData) return;
|
|
|
|
const languages = transData.languages || [];
|
|
const targets = targetLanguages(languages);
|
|
const sections = scopedSections();
|
|
|
|
if (!targets.length) {
|
|
listArea.innerHTML = `
|
|
<div class="trans-empty-state">
|
|
<p class="trans-empty-title">Add a language to get started</p>
|
|
<p class="text-muted">Use the form in the sidebar to add English, French, or any other target language.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
if (!selectedLang) {
|
|
listArea.innerHTML = `
|
|
<div class="trans-empty-state">
|
|
<p class="trans-empty-title">Select a target language</p>
|
|
<p class="text-muted">Click a language in the sidebar to begin translating.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
if (!transData.sections?.length) {
|
|
listArea.innerHTML = `
|
|
<div class="trans-empty-state">
|
|
<p class="trans-empty-title">No translatable content</p>
|
|
<p class="text-muted">Create questionnaires in the editor first. German source text is edited there.</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const sourceLabel = sourceLanguageLabel(languages);
|
|
const langLabel = targets.find(l => l.languageCode === selectedLang)?.name || selectedLang.toUpperCase();
|
|
const useQnDetails = selectedScope === '__all__';
|
|
|
|
const sectionsHtml = sections.map(sec => {
|
|
const entries = sectionEntries(allEntries, sec);
|
|
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 `
|
|
<details class="trans-qn-section"${open ? ' open' : ''} data-qn="${esc(sec.questionnaireID)}">
|
|
<summary class="trans-qn-summary">${esc(summary)}</summary>
|
|
${inner}
|
|
</details>`;
|
|
}
|
|
|
|
return `<div class="trans-scope-block" data-qn="${esc(sec.questionnaireID)}">${inner}</div>`;
|
|
}).join('');
|
|
|
|
listArea.innerHTML = `
|
|
<div class="trans-sheet">
|
|
${translationListHeaderHTML(langLabel, sourceLabel)}
|
|
<div id="transGroupedRoot" class="trans-sheet-body${useQnDetails ? ' trans-sheet-body-stack' : ''}">${sectionsHtml}</div>
|
|
</div>
|
|
`;
|
|
|
|
bindEntryEditing(allEntries);
|
|
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);
|
|
target.querySelector('[data-field="translation"]')?.focus({ preventScroll: false });
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
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);
|
|
setSaveStatus('saving');
|
|
});
|
|
});
|
|
}
|
|
|
|
async function saveTranslation(entry, inp, isGerman = false) {
|
|
savesInFlight++;
|
|
setSaveStatus('saving');
|
|
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) {
|
|
setSaveStatus('error');
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
savesInFlight = Math.max(0, savesInFlight - 1);
|
|
if (savesInFlight === 0) {
|
|
setSaveStatus('saved');
|
|
renderContentNav();
|
|
renderLanguageSidebar(transData?.languages || []);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateProgress() {
|
|
const scopeEntries = entriesInScope();
|
|
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');
|
|
if (fill) fill.style.width = `${pct}%`;
|
|
const track = document.querySelector('.trans-progress-track');
|
|
if (track) track.setAttribute('aria-valuenow', String(pct));
|
|
const pctEl = document.querySelector('.trans-progress-pct');
|
|
if (pctEl) pctEl.textContent = `${pct}% complete`;
|
|
if (!document.getElementById('transFilter')?.value && !document.getElementById('transTypeFilter')?.value) {
|
|
updateVisibleCountSummary();
|
|
}
|
|
}
|
|
|
|
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 || '';
|
|
const missing = countMissing(scopeEntries, selectedLang);
|
|
const pct = scopeEntries.length
|
|
? Math.round(((scopeEntries.length - missing) / scopeEntries.length) * 100)
|
|
: 0;
|
|
|
|
if (text || type || showMode !== 'missing') {
|
|
if (visible != null) {
|
|
countEl.innerHTML = `Showing ${visible}${visibleMissing ? ` (${visibleMissing} missing)` : ''}
|
|
<span class="trans-progress-pct">${pct}% complete</span>`;
|
|
}
|
|
return;
|
|
}
|
|
countEl.innerHTML = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} strings
|
|
<span class="trans-progress-pct">${pct}% complete</span>`;
|
|
}
|
|
|
|
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() || '';
|
|
const label = row.dataset.label?.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 isMissing = row.dataset.missing === '1';
|
|
let show = (!type || rowType === type) &&
|
|
(!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);
|
|
if (show) {
|
|
visible++;
|
|
if (isMissing) visibleMissing++;
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('#transGroupedRoot .trans-group-details').forEach(group => {
|
|
const anyVisible = [...group.querySelectorAll('.trans-list-item')].some(r => !r.classList.contains('trans-row-hidden'));
|
|
group.classList.toggle('trans-group-hidden', !anyVisible);
|
|
});
|
|
|
|
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'));
|
|
block.classList.toggle('trans-qn-hidden', !anyVisible);
|
|
});
|
|
|
|
const emptyEl = document.getElementById('transEmptyHint');
|
|
if (emptyEl) emptyEl.remove();
|
|
if (visible === 0 && items.length > 0) {
|
|
const root = document.getElementById('transGroupedRoot');
|
|
if (root) {
|
|
const hint = document.createElement('p');
|
|
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() {
|
|
const fileInput = document.getElementById('importTransBundleFile');
|
|
const importBtn = document.getElementById('importTransBundleBtn');
|
|
const exportBtn = document.getElementById('exportTransBundleBtn');
|
|
if (!fileInput || !importBtn || !exportBtn) return;
|
|
|
|
exportBtn.addEventListener('click', async () => {
|
|
exportBtn.disabled = true;
|
|
const prev = exportBtn.textContent;
|
|
exportBtn.textContent = 'Preparing…';
|
|
try {
|
|
const res = await apiDownloadFetch(apiUrl('translations?exportBundle=1'));
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error?.message || err.error || 'Export failed');
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `translations_bundle_${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast('Translations bundle downloaded', 'success');
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
exportBtn.disabled = false;
|
|
exportBtn.textContent = prev;
|
|
}
|
|
});
|
|
|
|
importBtn.addEventListener('click', () => fileInput.click());
|
|
|
|
fileInput.addEventListener('change', async () => {
|
|
const file = fileInput.files?.[0];
|
|
fileInput.value = '';
|
|
if (!file) return;
|
|
|
|
let bundle;
|
|
try {
|
|
bundle = JSON.parse(await file.text());
|
|
} catch {
|
|
showToast('Invalid JSON file', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!bundle?.entries || !Array.isArray(bundle.entries)) {
|
|
showToast('Not a translations bundle (missing entries array)', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!(await confirmAction({
|
|
title: 'Import translations',
|
|
message: `Import translations for ${bundle.entries.length} entries from this file?`,
|
|
confirmLabel: 'Import',
|
|
}))) return;
|
|
|
|
importBtn.disabled = true;
|
|
const prev = importBtn.textContent;
|
|
importBtn.textContent = 'Importing…';
|
|
try {
|
|
const result = await apiPost('translations.php', { action: 'importBundle', bundle });
|
|
const imported = result.imported ?? 0;
|
|
const skipped = result.skipped ?? 0;
|
|
let msg = `Imported ${imported} translation(s)`;
|
|
if (skipped) msg += ` (${skipped} skipped)`;
|
|
showToast(msg, 'success');
|
|
await loadTranslations();
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
importBtn.disabled = false;
|
|
importBtn.textContent = prev;
|
|
}
|
|
});
|
|
}
|