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