banyaro/backend/static/js/pages/hilfe.js
rene c517c9281d Refactor: 1167 _esc() → UI.escape() in 36 Dateien, SW by-v1113
Bündel 1 aus dem Duplikat-Audit: existierende zentrale Helper nutzen
statt lokale Duplikate.

Pure Migration ohne neuen Code:
- 1167 _esc()-Aufrufe in 36 Page-Modulen migriert auf UI.escape()
- 24 lokale _esc/_escape-Definitionen entfernt
- lost.js hatte _escape() (Variante) — 17 Aufrufe ebenfalls migriert
- jobs.js + breeder.js: tote Alias-Wrapper entfernt

UI.escape() existierte schon — wurde nur überall lokal nochmal
implementiert. Funktional identisch (gleiche 4-replace-chain für
& < > ").

Tests 19/19 grün. Frontend-LOC um ~120 Zeilen reduziert.

Hinweis: _emptyState (7 Stellen) und _icon (8 Stellen) wurden NICHT
migriert — sie haben abweichende Signaturen von UI.emptyState({...})
bzw. UI.icon(name). Eigener Sprint nötig.
2026-05-27 10:15:33 +02:00

238 lines
8.6 KiB
JavaScript

/* ============================================================
BAN YARO — Hilfe & FAQ
Akkordeon-FAQ mit Suche, gruppiert nach Kategorie.
============================================================ */
window.Page_hilfe = (() => {
let _container = null;
let _appState = null;
let _articles = [];
let _search = '';
const KAT_LABEL = {
installation: 'Installation & PWA',
erste_schritte: 'Erste Schritte',
standort: 'Standort & Wetter',
account: 'Account & Passwort',
features: 'Features erklärt',
probleme: 'Technische Probleme',
};
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_search = '';
_renderShell();
await _load();
}
async function refresh() {
await _load();
}
// ----------------------------------------------------------
function _renderShell() {
_container.innerHTML = `
<div style="padding:var(--space-4) var(--space-4) 0">
<!-- Suchfeld -->
<div style="position:relative;margin-bottom:var(--space-5)">
<svg class="ph-icon" aria-hidden="true"
style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
width:1.25rem;height:1.25rem;color:var(--c-text-muted);pointer-events:none">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input id="hilfe-search" type="search" autocomplete="off"
placeholder="Suche in den FAQ…"
style="width:100%;padding:var(--space-3) var(--space-3) var(--space-3) 2.75rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-base);box-sizing:border-box;
outline:none;transition:border-color 0.15s">
</div>
</div>
<!-- Artikel-Liste -->
<div id="hilfe-articles" style="padding:0 var(--space-4) var(--space-8)">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">
Lade…
</div>
</div>
`;
_container.querySelector('#hilfe-search').addEventListener('input', e => {
_search = e.target.value.trim().toLowerCase();
_render();
});
}
// ----------------------------------------------------------
async function _load() {
try {
_articles = await API.get('/help');
} catch {
_articles = [];
}
_render();
}
// ----------------------------------------------------------
function _render() {
const el = _container.querySelector('#hilfe-articles');
if (!el) return;
// Filter nach Suchbegriff
const filtered = _search
? _articles.filter(a =>
a.frage.toLowerCase().includes(_search) ||
a.antwort.toLowerCase().includes(_search)
)
: _articles;
if (!filtered.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" aria-hidden="true"
style="width:40px;height:40px;color:var(--c-border);margin-bottom:var(--space-3)">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<p style="font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">
Keine Ergebnisse
</p>
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
${_search
? `Zu "${UI.escape(_search)}" wurde nichts gefunden.`
: 'Noch keine FAQ-Artikel vorhanden.'}
</p>
</div>
`;
return;
}
// Gruppieren nach Kategorie (Reihenfolge der KAT_LABEL-Keys)
const katOrder = Object.keys(KAT_LABEL);
const grouped = {};
for (const a of filtered) {
if (!grouped[a.kategorie]) grouped[a.kategorie] = [];
grouped[a.kategorie].push(a);
}
// Sortieren nach KAT_LABEL-Reihenfolge, dann unbekannte hinten
const sortedKats = [
...katOrder.filter(k => grouped[k]),
...Object.keys(grouped).filter(k => !katOrder.includes(k)),
];
let html = '';
for (const kat of sortedKats) {
const items = grouped[kat];
const label = KAT_LABEL[kat] || kat;
html += `
<div style="margin-bottom:var(--space-6)">
<div style="font-size:var(--text-xs);font-weight:700;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2);
margin-bottom:var(--space-1)">
${UI.escape(label)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
`;
for (const a of items) {
const answerId = `hilfe-ans-${a.id}`;
const chevronId = `hilfe-chev-${a.id}`;
// Highlight Suchtreffer in der Frage
const frageHtml = _search
? _highlight(a.frage, _search)
: UI.escape(a.frage);
// Antwort: Zeilenumbrüche in <br> wandeln
const antwortHtml = _search
? _highlight(a.antwort, _search).replace(/\n/g, '<br>')
: UI.escape(a.antwort).replace(/\n/g, '<br>');
// Bei aktiver Suche: Antwort gleich aufgeklappt
const openByDefault = !!_search;
html += `
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);overflow:hidden">
<button class="hilfe-frage-btn"
data-target="${answerId}" data-chevron="${chevronId}"
aria-expanded="${openByDefault}"
style="width:100%;text-align:left;background:none;border:none;
padding:var(--space-4);cursor:pointer;
display:flex;align-items:flex-start;gap:var(--space-2);
font-size:var(--text-sm);font-weight:600;
color:var(--c-text);line-height:1.4">
<span class="flex-1">${frageHtml}</span>
<svg id="${chevronId}" class="ph-icon" aria-hidden="true"
style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;
color:var(--c-text-muted);
transform:rotate(${openByDefault ? '180deg' : '0deg'});
transition:transform 0.2s">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</button>
<div id="${answerId}"
style="overflow:hidden;
max-height:${openByDefault ? '2000px' : '0'};
transition:max-height 0.25s ease">
<div style="padding:0 var(--space-4) var(--space-4);
font-size:var(--text-sm);line-height:1.65;
color:var(--c-text-secondary);
border-top:1px solid var(--c-border)">
<div style="padding-top:var(--space-3)">${antwortHtml}</div>
</div>
</div>
</div>
`;
}
html += `</div></div>`;
}
el.innerHTML = html;
// Akkordeon-Interaktion
el.querySelectorAll('.hilfe-frage-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const chevronId = btn.dataset.chevron;
const answer = document.getElementById(targetId);
const chevron = document.getElementById(chevronId);
if (!answer) return;
const isOpen = answer.style.maxHeight !== '0px' && answer.style.maxHeight !== '';
if (isOpen) {
answer.style.maxHeight = '0';
if (chevron) chevron.style.transform = 'rotate(0deg)';
btn.setAttribute('aria-expanded', 'false');
} else {
answer.style.maxHeight = '2000px';
if (chevron) chevron.style.transform = 'rotate(180deg)';
btn.setAttribute('aria-expanded', 'true');
}
});
});
}
// ----------------------------------------------------------
function _highlight(text, term) {
if (!term) return text;
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${safe})`, 'gi');
return UI.escape(text).replace(re,
'<mark style="background:var(--c-warning-bg,#fef3c7);color:inherit;border-radius:2px">$1</mark>'
);
}
// ----------------------------------------------------------
return { init, refresh };
})();