PHASE 1 — Sofort-Cleanup ohne Risiko: - Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen: * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3 * flex-between, flex-1-min, mb-1/3, mt-1/3 * icon-xs/sm/md/lg, label-block, caption - index.html bindet utilities.css ein - mb-3/mt-3 ergänzt (waren in design-system.css unvollständig) PHASE 2 — .by-tab Modifier für Vereinheitlichung: - .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.) - .by-tabs.sticky (Desktop vertikale Tabs für Admin) - .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll) - .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border) PHASE 3 — Inline-Style → Klassen-Migration (Python-Script): - 948 Inline-Styles entfernt (5101 → 4153, -18%) - 962 Migrationen über 47 Page-Dateien - Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67), litters.js (62), settings.js (61), zuchthunde.js (51) - Patterns: text-muted, text-secondary, text-danger, text-xs-muted, text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3, p-3/4, mb-2/3/4, hidden, w-full, flex-1, ... - Bewahrt bestehende class-Attribute (mergt korrekt) Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
246 lines
8.8 KiB
JavaScript
246 lines
8.8 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 "${_esc(_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)">
|
|
${_esc(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)
|
|
: _esc(a.frage);
|
|
|
|
// Antwort: Zeilenumbrüche in <br> wandeln
|
|
const antwortHtml = _search
|
|
? _highlight(a.antwort, _search).replace(/\n/g, '<br>')
|
|
: _esc(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 _esc(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function _highlight(text, term) {
|
|
if (!term) return text;
|
|
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`(${safe})`, 'gi');
|
|
return _esc(text).replace(re,
|
|
'<mark style="background:var(--c-warning-bg,#fef3c7);color:inherit;border-radius:2px">$1</mark>'
|
|
);
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
return { init, refresh };
|
|
|
|
})();
|