This commit is contained in:
1762
website/js/app-ui-screens.js
Normal file
1762
website/js/app-ui-screens.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
325
website/js/pages/app-ui.js
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user