Feature: Hilfe/FAQ, Übungen-Content, Navigation-Fixes (SW by-v727)

Hilfe & FAQ:
- Neue Seite /hilfe mit Akkordeon + Live-Suche (6 Kategorien, 25 Artikel)
- DB-Tabelle help_articles — Inhalte admin-seitig ohne Deploy änderbar
- Admin-Tab Hilfe/FAQ zum Bearbeiten aller Artikel
- Link in Einstellungen (unter Welten einrichten, über Abmelden)
- routes/help.py: GET (public), POST/PATCH/DELETE (Admin)

Übungen:
- 110 Übungen: beschreibung (kurz), schritte (JSON 4-6 Schritte), tipp — gutes Deutsch mit Umlauten
- Admin-Tab Übungen: Inline-Editor für alle drei Felder
- PUT /training/exercises/{id} (Admin) neu
- Übung-des-Tages Chip → scrollt jetzt korrekt zur Übung (exercise_id-Feldname-Fix)

Welten-Navigation:
- hide() stellt app-header + bottom-nav wieder her (worlds-hidden wurde nie entfernt)
- init() mit _setupDone-Guard (keine doppelten Event-Listener)
- Login ruft Worlds.init(_appState) statt show() — _state war null → falscher Render
- X-Button in Welten-Konfiguration: 30×30px, Icon 17px, besser sichtbar

Wetter:
- Motivation bei blockiertem Standort: 6-Schritte-iOS-Anleitung + Flugmodus-Tipp
- Auto-locate bleibt (kein Button-Only mehr)

achievements.py:
- my_achievements(): d.user_id → JOIN dogs (zweite Funktion war noch kaputt)
This commit is contained in:
rene 2026-05-05 21:46:16 +02:00
parent 55069d246b
commit 05ecf3b94a
13 changed files with 1158 additions and 43 deletions

View file

@ -409,6 +409,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-hilfe">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-settings">
<div class="page-body page-container"></div>
</section>
@ -574,7 +578,7 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=715"></script>
<script src="/js/worlds.js?v=727"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '715'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '727'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
@ -79,6 +79,7 @@ const App = (() => {
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
personality: { title: 'Persönlichkeitstest', module: null },
reise: { title: 'Reise mit Hund', module: null },
hilfe: { title: 'Hilfe & FAQ', module: null },
};
// ----------------------------------------------------------

View file

@ -23,6 +23,8 @@ window.Page_admin = (() => {
{ id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
];
// ------------------------------------------------------------------
@ -157,6 +159,8 @@ window.Page_admin = (() => {
case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
case 'hilfe': await _renderHilfe(el); break;
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -2809,6 +2813,469 @@ window.Page_admin = (() => {
await _load();
}
// ------------------------------------------------------------------
// TAB: HILFE / FAQ
async function _renderHilfe(el) {
const KAT_LABEL = {
installation: 'Installation & PWA',
erste_schritte: 'Erste Schritte',
standort: 'Standort & Wetter',
account: 'Account & Passwort',
features: 'Features erklärt',
probleme: 'Technische Probleme',
};
el.innerHTML = `
<div style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-4)">
<h2 style="margin:0;font-size:var(--text-lg)">Hilfe / FAQ</h2>
<button class="btn btn-primary btn-sm" id="adm-hilfe-neu">
${UI.icon('plus')} Neuer Artikel
</button>
</div>
<!-- Neuer-Artikel-Formular (versteckt) -->
<div id="adm-hilfe-form" style="display:none;background:var(--c-surface-2);
border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-4);margin-bottom:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuer Artikel</h3>
<div style="display:grid;gap:var(--space-3)">
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Kategorie</label>
<select id="adm-hilfe-kat" style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
${Object.entries(KAT_LABEL).map(([k,v]) =>
`<option value="${k}">${_esc(v)}</option>`
).join('')}
</select>
</div>
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Frage</label>
<input id="adm-hilfe-frage" type="text" placeholder="Frage eingeben…"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Antwort</label>
<textarea id="adm-hilfe-antwort" rows="4" placeholder="Antwort eingeben…"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box;
resize:vertical;font-family:inherit"></textarea>
</div>
<div style="display:flex;gap:var(--space-2)">
<label style="font-size:var(--text-sm);font-weight:500;margin-right:var(--space-2)">
Reihenfolge
</label>
<input id="adm-hilfe-sort" type="number" value="0" min="0"
style="width:80px;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
</div>
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary btn-sm" id="adm-hilfe-form-cancel">Abbrechen</button>
<button class="btn btn-primary btn-sm" id="adm-hilfe-form-save">Speichern</button>
</div>
</div>
</div>
<!-- Artikel-Liste -->
<div id="adm-hilfe-list">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
Lade
</div>
</div>
</div>
`;
async function _load() {
const listEl = el.querySelector('#adm-hilfe-list');
try {
const articles = await API.get('/help?all=1');
if (!articles.length) {
listEl.innerHTML = _emptyState('question', 'Noch keine FAQ-Artikel', '');
return;
}
// Gruppieren nach Kategorie
const grouped = {};
for (const a of articles) {
if (!grouped[a.kategorie]) grouped[a.kategorie] = [];
grouped[a.kategorie].push(a);
}
let html = '';
for (const [kat, items] of Object.entries(grouped)) {
const label = KAT_LABEL[kat] || kat;
html += `
<div style="margin-bottom:var(--space-5)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:0.05em;
padding:var(--space-2) 0;margin-bottom:var(--space-2);
border-bottom:1px solid var(--c-border)">
${_esc(label)}
</div>
`;
for (const a of items) {
html += `
<div class="adm-hilfe-row" data-id="${a.id}"
style="border:1px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);margin-bottom:var(--space-2)">
<!-- Zusammenfassung -->
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-3) var(--space-4)">
<span style="flex:1;font-size:var(--text-sm);font-weight:500;
${a.aktiv ? '' : 'opacity:0.45;text-decoration:line-through'}">
${_esc(a.frage)}
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);
white-space:nowrap">
#${a.sort_order}
</span>
<button class="btn btn-sm adm-hilfe-edit-btn"
style="padding:2px 8px;font-size:var(--text-xs)"
data-id="${a.id}">
${UI.icon('pencil-simple')} Bearbeiten
</button>
<button class="btn btn-sm adm-hilfe-toggle-btn"
style="padding:2px 8px;font-size:var(--text-xs);
background:${a.aktiv ? 'var(--c-warning-bg,#fef3c7)' : 'var(--c-success-bg,#d1fae5)'};
color:${a.aktiv ? 'var(--c-warning,#92400e)' : 'var(--c-success,#065f46)'}"
data-id="${a.id}" data-aktiv="${a.aktiv}">
${a.aktiv ? UI.icon('eye-slash') + ' Ausblenden' : UI.icon('eye') + ' Einblenden'}
</button>
<button class="btn btn-sm adm-hilfe-del-btn"
style="padding:2px 8px;font-size:var(--text-xs);
background:var(--c-danger-bg,#fee2e2);color:var(--c-danger,#991b1b)"
data-id="${a.id}" data-frage="${_esc(a.frage)}">
${UI.icon('trash')}
</button>
</div>
<!-- Edit-Formular (versteckt) -->
<div class="adm-hilfe-edit-form" data-id="${a.id}"
style="display:none;padding:0 var(--space-4) var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:grid;gap:var(--space-3);padding-top:var(--space-3)">
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Kategorie</label>
<select class="adm-hilfe-edit-kat"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
${Object.entries(KAT_LABEL).map(([k,v]) =>
`<option value="${k}" ${k === a.kategorie ? 'selected' : ''}>${_esc(v)}</option>`
).join('')}
</select>
</div>
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Frage</label>
<input type="text" class="adm-hilfe-edit-frage"
value="${_esc(a.frage)}"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Antwort</label>
<textarea class="adm-hilfe-edit-antwort" rows="5"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box;
resize:vertical;font-family:inherit">${_esc(a.antwort)}</textarea>
</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<label style="font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary)">Reihenfolge</label>
<input type="number" class="adm-hilfe-edit-sort"
value="${a.sort_order}" min="0"
style="width:70px;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
<div style="flex:1"></div>
<button class="btn btn-secondary btn-sm adm-hilfe-edit-cancel" data-id="${a.id}"
style="font-size:var(--text-xs)">Abbrechen</button>
<button class="btn btn-primary btn-sm adm-hilfe-edit-save" data-id="${a.id}"
style="font-size:var(--text-xs)">Speichern</button>
</div>
</div>
</div>
</div>
`;
}
html += `</div>`;
}
listEl.innerHTML = html;
_bindListEvents(listEl);
} catch (e) {
listEl.innerHTML = _emptyState('warning', 'Fehler beim Laden', e.message || '');
}
}
function _bindListEvents(listEl) {
// Edit-Button: Inline-Formular auf/zu klappen
listEl.querySelectorAll('.adm-hilfe-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
if (form) form.style.display = form.style.display === 'none' ? '' : 'none';
});
});
// Edit-Cancel
listEl.querySelectorAll('.adm-hilfe-edit-cancel').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
if (form) form.style.display = 'none';
});
});
// Edit-Save
listEl.querySelectorAll('.adm-hilfe-edit-save').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const row = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
const payload = {
kategorie: row.querySelector('.adm-hilfe-edit-kat').value,
frage: row.querySelector('.adm-hilfe-edit-frage').value.trim(),
antwort: row.querySelector('.adm-hilfe-edit-antwort').value.trim(),
sort_order: parseInt(row.querySelector('.adm-hilfe-edit-sort').value, 10) || 0,
};
if (!payload.frage || !payload.antwort) {
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
return;
}
try {
await API.patch(`/help/${id}`, payload);
UI.toast.success('Artikel gespeichert.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
});
});
// Toggle aktiv/inaktiv
listEl.querySelectorAll('.adm-hilfe-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const aktiv = parseInt(btn.dataset.aktiv, 10);
try {
await API.patch(`/help/${id}`, { aktiv: aktiv ? 0 : 1 });
UI.toast.success(aktiv ? 'Artikel ausgeblendet.' : 'Artikel eingeblendet.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
// Delete
listEl.querySelectorAll('.adm-hilfe-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const frage = btn.dataset.frage;
if (!window.confirm(`Artikel wirklich löschen?\n\n"${frage}"`)) return;
try {
await API.del(`/help/${id}`);
UI.toast.success('Artikel gelöscht.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Löschen.'); }
});
});
}
// Neuer-Artikel-Button
el.querySelector('#adm-hilfe-neu').addEventListener('click', () => {
const form = el.querySelector('#adm-hilfe-form');
form.style.display = form.style.display === 'none' ? '' : 'none';
});
// Formular abbrechen
el.querySelector('#adm-hilfe-form-cancel').addEventListener('click', () => {
el.querySelector('#adm-hilfe-form').style.display = 'none';
});
// Formular speichern
el.querySelector('#adm-hilfe-form-save').addEventListener('click', async () => {
const kat = el.querySelector('#adm-hilfe-kat').value;
const frage = el.querySelector('#adm-hilfe-frage').value.trim();
const antwort= el.querySelector('#adm-hilfe-antwort').value.trim();
const sort = parseInt(el.querySelector('#adm-hilfe-sort').value, 10) || 0;
if (!frage || !antwort) {
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
return;
}
try {
await API.post('/help', { kategorie: kat, frage, antwort, sort_order: sort });
UI.toast.success('Artikel angelegt.');
el.querySelector('#adm-hilfe-form').style.display = 'none';
el.querySelector('#adm-hilfe-frage').value = '';
el.querySelector('#adm-hilfe-antwort').value = '';
el.querySelector('#adm-hilfe-sort').value = '0';
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Anlegen.'); }
});
await _load();
}
// ------------------------------------------------------------------
async function _renderUebungenAdmin(el) {
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade Übungen…</div>`;
let byTab;
try {
byTab = await API.get('/training/exercises');
} catch (e) {
el.innerHTML = `<div style="padding:var(--space-6);color:var(--c-danger)">Fehler: ${e.message}</div>`;
return;
}
// Flatten to sorted list grouped by kategorie
const allExercises = [];
for (const [kat, list] of Object.entries(byTab)) {
for (const ex of list) allExercises.push({ ...ex, _kat: kat });
}
allExercises.sort((a, b) => a._kat.localeCompare(b._kat) || a.name.localeCompare(b.name));
// Group by kategorie
const grouped = {};
for (const ex of allExercises) {
grouped[ex._kat] = grouped[ex._kat] || [];
grouped[ex._kat].push(ex);
}
const KAT_LABELS = {
'grundkommandos': 'Grundkommandos', 'tricks': 'Tricks',
'problemverhalten': 'Problemverhalten', 'mentale-auslastung': 'Mentale Auslastung',
'koerperpflege': 'Körperpflege', 'hundesport': 'Hundesport', 'welpe-basics': 'Welpe Basics',
};
let html = `<div style="padding:var(--space-4)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4)">
Trainingsübungen bearbeiten
</h2>`;
for (const [kat, list] of Object.entries(grouped)) {
html += `<div style="margin-bottom:var(--space-6)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-secondary);
padding:var(--space-2) 0 var(--space-2);
border-bottom:1px solid var(--c-border);margin-bottom:var(--space-2)">
${KAT_LABELS[kat] || kat} (${list.length})
</div>`;
for (const ex of list) {
const schritte = Array.isArray(ex.schritte) ? ex.schritte.join('\n') : '';
const exId = ex.exercise_id;
html += `<div class="adm-ueb-row" data-ex-id="${exId}"
style="padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="flex:1;font-size:var(--text-sm);font-weight:500">${ex.name}</span>
<button class="adm-ueb-edit-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:2px 10px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-surface);
cursor:pointer;color:var(--c-text-secondary)">Bearbeiten</button>
</div>
<div class="adm-ueb-form" data-ex-id="${exId}"
style="display:none;margin-top:var(--space-3);
background:var(--c-surface-2);border-radius:8px;padding:var(--space-3)">
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Beschreibung
</label>
<textarea class="adm-ueb-beschreibung" rows="2"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);
color:var(--c-text);resize:vertical">${(ex.beschreibung || '').replace(/</g, '&lt;')}</textarea>
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Schritte (eine Zeile = ein Schritt)
</label>
<textarea class="adm-ueb-schritte" rows="6"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);
color:var(--c-text);resize:vertical">${schritte.replace(/</g, '&lt;')}</textarea>
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Tipp
</label>
<input class="adm-ueb-tipp" type="text"
value="${(ex.tipp || '').replace(/"/g, '&quot;')}"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-3);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);color:var(--c-text)">
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="adm-ueb-cancel-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-surface);
cursor:pointer;color:var(--c-text-secondary)">Abbrechen</button>
<button class="adm-ueb-save-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
border:none;background:var(--c-primary);
cursor:pointer;color:#fff;font-weight:600">Speichern</button>
</div>
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
el.innerHTML = html;
// Edit toggle
el.querySelectorAll('.adm-ueb-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`);
form.style.display = form.style.display === 'none' ? '' : 'none';
});
});
// Cancel
el.querySelectorAll('.adm-ueb-cancel-btn').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`).style.display = 'none';
});
});
// Save
el.querySelectorAll('.adm-ueb-save-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.exId;
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${id}"]`);
const beschreibung = form.querySelector('.adm-ueb-beschreibung').value.trim();
const schritte = form.querySelector('.adm-ueb-schritte').value
.split('\n').map(s => s.trim()).filter(Boolean);
const tipp = form.querySelector('.adm-ueb-tipp').value.trim();
btn.disabled = true;
btn.textContent = 'Speichert…';
try {
await API.put(`/training/exercises/${id}`, {
beschreibung,
schritte: JSON.stringify(schritte),
tipp,
});
UI.toast.success('Übung gespeichert.');
form.style.display = 'none';
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Speichern.');
} finally {
btn.disabled = false;
btn.textContent = 'Speichern';
}
});
});
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };

View file

@ -0,0 +1,246 @@
/* ============================================================
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 style="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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 };
})();

View file

@ -270,6 +270,12 @@ window.Page_settings = (() => {
<span>Welten einrichten</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-hilfe-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border);cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#question"></use></svg>
<span>Hilfe &amp; FAQ</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
@ -766,6 +772,10 @@ window.Page_settings = (() => {
else if (window.Worlds) window.Worlds.openConfig?.();
});
document.getElementById('settings-hilfe-btn')?.addEventListener('click', () => {
App.navigate('hilfe');
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',
@ -1672,9 +1682,9 @@ window.Page_settings = (() => {
_offerPushNotifications();
}
// Nach Login: Direkt in HUND-Welt oder Profil anlegen
// Nach Login: Welten initialisieren (mit User-State) oder Profil anlegen
if (_appState.activeDog) {
window.Worlds?.show(1);
window.Worlds?.init(_appState);
} else {
App.navigate('dog-profile');
}

View file

@ -73,23 +73,17 @@ window.Page_wetter = (() => {
_tryAutoLocate();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_selDay = 0;
_selDay = 0;
_recordsLoaded = false;
_renderShell();
_tryAutoLocate();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _renderShell() {
_container.innerHTML = `
<div id="wttr-body">
<div id="wttr-locating" style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="margin-bottom:var(--space-3)">${_wmoIcon(2, '2.5rem')}</div>
<p style="color:var(--c-text-secondary)">Standort wird ermittelt</p>
</div>
@ -97,26 +91,55 @@ window.Page_wetter = (() => {
`;
}
// ----------------------------------------------------------
// STANDORT AUTOMATISCH ERMITTELN
// ----------------------------------------------------------
async function _tryAutoLocate() {
try {
const pos = await API.getLocation({ timeout: 8000, maximumAge: 300_000 });
await _loadData(pos.lat, pos.lon);
} catch {
_showLocationError();
} catch (err) {
_showLocationError(err?.code);
}
}
function _showLocationError() {
const body = _container.querySelector('#wttr-body');
function _showLocationError(errCode) {
const body = _container?.querySelector('#wttr-body');
if (!body) return;
const isLoggedIn = !!_appState?.user;
const isLoggedIn = !!_appState?.user;
const isDenied = errCode === 1; // GeolocationPositionError.PERMISSION_DENIED
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const deniedHint = isDenied ? `
<div style="background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.4);
border-radius:var(--radius-lg);padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:#F59E0B;flex-shrink:0">
<use href="/icons/phosphor.svg#warning"></use>
</svg>
<div style="font-weight:700;font-size:var(--text-sm)">Standort-Zugriff blockiert</div>
</div>
${isIos ? `
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
<b>Wichtig:</b> Die App läuft getrennt von Safari Safari-Einstellungen gelten hier nicht.
</div>
<ol style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-1);
color:var(--c-text-secondary);font-size:var(--text-sm)">
<li>Öffne <b>Einstellungen Datenschutz &amp; Sicherheit Ortungsdienste</b></li>
<li>Scrolle ganz nach unten zu <b>Ban Yaro</b> (nicht Safari!)</li>
<li>Wähle <b>Beim Verwenden der App"</b></li>
<li>Komm zurück und tippe nochmal auf den Button</li>
</ol>
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">
<b>Letzter Ausweg:</b> Einstellungen Apps Safari Erweitert Website-Daten banyaro.app löschen. Danach nochmal öffnen und Button tippen.
</div>` : `
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Klicke auf das Schloss-Symbol in der Adressleiste <b>Standort</b> <b>Erlauben</b>, dann nochmal tippen.
</div>`}
</div>` : '';
body.innerHTML = `
<div style="max-width:420px;margin:0 auto;padding:var(--space-6) var(--space-4)">
${deniedHint}
<!-- Hero -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:4rem;line-height:1;margin-bottom:var(--space-2)">🌤🐾</div>

View file

@ -14,6 +14,7 @@ window.Worlds = (() => {
let _dogs = []; // gecachte Hundesliste
let _dogIdx = 0; // aktuell angezeigter Hund
let _hasBgPhoto = false; // Hintergrund-Foto vorhanden?
let _setupDone = false; // Swipe/Button-Listener nur einmal registrieren
// Touch-Tracking
const _t = { x:0, y:0, active:false, vert:null, moved:0 };
@ -44,13 +45,17 @@ window.Worlds = (() => {
// ── PUBLIC ──────────────────────────────────────────────────
async function init(appState) {
_state = appState;
_cur = 1; // immer HUND als Start
_setupSwipe();
_setupButtons();
_state = appState;
_lastUserId = undefined; // Neurender erzwingen
_cur = 1;
if (!_setupDone) {
_setupDone = true;
_setupSwipe();
_setupButtons();
_showSwipeHints();
}
_goTo(_cur, false);
show();
_showSwipeHints();
}
function _showSwipeHints() {
@ -133,6 +138,8 @@ window.Worlds = (() => {
ov.classList.remove('worlds-visible');
ov.style.display = 'none';
_visible = false;
document.getElementById('app-header')?.classList.remove('worlds-hidden');
document.getElementById('bottom-nav')?.classList.remove('worlds-hidden');
document.getElementById('worlds-back')?.classList.add('worlds-back-visible');
}
@ -665,11 +672,11 @@ window.Worlds = (() => {
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none">
${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-8px;right:-8px;width:24px;height:24px;
border-radius:50%;background:#EF4444;border:2px solid rgba(18,22,32,0.9);
style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;
border-radius:50%;background:#EF4444;border:3px solid rgba(18,22,32,0.95);
cursor:pointer;display:flex;align-items:center;justify-content:center;
z-index:2;box-shadow:0 2px 6px rgba(0,0,0,0.5)">
<svg class="ph-icon" style="width:13px;height:13px;color:white">
z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.7)">
<svg class="ph-icon" style="width:17px;height:17px;color:white;stroke:white;stroke-width:1.5">
<use href="/icons/phosphor.svg#x"></use>
</svg>
</button>` : `
@ -1001,7 +1008,7 @@ window.Worlds = (() => {
<span class="wj-chip-val" id="wj-route-val"></span>
${totalKm != null ? `<span style="font-size:9px;color:rgba(255,255,255,0.4);margin-top:1px">∑ ${totalKm} km</span>` : ''}
</div>
<div class="wj-chip" data-wnav="uebungen">
<div class="wj-chip" id="wj-exercise-chip">
<svg class="ph-icon" style="width:1.2rem;height:1.2rem;color:var(--c-primary)">
<use href="/icons/phosphor.svg#barbell"></use></svg>
<span class="wj-chip-label">Übung</span>
@ -1034,6 +1041,17 @@ window.Worlds = (() => {
const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`);
const ex = res.data?.daily_exercise;
valEl.textContent = ex?.name || '—';
// Chip-Klick mit exercise_id/name damit übungen.js direkt dorthin scrollt
const chip = document.getElementById('wj-exercise-chip');
if (chip) {
chip.style.cursor = 'pointer';
chip.onclick = () => {
hide();
if (window.App) window.App.navigate('uebungen', true,
ex ? { exercise_id: ex.exercise_id || '', name: ex.name || '' } : {}
);
};
}
} catch { valEl.textContent = '—'; }
}

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v715';
const CACHE_VERSION = 'by-v727';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache