326 lines
11 KiB
JavaScript
326 lines
11 KiB
JavaScript
import { apiGet, apiPut } from '../api.js';
|
|
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
|
import { SOURCE_LANG, normalizeEntry, germanFor, escHtml as esc } from '../translations-helpers.js';
|
|
import { APP_UI_SCREENS, screenById, getVariants, variantById } from '../app-ui-screens.js';
|
|
|
|
const STORAGE_SCREEN = 'qdb_appui_screen';
|
|
const STORAGE_VARIANT = 'qdb_appui_variant';
|
|
|
|
let appStrings = [];
|
|
/** @type {Record<string, string>} key → german text */
|
|
let germanByKey = {};
|
|
let selectedScreenId = '';
|
|
/** @type {Record<string, string>} screenId → variantId */
|
|
let selectedVariantByScreen = {};
|
|
let saveTimers = {};
|
|
let savesInFlight = 0;
|
|
|
|
export async function appUiPage() {
|
|
const app = document.getElementById('app');
|
|
app.innerHTML = `
|
|
${pageHeaderHTML(
|
|
'App UI',
|
|
'<span id="appuiSaveStatus" class="appui-save-status" aria-live="polite"></span>',
|
|
{
|
|
subtitle: 'Edit German wording for the counselor app. Early version — some design details in the preview may be off.',
|
|
},
|
|
)}
|
|
<div id="appuiRoot"><div class="spinner"></div></div>
|
|
`;
|
|
|
|
try {
|
|
selectedScreenId = localStorage.getItem(STORAGE_SCREEN) || APP_UI_SCREENS[0].id;
|
|
if (!screenById(selectedScreenId)) selectedScreenId = APP_UI_SCREENS[0].id;
|
|
try {
|
|
selectedVariantByScreen = JSON.parse(localStorage.getItem(STORAGE_VARIANT) || '{}') || {};
|
|
} catch (_) {
|
|
selectedVariantByScreen = {};
|
|
}
|
|
renderShell();
|
|
await loadAppStrings();
|
|
} catch (e) {
|
|
document.getElementById('appuiRoot').innerHTML =
|
|
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
function currentVariantId(screen) {
|
|
const variants = getVariants(screen);
|
|
const stored = selectedVariantByScreen[screen.id];
|
|
if (stored && variants.some(v => v.id === stored)) return stored;
|
|
return variants[0].id;
|
|
}
|
|
|
|
function setVariant(screenId, variantId) {
|
|
selectedVariantByScreen[screenId] = variantId;
|
|
localStorage.setItem(STORAGE_VARIANT, JSON.stringify(selectedVariantByScreen));
|
|
}
|
|
|
|
function renderShell() {
|
|
document.getElementById('appuiRoot').innerHTML = `
|
|
<div class="appui-layout">
|
|
<aside class="appui-sidebar">
|
|
<h2 class="appui-panel-title">Screens</h2>
|
|
<nav class="appui-screen-nav" id="appuiScreenNav" aria-label="App screens"></nav>
|
|
</aside>
|
|
|
|
<section class="appui-stage">
|
|
<div class="appui-preview-card">
|
|
<div class="appui-variant-bar" id="appuiVariantBar" hidden></div>
|
|
<div class="appui-tablet-wrap">
|
|
<div class="appui-tablet" aria-label="Tablet preview">
|
|
<div class="appui-tablet-bezel">
|
|
<div class="appui-tablet-camera"></div>
|
|
<div class="appui-tablet-screen" id="appuiTabletScreen"></div>
|
|
<div class="appui-tablet-home"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="appui-keys-panel">
|
|
<div class="appui-keys-head">
|
|
<h2 class="appui-panel-title">Text keys</h2>
|
|
<p class="appui-keys-meta" id="appuiKeysHint"></p>
|
|
</div>
|
|
<div class="appui-keys-list" id="appuiKeysList"></div>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
`;
|
|
renderScreenNav();
|
|
}
|
|
|
|
function renderScreenNav() {
|
|
const nav = document.getElementById('appuiScreenNav');
|
|
nav.innerHTML = APP_UI_SCREENS.map(s => {
|
|
const variants = getVariants(s);
|
|
const multi = variants.length > 1 && variants[0].id !== 'default';
|
|
return `
|
|
<button type="button" class="appui-screen-btn${s.id === selectedScreenId ? ' active' : ''}"
|
|
data-screen="${esc(s.id)}">
|
|
<span class="appui-screen-btn-label">${esc(s.label)}</span>
|
|
${multi ? `<span class="appui-screen-btn-meta">${variants.length}</span>` : ''}
|
|
</button>`;
|
|
}).join('');
|
|
|
|
nav.querySelectorAll('.appui-screen-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
selectedScreenId = btn.dataset.screen;
|
|
localStorage.setItem(STORAGE_SCREEN, selectedScreenId);
|
|
renderScreenNav();
|
|
renderWorkspace();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderVariantBar(screen) {
|
|
const bar = document.getElementById('appuiVariantBar');
|
|
const variants = getVariants(screen);
|
|
const multi = variants.length > 1 && variants[0].id !== 'default';
|
|
if (!multi) {
|
|
bar.hidden = true;
|
|
bar.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const activeId = currentVariantId(screen);
|
|
bar.hidden = false;
|
|
bar.innerHTML = `
|
|
<div class="appui-variant-chips" role="tablist" aria-label="Screen states">
|
|
${variants.map(v => `
|
|
<button type="button" role="tab"
|
|
class="appui-variant-chip${v.id === activeId ? ' active' : ''}"
|
|
data-variant="${esc(v.id)}"
|
|
aria-selected="${v.id === activeId}">
|
|
${esc(v.label)}
|
|
</button>`).join('')}
|
|
</div>`;
|
|
|
|
bar.querySelectorAll('.appui-variant-chip').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
setVariant(screen.id, btn.dataset.variant);
|
|
renderWorkspace();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadAppStrings() {
|
|
const data = await apiGet('translations.php?all=1');
|
|
appStrings = (data.appStrings || []).map(normalizeEntry).filter(e => e.type === 'app_string');
|
|
|
|
germanByKey = {};
|
|
appStrings.forEach(e => {
|
|
const key = e.key || e.entityId;
|
|
if (key) germanByKey[key] = germanFor(e);
|
|
});
|
|
|
|
renderWorkspace();
|
|
}
|
|
|
|
function textFor(key) {
|
|
if (germanByKey[key] != null && germanByKey[key] !== '') return germanByKey[key];
|
|
const entry = appStrings.find(e => (e.key || e.entityId) === key);
|
|
if (entry) return germanFor(entry);
|
|
return key;
|
|
}
|
|
|
|
function renderWorkspace() {
|
|
const screen = screenById(selectedScreenId);
|
|
const variant = variantById(screen, currentVariantId(screen));
|
|
const focusSet = new Set(variant.focusKeys || screen.keys);
|
|
|
|
renderVariantBar(screen);
|
|
|
|
const hint = document.getElementById('appuiKeysHint');
|
|
if (hint) {
|
|
const nFocus = [...focusSet].filter(k => screen.keys.includes(k)).length;
|
|
hint.textContent = variant.id !== 'default'
|
|
? `${variant.label} · ${nFocus} keys`
|
|
: `${screen.keys.length} keys · German`;
|
|
}
|
|
|
|
const tablet = document.getElementById('appuiTabletScreen');
|
|
tablet.innerHTML = variant.mock();
|
|
fillFields(tablet);
|
|
bindFields(tablet);
|
|
|
|
renderKeysList(screen, focusSet);
|
|
}
|
|
|
|
function fillFields(root) {
|
|
root.querySelectorAll('[data-appui-key]').forEach(el => {
|
|
const key = el.dataset.appuiKey;
|
|
el.value = textFor(key);
|
|
el.title = key;
|
|
el.disabled = !canEdit();
|
|
});
|
|
}
|
|
|
|
function bindFields(root) {
|
|
root.querySelectorAll('[data-appui-key]').forEach(el => {
|
|
el.addEventListener('input', () => {
|
|
const key = el.dataset.appuiKey;
|
|
germanByKey[key] = el.value;
|
|
syncKeyListInput(key, el.value);
|
|
scheduleSave(key, el);
|
|
});
|
|
el.addEventListener('change', () => {
|
|
const key = el.dataset.appuiKey;
|
|
germanByKey[key] = el.value;
|
|
scheduleSave(key, el, true);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderKeysList(screen, focusSet) {
|
|
const list = document.getElementById('appuiKeysList');
|
|
const editable = canEdit();
|
|
const focused = screen.keys.filter(k => focusSet.has(k));
|
|
const other = screen.keys.filter(k => !focusSet.has(k));
|
|
|
|
const row = (key, inFocus) => {
|
|
const val = esc(textFor(key));
|
|
return `
|
|
<div class="appui-key-row${inFocus ? ' in-variant' : ' out-variant'}" data-key="${esc(key)}">
|
|
<code class="appui-key-code">${esc(key)}</code>
|
|
<textarea class="appui-key-input" data-appui-key="${esc(key)}" rows="2"
|
|
${editable ? '' : 'disabled'}>${val}</textarea>
|
|
</div>`;
|
|
};
|
|
|
|
let html = '';
|
|
if (focused.length && other.length) {
|
|
html += `<div class="appui-keys-group-label">This state</div>`;
|
|
}
|
|
html += focused.map(k => row(k, true)).join('');
|
|
if (other.length) {
|
|
html += `<div class="appui-keys-group-label">Other keys on this screen</div>`;
|
|
html += other.map(k => row(k, false)).join('');
|
|
}
|
|
list.innerHTML = html;
|
|
|
|
list.querySelectorAll('[data-appui-key]').forEach(el => {
|
|
el.addEventListener('input', () => {
|
|
const key = el.dataset.appuiKey;
|
|
germanByKey[key] = el.value;
|
|
syncTabletField(key, el.value);
|
|
scheduleSave(key, el);
|
|
});
|
|
el.addEventListener('change', () => {
|
|
const key = el.dataset.appuiKey;
|
|
germanByKey[key] = el.value;
|
|
scheduleSave(key, el, true);
|
|
});
|
|
el.addEventListener('focus', () => {
|
|
list.querySelectorAll('.appui-key-row').forEach(r => r.classList.remove('focused'));
|
|
el.closest('.appui-key-row')?.classList.add('focused');
|
|
highlightTabletKey(el.dataset.appuiKey);
|
|
});
|
|
});
|
|
}
|
|
|
|
function syncKeyListInput(key, value) {
|
|
const inp = document.querySelector(`#appuiKeysList [data-appui-key="${CSS.escape(key)}"]`);
|
|
if (inp && inp.value !== value) inp.value = value;
|
|
}
|
|
|
|
function syncTabletField(key, value) {
|
|
document.querySelectorAll(`#appuiTabletScreen [data-appui-key="${CSS.escape(key)}"]`).forEach(el => {
|
|
if (el.value !== value) el.value = value;
|
|
});
|
|
}
|
|
|
|
function highlightTabletKey(key) {
|
|
document.querySelectorAll('#appuiTabletScreen [data-appui-key]').forEach(el => {
|
|
el.classList.toggle('appui-field-highlight', el.dataset.appuiKey === key);
|
|
});
|
|
}
|
|
|
|
function setSaveStatus(state) {
|
|
const el = document.getElementById('appuiSaveStatus');
|
|
if (!el) return;
|
|
if (state === 'saving') el.textContent = 'Saving…';
|
|
else if (state === 'saved') el.textContent = 'Saved';
|
|
else if (state === 'error') el.textContent = 'Save failed';
|
|
else el.textContent = '';
|
|
}
|
|
|
|
function scheduleSave(key, el, immediate = false) {
|
|
if (!canEdit()) return;
|
|
clearTimeout(saveTimers[key]);
|
|
const run = () => saveGerman(key, el);
|
|
if (immediate) run();
|
|
else {
|
|
saveTimers[key] = setTimeout(run, 450);
|
|
setSaveStatus('saving');
|
|
}
|
|
}
|
|
|
|
async function saveGerman(key, el) {
|
|
savesInFlight++;
|
|
setSaveStatus('saving');
|
|
try {
|
|
await apiPut('translations.php', {
|
|
type: 'app_string',
|
|
id: key,
|
|
languageCode: SOURCE_LANG,
|
|
text: el.value,
|
|
});
|
|
germanByKey[key] = el.value;
|
|
const entry = appStrings.find(e => (e.key || e.entityId) === key);
|
|
if (entry) {
|
|
entry.germanText = el.value;
|
|
entry.translations = { ...(entry.translations || {}), [SOURCE_LANG]: el.value };
|
|
}
|
|
el.classList.add('save-flash');
|
|
setTimeout(() => el.classList.remove('save-flash'), 400);
|
|
} catch (e) {
|
|
setSaveStatus('error');
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
savesInFlight = Math.max(0, savesInFlight - 1);
|
|
if (savesInFlight === 0) setSaveStatus('saved');
|
|
}
|
|
}
|