started working on app ui page
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-18 12:58:06 +02:00
parent 14f90bd1fd
commit 154535edc2
7 changed files with 3330 additions and 64 deletions

1762
website/js/app-ui-screens.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ import { usersPage } from './pages/users.js';
import { assignmentsPage } from './pages/assignments.js';
import { clientsPage } from './pages/clients.js';
import { translationsPage } from './pages/translations.js';
import { appUiPage } from './pages/app-ui.js';
import { devPage } from './pages/dev.js';
import { insightsPage } from './pages/insights.js';
import { coachesPage } from './pages/coaches.js';
@ -163,6 +164,7 @@ addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
addRoute('/app-ui', roleGuard(['admin', 'supervisor'], appUiPage));
addRoute('/insights', roleGuard(['admin', 'supervisor'], insightsPage));
addRoute('/coaches', roleGuard(['admin', 'supervisor'], coachesPage));
addRoute('/dev', roleGuard(['admin'], devPage));

325
website/js/pages/app-ui.js Normal file
View File

@ -0,0 +1,325 @@
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');
}
}