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',
'',
{ subtitle: 'Manage languages, availability, and translated text for the coach app.' },
)}
`;
if (canEdit()) {
document.getElementById('transHeaderActions').innerHTML = `
Import
Export
`;
bindTranslationBundleActions();
}
try {
renderShell();
await loadTranslations();
} catch (e) {
document.getElementById('transPageRoot').innerHTML =
`${esc(e.message)}
`;
}
}
function renderShell() {
document.getElementById('transPageRoot').innerHTML = `
`;
}
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 = '
';
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 = `${esc(e.message)}
`;
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 = `
DE
${esc(source?.name || 'German')}
Source language
Source
${others.length ? others.map(lang => {
const selected = lang.languageCode === selectedLang;
const enabled = isLanguageGloballyEnabled(lang);
const missing = countMissing(allEntries, lang.languageCode);
return `
${esc(lang.languageCode.toUpperCase())}
${esc(lang.name || lang.languageCode)}
${missing ? `${missing} missing overall` : 'Fully translated'}
${canEdit() ? `× ` : ''}
`;
}).join('') : `
`}
`;
addBox.innerHTML = canEdit() ? `
` : '';
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
? `${missing} `
: `✓ `;
const icon = sec.isApp ? '◇' : '▢';
return `
${icon}
${esc(name)}
${selectedLang ? badge : ''}
`;
});
items.push(`
≡
All content
`);
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 = `
`;
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 = `
Content area
App UI strings
${sections.map(sec => `
${esc(sec.name)}
`).join('')}
All content
Language
All target languages
${languages.map(l => `
${esc(l.name || l.languageCode)} (${esc(l.languageCode)})
`).join('')}
Delete translations
`;
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 = `
${esc(scope.name)}
${selectedLang
? `Translating into ${esc(lang.name)} (${esc(lang.code)})`
: 'Add a target language to begin'}
${!lang.enabled && selectedLang ? ' · Hidden from app ' : ''}
All changes saved
Next missing
${missingCount ? `${missingCount} missing · ` : ''}${scopeEntries.length} strings
${pct}% complete
`;
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 = `
Add a language to get started
Use the form in the sidebar to add English, French, or any other target language.
`;
return;
}
if (!selectedLang) {
listArea.innerHTML = `
Select a target language
Click a language in the sidebar to begin translating.
`;
return;
}
if (!transData.sections?.length) {
listArea.innerHTML = `
No translatable content
Create questionnaires in the editor first. German source text is edited there.
`;
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 `
${esc(summary)}
${inner}
`;
}
return `${inner}
`;
}).join('');
listArea.innerHTML = `
${translationListHeaderHTML(langLabel, sourceLabel)}
${sectionsHtml}
`;
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)` : ''}
${pct}% complete `;
}
return;
}
countEl.innerHTML = `${missing ? `${missing} missing · ` : ''}${scopeEntries.length} strings
${pct}% complete `;
}
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;
}
});
}