+
+
+
+
@@ -179,6 +199,18 @@
+
+
+
+ +
+
+
+ +
+
+
+
@@ -187,6 +219,10 @@
+
+
+
+
@@ -233,9 +269,9 @@ - - - + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a5f029c..1631e17 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,10 +3,19 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '66'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '79'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { + // ---------------------------------------------------------- + // PWA INSTALL PROMPT — frühzeitig abfangen, bevor es verloren geht + // ---------------------------------------------------------- + let _installPrompt = null; + window.addEventListener('beforeinstallprompt', e => { + e.preventDefault(); + _installPrompt = e; + }); + // ---------------------------------------------------------- // STATE — zentraler App-Zustand // ---------------------------------------------------------- @@ -23,23 +32,41 @@ const App = (() => { // load() wird beim ersten Aufruf einmalig ausgeführt // ---------------------------------------------------------- const pages = { - diary: { title: 'Tagebuch', module: null }, - health: { title: 'Gesundheit', module: null }, - 'dog-profile': { title: 'Mein Hund', module: null }, - map: { title: 'Karte', module: null }, - routes: { title: 'Routen', module: null }, -events: { title: 'Events', module: null }, - poison: { title: 'Giftköder-Alarm', module: null }, - walks: { title: 'Gassi-Treffen', module: null }, - sitting: { title: 'Sitting', module: null }, - forum: { title: 'Forum', module: null }, - wiki: { title: 'Wiki', module: null }, - knigge: { title: 'Knigge', module: null }, - movies: { title: 'Filme', module: null }, - settings: { title: 'Einstellungen', module: null }, - lost: { title: 'Verlorener Hund', module: null }, - friends: { title: 'Freunde', module: null }, - chat: { title: 'Nachrichten', module: null }, + welcome: { title: 'Willkommen', module: null }, + diary: { title: 'Tagebuch', module: null, requiresAuth: true }, + health: { title: 'Gesundheit', module: null, requiresAuth: true }, + 'dog-profile': { title: 'Mein Hund', module: null, requiresAuth: true }, + map: { title: 'Karte', module: null }, + routes: { title: 'Routen', module: null }, + events: { title: 'Events', module: null }, + poison: { title: 'Giftköder-Alarm', module: null }, + walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true }, + sitting: { title: 'Sitting', module: null, requiresAuth: true }, + forum: { title: 'Forum', module: null }, + wiki: { title: 'Wiki', module: null }, + knigge: { title: 'Knigge', module: null }, + movies: { title: 'Filme', module: null }, + trainingsplaene: { title: 'Trainingspläne', module: null }, + uebungen: { title: 'Übungsbibliothek', module: null }, + 'erste-hilfe': { title: 'Erste Hilfe', module: null }, + settings: { title: 'Einstellungen', module: null }, + lost: { title: 'Verlorener Hund', module: null }, + friends: { title: 'Freunde', module: null, requiresAuth: true }, + chat: { title: 'Nachrichten', module: null, requiresAuth: true }, + admin: { title: 'Admin', module: null, requiresAuth: true }, + }; + + // ---------------------------------------------------------- + // AUTH GUARD — Login-Gate Texte pro Seite + // ---------------------------------------------------------- + const AUTH_GATE = { + diary: { icon: 'book-open', text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Meilensteine, Fotos.' }, + health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Medikamente und Allergien deines Hundes immer im Blick.' }, + 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund — mit Foto, Bio und NFC-Tag.' }, + friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und baue ein Netzwerk auf.' }, + chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.' }, + walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde in deiner Gegend.' }, + sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.' }, }; // ---------------------------------------------------------- @@ -78,6 +105,14 @@ events: { title: 'Events', module: null }, async function _loadPage(pageId, params = {}) { const page = pages[pageId]; + + // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User + if (page.requiresAuth && !state.user) { + const container = document.querySelector(`#page-${pageId} .page-body`); + if (container) _renderLoginGate(container, pageId); + return; + } + if (page.module) { const hasParams = params && Object.keys(params).length > 0; if (hasParams) { @@ -128,6 +163,80 @@ events: { title: 'Events', module: null }, } } + // ---------------------------------------------------------- + // LOGIN GATE — wird statt Seiteninhalt angezeigt + // ---------------------------------------------------------- + function _renderLoginGate(container, pageId) { + const gate = AUTH_GATE[pageId] || { icon: 'lock', text: 'Dieser Bereich ist nur für angemeldete Nutzer.' }; + const title = pages[pageId]?.title || 'Dieser Bereich'; + + container.innerHTML = ` +
+ + +
+ +
+ + +
+

+ ${_esc(title)} +

+

+ ${_esc(gate.text)} +

+
+ + +
+ + +
+ + +

+ Karte, Wiki, Übungen, Erste Hilfe und mehr sind ohne Account zugänglich. +

+ + + + +
+ `; + + container.querySelector('#gate-login-btn')?.addEventListener('click', () => { + navigate('settings'); + }); + container.querySelector('#gate-register-btn')?.addEventListener('click', () => { + navigate('settings'); + }); + container.querySelector('#gate-install-hint')?.addEventListener('click', () => { + navigate('welcome'); + }); + } + function _loadScript(src) { // Versionierter URL damit SW/Browser-Cache bei jedem Deploy invalidiert wird const versioned = `${src}?v=${APP_VER}`; @@ -153,6 +262,13 @@ events: { title: 'Events', module: null }, return; } + // Sidebar-Logo → Willkommensseite + if (e.target.closest('.sidebar-logo-text')) { + navigate('welcome'); + _closeSidebar(); + return; + } + // Sidebar-User (kein data-page, damit keine Aktiv-Markierung) if (e.target.closest('#sidebar-user')) { navigate('settings'); @@ -218,23 +334,31 @@ events: { title: 'Events', module: null }, // SCHNELL-HINZUFÜGEN (+ Button) // ---------------------------------------------------------- function _showQuickAdd() { + const loggedIn = !!state.user; + + const authBtn = (quick, cls, icon, label) => loggedIn + ? `` + : ``; + UI.modal.open({ title: 'Was möchtest du hinzufügen?', body: `
- - + ${authBtn('diary', 'btn-secondary', 'book-open', 'Tagebuch-Eintrag')} + ${authBtn('health', 'btn-secondary', 'syringe', 'Gesundheits-Eintrag')} - + ${authBtn('walk', 'btn-nature', 'paw-print', 'Gassi-Treffen erstellen')}
+ ${!loggedIn ? `

+ + Einige Funktionen erfordern einen Account. +

` : ''} `, }); @@ -248,10 +372,11 @@ events: { title: 'Events', module: null }, // ~300ms später ein synthetisches Click-Event an derselben Position. // Ohne Delay trifft es das neu geöffnete Modal und schließt es sofort. setTimeout(() => { + if (action.startsWith('auth-')) { navigate('settings'); return; } if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); } if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } -if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } + if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } }, 350); }, { once: true }); } @@ -271,6 +396,13 @@ if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.( async function _onLoggedIn() { document.getElementById('sidebar-username').textContent = state.user.name; + // Admin/Moderator-Item einblenden + const adminItem = document.getElementById('sidebar-admin'); + if (adminItem) { + const isMod = state.user.rolle === 'admin' || state.user.rolle === 'moderator' + || state.user.is_moderator; + adminItem.style.display = isMod ? '' : 'none'; + } await _loadDogs(); } @@ -278,8 +410,22 @@ if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.( state.user = null; state.dogs = []; state.activeDog = null; + + // Gecachte Module geschützter Seiten leeren, damit sie beim nächsten Login + // sauber neu initialisiert werden statt den alten Zustand zu refreshen. + Object.entries(pages).forEach(([, page]) => { + if (page.requiresAuth) page.module = null; + }); + _renderDogSwitcher(); - navigate('settings', false); + + // Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln + if (pages[state.page]?.requiresAuth) { + navigate('map', false); + } else { + // Bleib auf der Seite, zeige aber den Gate-Screen + _loadPage(state.page); + } } async function _loadDogs() { @@ -448,7 +594,8 @@ if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.( // ÖFFENTLICHE API // (andere Module können App.state, App.navigate etc. nutzen) // ---------------------------------------------------------- - return { init, navigate, state, setActiveDog, renderDogSwitcher: _renderDogSwitcher }; + return { init, navigate, state, setActiveDog, renderDogSwitcher: _renderDogSwitcher, + getInstallPrompt: () => _installPrompt }; })(); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js new file mode 100644 index 0000000..892132e --- /dev/null +++ b/backend/static/js/pages/admin.js @@ -0,0 +1,588 @@ +/* ============================================================ + BAN YARO — Admin-Bereich + Nur für Admins und Moderatoren. + ============================================================ */ + +window.Page_admin = (() => { + + let _container = null; + let _appState = null; + let _tab = 'uebersicht'; + + const TABS = [ + { id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' }, + { id: 'nutzer', label: 'Nutzer', icon: 'users' }, + { id: 'forum', label: 'Forum & Meldungen',icon: 'chat-circle-dots' }, + ]; + + // ------------------------------------------------------------------ + async function init(container, appState) { + _container = container; + _appState = appState; + + const u = appState.user; + const isMod = u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator; + if (!isMod) { + container.innerHTML = _emptyState('shield', 'Kein Zugriff', 'Dieser Bereich ist nur für Admins und Moderatoren.'); + return; + } + + _render(); + } + + function refresh() { _renderTab(); } + function onDogChange() {} + + // ------------------------------------------------------------------ + // SHELL + // ------------------------------------------------------------------ + function _render() { + _container.innerHTML = ` +
+ + +
+ ${TABS.map(t => ` + + `).join('')} +
+ + +
+
+ `; + + _container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => { + btn.addEventListener('click', () => { + _tab = btn.dataset.tab; + _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === _tab) + ); + _renderTab(); + }); + }); + + _renderTab(); + } + + async function _renderTab() { + const el = _container.querySelector('#adm-content'); + if (!el) return; + el.innerHTML = `
Lade…
`; + try { + switch (_tab) { + case 'uebersicht': await _renderStats(el); break; + case 'nutzer': await _renderUsers(el); break; + case 'forum': await _renderForum(el); break; + } + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); + } + } + + // ------------------------------------------------------------------ + // TAB: ÜBERSICHT + // ------------------------------------------------------------------ + async function _renderStats(el) { + const s = await API.get('/admin/stats'); + el.innerHTML = ` +
+ ${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')} + ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')} + ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')} + ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')} + ${_statCard('warning', 'Offene Meldungen',s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')} + ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')} + ${_statCard('warning-octagon','Giftk. aktiv', s.poison_active,'var(--c-danger)')} +
+ +
+

+ + Ersten Admin per SQL setzen: + + UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de'; + +

+
+ `; + } + + function _statCard(icon, label, value, color) { + return ` +
+ +
+ ${value ?? '—'} +
+
${label}
+
+ `; + } + + // ------------------------------------------------------------------ + // TAB: NUTZER + // ------------------------------------------------------------------ + async function _renderUsers(el) { + el.innerHTML = ` +
+ + +
+
Lade…
+ `; + + const load = async () => { + const q = el.querySelector('#adm-user-q').value; + const rolle = el.querySelector('#adm-user-rolle').value; + const data = await API.get(`/admin/users?q=${encodeURIComponent(q)}&rolle=${rolle}`); + _renderUserList(el.querySelector('#adm-user-list'), data.users, data.total); + }; + + let timer; + el.querySelector('#adm-user-q').addEventListener('input', () => { + clearTimeout(timer); + timer = setTimeout(load, 350); + }); + el.querySelector('#adm-user-rolle').addEventListener('change', load); + await load(); + } + + function _renderUserList(el, users, total) { + if (!users.length) { + el.innerHTML = _emptyState('users', 'Keine Nutzer gefunden', ''); + return; + } + + const isAdmin = _appState.user?.rolle === 'admin'; + + el.innerHTML = ` +
+ ${total} Nutzer gefunden +
+
+ ${users.map(u => ` +
+
+ + +
+ ${_esc(u.name[0].toUpperCase())} +
+ + +
+
+ ${_esc(u.name)} + ${u.is_banned ? ` + GESPERRT` : ''} +
+
+ ${_esc(u.email)} · + + ${_esc(u.rolle)} + + · ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''} + · ${u.thread_count} Threads +
+
+ + +
+ ${u.is_banned + ? `` + : `` + } + ${isAdmin ? ` + + + ` : ''} +
+ +
+
+ `).join('')} +
+ `; + + // Events + el.querySelectorAll('.adm-ban').forEach(btn => { + btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, true)); + }); + el.querySelectorAll('.adm-unban').forEach(btn => { + btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, false)); + }); + el.querySelectorAll('.adm-rolle').forEach(btn => { + btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle)); + }); + el.querySelectorAll('.adm-delete').forEach(btn => { + btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name)); + }); + } + + async function _banUser(uid, name, ban) { + if (ban) { + const reason = await _prompt(`${name} sperren — Grund (optional):`); + if (reason === null) return; // abgebrochen + try { + await API.patch(`/admin/users/${uid}`, { is_banned: 1, ban_reason: reason || 'Kein Grund angegeben.' }); + UI.toast.success(`${name} gesperrt.`); + _renderTab(); + } catch (e) { UI.toast.error(e.message); } + } else { + try { + await API.patch(`/admin/users/${uid}`, { is_banned: 0, ban_reason: null }); + UI.toast.success(`Sperre für ${name} aufgehoben.`); + _renderTab(); + } catch (e) { UI.toast.error(e.message); } + } + } + + async function _changeRolle(uid, name, currentRolle) { + const rollen = ['user', 'moderator', 'admin'].filter(r => r !== currentRolle); + UI.modal.open({ + title: `Rolle ändern: ${name}`, + body: ` +

+ Aktuelle Rolle: ${currentRolle} +

+
+ ${rollen.map(r => ` + + `).join('')} +
+ `, + }); + + document.querySelectorAll('.adm-rolle-choice').forEach(btn => { + btn.addEventListener('click', async () => { + UI.modal.close(); + try { + await API.patch(`/admin/users/${uid}`, { rolle: btn.dataset.rolle }); + UI.toast.success(`${name} ist jetzt ${btn.dataset.rolle}.`); + _renderTab(); + } catch (e) { UI.toast.error(e.message); } + }); + }); + } + + async function _deleteUser(uid, name) { + const ok = await UI.modal.confirm({ + title: `${name} löschen?`, + message: 'Alle Daten dieses Accounts werden unwiderruflich gelöscht — Hunde, Tagebuch, Beiträge.', + confirmText: 'Endgültig löschen', + }); + if (!ok) return; + try { + await API.del(`/admin/users/${uid}`); + UI.toast.success(`${name} gelöscht.`); + _renderTab(); + } catch (e) { UI.toast.error(e.message); } + } + + // ------------------------------------------------------------------ + // TAB: FORUM & MELDUNGEN + // ------------------------------------------------------------------ + async function _renderForum(el) { + el.innerHTML = ` + +
+ + +
+
Lade…
+ `; + + el.querySelectorAll('.adm-forum-nav').forEach(btn => { + btn.addEventListener('click', async () => { + el.querySelectorAll('.adm-forum-nav').forEach(b => { + b.className = b === btn ? 'btn btn-primary btn-sm adm-forum-nav' : 'btn btn-ghost btn-sm adm-forum-nav'; + }); + await _renderForumView(el.querySelector('#adm-forum-content'), btn.dataset.view); + }); + }); + + await _renderForumView(el.querySelector('#adm-forum-content'), 'reports'); + } + + async function _renderForumView(el, view) { + el.innerHTML = '
Lade…
'; + + if (view === 'reports') { + const reports = await API.get('/admin/reports'); + if (!reports.length) { + el.innerHTML = _emptyState('check', 'Keine offenen Meldungen', 'Alles sauber.'); + return; + } + el.innerHTML = ` +
+ ${reports.map(r => ` +
+
+
+
+ ${r.resolved ? '✓ Erledigt · ' : ''} + ${_esc(r.target_type)} #${r.target_id} · + Gemeldet von ${_esc(r.melder_name)} +
+
+ Grund: ${_esc(r.grund)} +
+ ${r.content_preview ? ` +
+ ${_esc(r.content_preview)} +
` : ''} +
+
+ + ${!r.resolved ? ` + ` : ''} +
+
+
+ `).join('')} +
+ `; + + el.querySelectorAll('.adm-resolve-btn').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await API.patch(`/admin/reports/${btn.dataset.rid}`, {}); + _renderForumView(el, 'reports'); + } catch (e) { UI.toast.error(e.message); } + }); + }); + + el.querySelectorAll('.adm-del-content').forEach(btn => { + btn.addEventListener('click', () => _deleteContent(btn.dataset.type, btn.dataset.id, el, 'reports')); + }); + + } else { + // Threads + el.innerHTML = ` +
+ + +
+
Lade…
+ `; + + const loadThreads = async () => { + const q = el.querySelector('#adm-thread-q').value; + const deleted = el.querySelector('#adm-show-deleted').checked ? 1 : 0; + const data = await API.get(`/admin/forum/threads?q=${encodeURIComponent(q)}&deleted=${deleted}`); + _renderThreadList(el.querySelector('#adm-thread-list'), data.threads, el); + }; + + let t2; + el.querySelector('#adm-thread-q').addEventListener('input', () => { clearTimeout(t2); t2 = setTimeout(loadThreads, 350); }); + el.querySelector('#adm-show-deleted').addEventListener('change', loadThreads); + await loadThreads(); + } + } + + function _renderThreadList(el, threads, parentEl) { + if (!threads.length) { + el.innerHTML = _emptyState('chat-circle-dots', 'Keine Threads', ''); + return; + } + el.innerHTML = ` +
+ ${threads.map(t => ` +
+
+
+
+ ${t.is_deleted ? '' : ''}${_esc(t.titel)}${t.is_deleted ? '' : ''} +
+
+ von ${_esc(t.autor_name)} · + ${t.antworten} Antworten · + ${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''} +
+
+
+ ${!t.is_deleted ? ` + + + + ` : ` + + `} +
+
+
+ `).join('')} +
+ `; + + el.querySelectorAll('.adm-pin').forEach(btn => { + btn.addEventListener('click', async () => { + await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_pinned: btn.dataset.pinned === '1' ? 0 : 1 }); + parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input')); + }); + }); + el.querySelectorAll('.adm-lock').forEach(btn => { + btn.addEventListener('click', async () => { + await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_locked: btn.dataset.locked === '1' ? 0 : 1 }); + parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input')); + }); + }); + el.querySelectorAll('.adm-del-thread').forEach(btn => { + btn.addEventListener('click', () => _deleteContent('thread', btn.dataset.tid, parentEl, 'threads')); + }); + el.querySelectorAll('.adm-restore-thread').forEach(btn => { + btn.addEventListener('click', async () => { + await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_deleted: 0 }); + parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input')); + }); + }); + } + + async function _deleteContent(type, id, parentEl, view) { + const ok = await UI.modal.confirm({ + title: `${type === 'thread' ? 'Thread' : 'Beitrag'} löschen?`, + message: 'Der Inhalt wird als gelöscht markiert.', + confirmText: 'Löschen', + }); + if (!ok) return; + try { + await API.del(`/admin/forum/${type === 'thread' ? 'threads' : 'posts'}/${id}`); + UI.toast.success('Gelöscht.'); + _renderForumView(parentEl, view); + } catch (e) { UI.toast.error(e.message); } + } + + // ------------------------------------------------------------------ + // HELPERS + // ------------------------------------------------------------------ + function _prompt(msg) { + return new Promise(resolve => { + UI.modal.open({ + title: 'Eingabe', + body: ` +

${msg}

+ + `, + footer: ` + + + `, + }); + document.getElementById('adm-prompt-ok')?.addEventListener('click', () => { + const val = document.getElementById('adm-prompt-input')?.value || ''; + UI.modal.close(); + resolve(val); + }); + document.getElementById('adm-prompt-cancel')?.addEventListener('click', () => { + UI.modal.close(); + resolve(null); + }); + }); + } + + function _emptyState(icon, title, text) { + return ` +
+ +

${title}

+ ${text ? `

${text}

` : ''} +
+ `; + } + + function _esc(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // ------------------------------------------------------------------ + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/erste-hilfe.js b/backend/static/js/pages/erste-hilfe.js new file mode 100644 index 0000000..5e35644 --- /dev/null +++ b/backend/static/js/pages/erste-hilfe.js @@ -0,0 +1,389 @@ +window.Page_erste_hilfe = (() => { + let _container = null; + let _appState = null; + + async function init(container, appState) { + _container = container; + _appState = appState; + _render(); + } + + function refresh() {} + function onDogChange() {} + + // ---------------------------------------------------------------- + // DATA + // ---------------------------------------------------------------- + const NOTFALLNUMMERN = [ + { label: 'Tiergiftzentrale München', tel: '+4989 19240', display: '+49 89 19240' }, + { label: 'Tiergiftzentrale Berlin', tel: '+4930 19240', display: '+49 30 19240' }, + { label: 'Tiergiftzentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' }, + ]; + + const SCHNELL = [ + { notfall: 'Vergiftung / Giftköder', massnahme: 'Ruhig halten, NICHT erbrechen lassen', tierarzt: 'Sofort' }, + { notfall: 'Hitzschlag', massnahme: 'Kühlen, Wasser anbieten', tierarzt: 'Sofort' }, + { notfall: 'Bewusstlosigkeit', massnahme: 'Atemwege frei, Stabile Seitenlage', tierarzt: 'Sofort' }, + { notfall: 'Starke Blutung', massnahme: 'Druckverband anlegen', tierarzt: 'Sofort' }, + { notfall: 'Knochenbruch', massnahme: 'Ruhigstellen, nicht bewegen', tierarzt: 'Sofort' }, + { notfall: 'Zeckenbiss', massnahme: 'Zecke entfernen, Stelle beobachten', tierarzt: 'Bei Entzündung' }, + { notfall: 'Pfotenverletzung', massnahme: 'Reinigen, Verband', tierarzt: 'Bei tiefer Wunde' }, + { notfall: 'Fremdkörper verschluckt', massnahme: 'Beobachten, nicht erbrechen lassen', tierarzt: 'Bei Symptomen' }, + { notfall: 'Bisswunde', massnahme: 'Reinigen, Wunde beurteilen', tierarzt: 'Bei tiefer Wunde' }, + { notfall: 'Epileptischer Anfall', massnahme: 'Nicht festhalten, sichern', tierarzt: 'Nach dem Anfall' }, + ]; + + const KATEGORIEN = [ + { + id: 'lebensgefahr', + label: 'Lebensbedrohliche Notfälle', + color: 'var(--c-danger, #ef4444)', + icon: 'warning', + eintraege: [ + { + titel: 'Vergiftung / Giftköder', + icon: 'skull', + symptome: ['Erbrechen, Durchfall, übermäßiges Speicheln','Zittern, Krämpfe, Muskelzucken','Taumeln, Orientierungslosigkeit','Blasse oder blaue Schleimhäute','Plötzliche Schwäche, Zusammenbruch'], + massnahmen: ['Hund ruhig halten und von der Giftquelle entfernen','NICHT selbst zum Erbrechen bringen — kann die Vergiftung verschlimmern','Giftköder oder Erbrochenes wenn möglich in einem Beutel sichern','Sofort Tierarzt oder Tiergiftzentrale anrufen','Auf dem Weg: Hund warm halten, ruhig sprechen'], + warn: [{ typ: 'danger', text: 'Nie: Erbrechen einleiten ohne Anweisung des Tierarztes' }], + extra: '

Häufige Giftquellen: Rattengift, Schneckenkorn (Metaldehyd), Ibuprofen, Paracetamol, Schokolade, Weintrauben/Rosinen, Zwiebeln, Xylit (Kaugummi, Erdnussbutter), präparierte Köder.

', + }, + { + titel: 'Hitzschlag', + icon: 'thermometer-hot', + symptome: ['Starkes, lautes Hecheln','Taumeln, Koordinationsprobleme','Erbrechen','Glasiger Blick, Apathie','Rote oder blasse Schleimhäute','Bewusstlosigkeit'], + massnahmen: ['Sofort in den Schatten / kühlen Raum bringen','Mit lauwarmem (nicht eiskaltem) Wasser kühlen — Pfoten, Leiste, Nacken','Frisches Wasser anbieten — nicht zwingen','Nassen Lappen auf Bauch und Pfoten legen','Sofort zum Tierarzt — auch wenn der Hund sich erholt'], + warn: [{ typ: 'danger', text: 'Nie: Eiswasser oder Eiswürfel — verursacht Schock durch zu schnelle Abkühlung' }], + }, + { + titel: 'Bewusstlosigkeit / Herzstillstand', + icon: 'heartbeat', + symptome: ['Hund reagiert nicht auf Ansprechen oder Berühren','Keine sichtbare Atembewegung','Schleimhäute blass oder blau'], + massnahmen: ['Atemwege freihalten: Maul öffnen, Zunge nach vorne, Fremdkörper entfernen','Atmet der Hund? → Stabile Seitenlage, sofort Tierarzt anrufen','Atmet er nicht? → Herz-Lungen-Wiederbelebung beginnen'], + extra: '
Herzdruckmassage: Hund auf die rechte Seite, Hände auf breiteste Stelle des Brustkorbs hinter dem Ellenbogen, 100–120 Kompressionen/min, ca. 1/3 eindrücken. Bei kleinen Hunden: eine Hand oder zwei Finger.

Beatmung: Nach je 30 Kompressionen 2 Atemzüge — Maul schließen, durch die Nase blasen bis der Brustkorb sich hebt.

Weiterführen bis: Hund selbst atmet, Tierarzt übernimmt oder nach 10 Min. ohne Reaktion.
', + }, + { + titel: 'Starke Blutung', + icon: 'drop', + symptome: [], + massnahmen: ['Sauberes Tuch fest auf die Wunde drücken','Druck min. 5 Minuten halten — nicht zwischendurch nachschauen','Druckverband anlegen: Watte auf Wunde, fest mit Binde umwickeln','Hund ruhig halten — Bewegung verstärkt die Blutung','Bei arterieller Blutung (spritzend, hellrot): sofort Tierarzt'], + warn: [{ typ: 'danger', text: 'Niemals ein Tourniquet anlegen — außer als letzter Ausweg bei abgetrennter Gliedmaße' }], + }, + { + titel: 'Knochenbruch', + icon: 'bone', + symptome: ['Hund belastet Gliedmaße nicht','Sichtbare Fehlstellung','Starke Schmerzen, Schreien bei Berührung','Schwellung, Blutung'], + massnahmen: ['Hund so wenig wie möglich bewegen','Gebrochene Stelle nicht einrenken oder massieren','Improvisierte Schiene nur wenn nötig: gerades Brett mit Tuch fixieren, nicht zu fest','Hund in Decke einwickeln, ruhig transportieren','Sofort Tierarzt'], + }, + ], + }, + { + id: 'haeufig', + label: 'Häufige Notfälle', + color: 'var(--c-warning, #f59e0b)', + icon: 'first-aid', + eintraege: [ + { + titel: 'Zeckenbiss', + icon: 'bug', + symptome: [], + massnahmen: ['Zeckenzange oder Zeckenkarte verwenden — kein Öl, kein Klebstoff, kein Feuer','Zecke so nah wie möglich an der Haut fassen','Gerade herausziehen — nicht drehen','Einstichstelle desinfizieren','Datum und Stelle notieren, 4 Wochen beobachten'], + warn: [{ typ: 'warning', text: 'Zum Tierarzt bei: Rötung/Schwellung, Fieber, Apathie, Lahmheit innerhalb von 4 Wochen oder abgebrochenem Zeckenkopf' }], + extra: '

Übertragende Krankheiten (DE): Borreliose (häufig), FSME (selten), Babesiose (Süddeutschland, zunehmend), Anaplasmose.

', + }, + { + titel: 'Pfotenverletzung', + icon: 'paw-print', + symptome: [], + massnahmen: ['Pfote vorsichtig mit lauwarmem Wasser reinigen','Sichtbaren Fremdkörper mit Pinzette entfernen','Leichte Verletzung: reinigen, Pfotenschutzspray, beobachten','Tiefer Schnitt: sauberen Verband anlegen, Tierarzt aufsuchen'], + warn: [{ typ: 'warning', text: 'Notverband: Watte auf Wunde, Mullbinde umwickeln (nicht zu fest), mit Kohäsivbinde sichern' }], + extra: '

Zum Tierarzt wenn: Wunde klafft, Blutung nicht stoppt, tiefer Einstich, oder Hund nach 24 h noch nicht belastet.

', + }, + { + titel: 'Fremdkörper verschluckt', + icon: 'circle-dashed', + symptome: ['Im Rachen: Würgen, Pfoten ans Maul, Speicheln','Im Magen: Erbrechen, Appetitlosigkeit','Im Darm: Erbrechen, Blähungen, kein Kot, Schmerzen'], + massnahmen: ['Hund beobachten — viele Gegenstände gehen von selbst durch','Nicht zum Erbrechen bringen (außer auf Anweisung des Tierarztes)','Kein Öl oder Futter geben um nachzuschieben','Bei Würgen: Maul öffnen, sichtbaren Gegenstand entfernen — nur wenn gut erreichbar','Bei Atemnot: Heimlich-Manöver anwenden'], + warn: [{ typ: 'warning', text: 'Sofort zum Tierarzt: anhaltend würgen, Atemnot, angespannter Bauch, kein Kot seit 24 h + Unwohlsein' }], + extra: '

Heimlich-Manöver: Kleiner Hund: auf den Rücken, sanft aber fest auf den Bauch unter dem Brustkorb drücken. Großer Hund: hinter dem Hund stehen, Arme um den Bauch, Hände unter dem Brustkorb zusammenführen, nach oben und innen drücken.

', + }, + { + titel: 'Bisswunde', + icon: 'dog', + symptome: [], + massnahmen: ['Hund beruhigen — Schmerz macht auch ruhige Hunde aggressiv','Mit lauwarmem Wasser spülen, kein Alkohol direkt in die Wunde','Oberfläche beurteilen — Bisswunden sehen oft klein aus, sind aber tief'], + warn: [{ typ: 'warning', text: 'Bisswunden sind immer tiefer als sie aussehen. Hunde- und Katzenzähne sind lang und dünn.' },{ typ: 'danger', text: 'Sofort zum Tierarzt: Wunde am Hals/Bauch/Brust, Atembeschwerden, starke Blutung, Apathie/Schock, Bisse von fremden Tieren (Tollwut-Risiko)' }], + }, + { + titel: 'Epileptischer Anfall', + icon: 'lightning', + symptome: ['Zuckungen, Krämpfe der Gliedmaßen','Bewusstseinsverlust, starrer Blick','Speicheln, Urin- oder Kotabgang','Desorientierung vor und nach dem Anfall'], + massnahmen: ['Ruhe bewahren — Anfälle enden meist von selbst','Hund NICHT festhalten — Verletzungsgefahr','Gefährliche Gegenstände aus dem Weg räumen','Raum abdunkeln, Geräusche minimieren','Zeit messen — dauert länger als 5 Min: Notfalltierarzt'], + warn: [{ typ: 'warning', text: 'Nach dem Anfall: Hund ist oft desorientiert, kann blind wirken — das ist normal (postiktale Phase). Ruhig sprechen, nicht bedrängen.' }], + extra: '

Sofort zum Tierarzt: erster Anfall überhaupt, Dauer > 5 Min, mehrere Anfälle in 24 h, Hund kommt nach 30 Min nicht zu sich.

', + }, + { + titel: 'Verbrennung / Verbrühung', + icon: 'fire', + symptome: [], + massnahmen: ['Betroffene Stelle 10–15 Min mit kühlem (nicht eiskaltem) Wasser kühlen','Kein Öl, keine Butter, keine Zahncreme — verstärken den Schaden','Leichte Rötung: kühlen, beobachten','Blasenbildung oder offene Wunden: sofort Tierarzt'], + warn: [{ typ: 'warning', text: 'Heißer Asphalt: Handfläche 5 Sek. auf Boden — zu heiß für dich = zu heiß für Pfoten' }], + }, + ], + }, + { + id: 'wissen', + label: 'Nützliches Wissen', + color: '#ca8a04', + icon: 'book-open', + eintraege: [ + { + titel: 'Verbotene Medikamente für Hunde', + icon: 'pill', + symptome: [], + massnahmen: [], + extra: `
+ + + + + + + + + + + + +
MedikamentWirkung beim Hund
IbuprofenMagenblutungen, Nierenversagen — schon 1 Tablette gefährlich
ParacetamolLeberschäden, tödlich
AspirinMagenblutungen
DiclofenacNieren- und Magenprobleme
AntidepressivaKrämpfe, Herzprobleme
`, + }, + { + titel: 'Giftige Pflanzen (Auswahl)', + icon: 'plant', + symptome: [], + massnahmen: [], + extra: `
+ + + + + + + ${[['Herbstzeitlose','Sehr giftig, alle Teile'],['Goldregen','Sehr giftig, besonders Samen'],['Eibe','Sehr giftig, alle Teile außer rotem Fruchtfleisch'],['Maiglöckchen','Giftig, Herzrhythmusstörungen'],['Stechapfel','Sehr giftig'],['Oleander','Sehr giftig'],['Kirschlorbeer','Giftig, besonders Samen'],['Buchsbaum','Giftig'],['Narzisse / Tulpe','Giftig, besonders Zwiebel'],['Wisteria (Blauregen)','Giftig']].map((r, i) => ``).join('')} + +
PflanzeGiftigkeit
${r[0]}${r[1]}
`, + }, + { + titel: 'Schleimhäute prüfen', + icon: 'stethoscope', + symptome: [], + massnahmen: [], + extra: `

Zahnfleisch anheben, Finger andrücken, loslassen — Farbe muss binnen 2 Sek. zurückkehren (kapilläre Füllungszeit).

+
+ + + + + + + + + + + + + +
FarbeBedeutung
Rosa, feuchtNormal
Blass / weißSchock, Blutverlust, Vergiftung
Blau / grauSauerstoffmangel — NOTFALL
GelbLeberprobleme
ZiegelrotHitzschlag, Vergiftung
TrockenAustrocknung
`, + }, + { + titel: 'Erste-Hilfe-Ausrüstung', + icon: 'backpack', + symptome: [], + massnahmen: ['Mullbinden und Verbandsmull','Kohäsivbinde (haftet selbst, kein Kleber)','Zeckenzange oder Zeckenkarte','Pinzette','Desinfektionsspray (Chlorhexidin)','Pfotenschutzspray','Einmalhandschuhe','Notfalldecke (Rettungsfolie)','Taschenlampe','Tierarzt-Notfallnummer gespeichert'], + }, + ], + }, + ]; + + // ---------------------------------------------------------------- + // RENDER + // ---------------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ + ${_renderNotfallbanner()} + ${_renderSchnell()} + +
+ ${KATEGORIEN.map(k => ` + + `).join('')} +
+ + ${KATEGORIEN.map(k => ` + + `).join('')} + +
+ + Diese Inhalte ersetzen keinen Tierarztbesuch. Im Zweifel immer sofort zum Tierarzt oder den tierärztlichen Notdienst anrufen. +
+
+ `; + _bindTabs(); + _bindAccordions(); + _activateTab('lebensgefahr'); + } + + function _renderNotfallbanner() { + const nums = NOTFALLNUMMERN.map(n => ` + + + ${n.label}
${n.display}
+
+ `).join(''); + + return ` +
+
+ + Tiergiftzentralen — jetzt anrufen +
+
+ ${nums} +
+

+ Tierärztlicher Notdienst: Über die Tierarztsuche in der Banyaro-Karte +

+
+ `; + } + + function _renderSchnell() { + const rows = SCHNELL.map((s, i) => ` + + ${s.notfall} + ${s.massnahme} + ${s.tierarzt} + + `).join(''); + + return ` +
+
+ + Schnellübersicht: Was tun bei … +
+
+ + + + + + + ${rows} +
NotfallSofortmaßnahmeTierarzt
+
+
+ `; + } + + function _renderEintrag(e, katId, idx, katColor) { + const accId = `eh-acc-${katId}-${idx}`; + const bodyId = `eh-body-${katId}-${idx}`; + + const symptomeHtml = e.symptome.length + ? `

Symptome

+
    + ${e.symptome.map(s => `
  • ${s}
  • `).join('')} +
` + : ''; + + const massnahmenHtml = e.massnahmen.length + ? `

Sofortmaßnahmen

+
    + ${e.massnahmen.map(m => `
  1. ${m}
  2. `).join('')} +
` + : ''; + + const warnHtml = (e.warn || []).map(w => ` +
+ + ${w.text} +
+ `).join(''); + + return ` +
+ + +
+ `; + } + + // ---------------------------------------------------------------- + // EVENTS + // ---------------------------------------------------------------- + function _bindTabs() { + _container.querySelectorAll('.eh-tab-btn').forEach(btn => { + btn.addEventListener('click', () => _activateTab(btn.dataset.tab)); + }); + } + + function _activateTab(id) { + _container.querySelectorAll('.eh-tab-btn').forEach(btn => { + const kat = KATEGORIEN.find(k => k.id === btn.dataset.tab); + const active = btn.dataset.tab === id; + btn.style.background = active ? kat.color : 'transparent'; + btn.style.color = active ? '#fff' : kat.color; + }); + _container.querySelectorAll('.eh-tab-panel').forEach(panel => { + panel.style.display = panel.id === `eh-panel-${id}` ? 'block' : 'none'; + }); + } + + function _bindAccordions() { + _container.querySelectorAll('[data-acc-id]').forEach(btn => { + btn.addEventListener('click', () => { + const bodyId = btn.dataset.accId; + const arrowId = btn.dataset.accArrow; + const body = document.getElementById(bodyId); + const arrow = document.getElementById(arrowId); + if (!body) return; + const open = !body.hidden; + body.hidden = open; + btn.setAttribute('aria-expanded', String(!open)); + if (arrow) arrow.style.transform = open ? '' : 'rotate(180deg)'; + }); + }); + } + + // ---------------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------------- + return { init, refresh, onDogChange }; +})(); diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js index 10edb4f..8a911f9 100644 --- a/backend/static/js/pages/events.js +++ b/backend/static/js/pages/events.js @@ -65,7 +65,7 @@ window.Page_events = (() => { // ---------------------------------------------------------- function _render() { _container.innerHTML = ` -
+
@@ -74,20 +74,20 @@ window.Page_events = (() => { ${_state.user ? `` : ''}
-
+
${TYPEN.map(t => ` - `).join('')}
-
- - + - +
@@ -352,7 +352,7 @@ window.Page_events = (() => {
- +
diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 720d9e0..5ec8616 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -83,17 +83,18 @@ window.Page_forum = (() => {

Forum

${isMod ? `` : ''} +
-
+
${KATEGORIEN.map(k => ` - `).join('')} -
@@ -119,7 +120,7 @@ window.Page_forum = (() => { if (btn.dataset.section === 'map') { _aktivKat = 'alle'; _activeSection = 'map'; - document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active')); + document.querySelectorAll('#forum-tabs .by-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _renderMembersMap(); return; @@ -127,7 +128,7 @@ window.Page_forum = (() => { _aktivKat = btn.dataset.kat; _activeSection = 'list'; - document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active')); + document.querySelectorAll('#forum-tabs .by-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _offset = 0; _threads = []; @@ -154,6 +155,9 @@ window.Page_forum = (() => { // Moderations-Panel document.getElementById('forum-mod-btn')?.addEventListener('click', _showModPanel); + + // Regeln & Netiquette + document.getElementById('forum-rules-btn').addEventListener('click', _showRules); } // ---------------------------------------------------------- @@ -657,6 +661,78 @@ window.Page_forum = (() => { // ---------------------------------------------------------- // Neues Thema + // ---------------------------------------------------------- + // Regeln & Netiquette + // ---------------------------------------------------------- + function _showRules() { + UI.modal.open({ + title: `${UI.icon('info')} Regeln & Netiquette`, + body: ` +
+ +
+

+ Das Ban-Yaro-Forum ist ein Ort für Hundehalter — freundlich, hilfsbereit und respektvoll. + Bitte halte diese Grundregeln ein, damit es für alle schön bleibt. +

+
+ +
+
+ ${UI.icon('chat-circle-dots')} Ton & Umgang +
+
    +
  • ${UI.icon('check')} Freundlich und respektvoll bleiben — auch bei verschiedenen Meinungen
  • +
  • ${UI.icon('check')} Konstruktive Kritik statt persönliche Angriffe
  • +
  • ${UI.icon('check')} Andere Haltungsmethoden tolerieren — solange der Hund nicht leidet
  • +
  • ${UI.icon('prohibit')} Keine Beleidigungen, Drohungen oder Diskriminierung
  • +
+
+ +
+
+ ${UI.icon('files')} Inhalte +
+
    +
  • ${UI.icon('check')} Thema passend zur gewählten Kategorie
  • +
  • ${UI.icon('check')} Aussagekräftiger Titel — kein "Hilfe!!!" ohne Kontext
  • +
  • ${UI.icon('prohibit')} Keine Werbung, Spam oder Affiliate-Links
  • +
  • ${UI.icon('prohibit')} Keine Doppelposts — bestehenden Thread suchen bevor du neu erstellst
  • +
+
+ +
+
+ ${UI.icon('syringe')} Gesundheit & Notfälle +
+
    +
  • ${UI.icon('check')} Erfahrungen teilen ist wertvoll — bitte immer den Tierarzt empfehlen
  • +
  • ${UI.icon('prohibit')} Keine Diagnosen oder Medikamentendosierungen für fremde Hunde
  • +
  • ${UI.icon('warning-circle')} Bei Notfällen: direkt zum Tierarzt — nicht erst im Forum fragen
  • +
+
+ +
+
+ ${UI.icon('flag')} Moderation +
+
    +
  • ${UI.icon('check')} Regelverstoß? Melde-Funktion nutzen statt selbst zu reagieren
  • +
  • ${UI.icon('check')} Moderatoren können Beiträge bearbeiten, verstecken oder löschen
  • +
  • ${UI.icon('check')} Bei Unklarheiten freundlich nachfragen
  • +
+
+ +

+ Wer wiederholt gegen die Regeln verstößt, kann vorübergehend gesperrt werden. + Das Ziel ist ein freundliches Forum — nicht Kontrolle um der Kontrolle willen. 🐾 +

+ +
`, + footer: ``, + }); + } + // ---------------------------------------------------------- function _showCreateForm() { const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k => @@ -687,7 +763,12 @@ window.Page_forum = (() => {
- `; + +

+ ${UI.icon('info')} Bitte die + + beachten. +

`; const footer = ` @@ -696,6 +777,7 @@ window.Page_forum = (() => { UI.modal.open({ title: '+ Neues Thema', body, footer }); document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close); + document.getElementById('ff-rules-link')?.addEventListener('click', _showRules); // Foto-Vorschau document.getElementById('forum-thread-files')?.addEventListener('change', e => { diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index 97cf25a..7e56b46 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -1,69 +1,161 @@ /* ============================================================ - BAN YARO — Freunde-Seite + BAN YARO — Freunde ============================================================ */ window.Page_friends = (() => { - let _container = null; + let _container = null; + let _appState = null; let _searchTimer = null; // ---------------------------------------------------------- - function init(container) { + async function init(container, appState, params = {}) { _container = container; - render(); + _appState = appState; + _render(params.suche || null); } + function refresh() { _loadFriends(); } + function onDogChange() {} + // ---------------------------------------------------------- - async function render() { + // HAUPT-RENDER + // ---------------------------------------------------------- + function _render(prefill = null) { + const myName = _appState?.user?.name || ''; + const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`; + _container.innerHTML = ` -
-

- Freunde -

+
+ + +
+
+
+ +
+
+
Dein Freundes-Link
+
+ Teile ihn — der andere tippt drauf und findet dich sofort. +
+
+
+
+
+ banyaro.app/#friends?suche=${_esc(encodeURIComponent(myName))} +
+ + +
+
-
- +
+ +
+

+ Tipp: Lass dir den Freundes-Link einer anderen Person schicken — dann klappt die Suche automatisch. +

- +
- +
- +
+
`; - document.getElementById('fr-search').addEventListener('input', e => { - clearTimeout(_searchTimer); - _searchTimer = setTimeout(() => _doSearch(e.target.value.trim()), 400); + // Copy-Button + _container.querySelector('#fr-copy-btn')?.addEventListener('click', () => { + navigator.clipboard.writeText(myLink).then(() => { + UI.toast.success('Link kopiert!'); + }).catch(() => { + UI.toast.info('Link: ' + myLink); + }); }); - await _loadFriends(); + // Share-Button (Web Share API, Fallback: Copy) + _container.querySelector('#fr-share-btn')?.addEventListener('click', async () => { + if (navigator.share) { + try { + await navigator.share({ + title: `${myName} auf Ban Yaro`, + text: `Füge mich auf Ban Yaro als Freund hinzu!`, + url: myLink, + }); + } catch { /* abgebrochen */ } + } else { + navigator.clipboard.writeText(myLink).then(() => { + UI.toast.success('Link kopiert!'); + }); + } + }); + + // Suche + const searchInput = _container.querySelector('#fr-search'); + searchInput.addEventListener('input', e => { + clearTimeout(_searchTimer); + const q = e.target.value.trim(); + if (q.length < 2) { + _container.querySelector('#fr-search-results').innerHTML = ''; + return; + } + _searchTimer = setTimeout(() => _doSearch(q), 380); + }); + + // Prefill aus URL-Parameter → sofort suchen + if (prefill && prefill.length >= 2) { + _doSearch(prefill); + } + + _loadFriends(); } + // ---------------------------------------------------------- + // DATEN LADEN // ---------------------------------------------------------- async function _loadFriends() { try { const data = await API.friends.list(); - _renderIncoming(data.incoming); - _renderOutgoing(data.outgoing); - _renderFriends(data.friends); - _updateBadge(data.incoming.length); - } catch (e) { - if (e.status === 401) { - document.getElementById('fr-list').innerHTML = - `

Bitte melde dich an, um Freunde zu verwalten.

`; - } - } + _renderIncoming(data.incoming || []); + _renderOutgoing(data.outgoing || []); + _renderFriends(data.friends || []); + _updateBadge((data.incoming || []).length); + } catch { /* 401 wird vom Auth-Guard abgefangen */ } } - // ---------------------------------------------------------- function _updateBadge(count) { const el = document.getElementById('friends-badge'); if (!el) return; @@ -71,123 +163,286 @@ window.Page_friends = (() => { el.style.display = count > 0 ? '' : 'none'; } + // ---------------------------------------------------------- + // EINGEHENDE ANFRAGEN // ---------------------------------------------------------- function _renderIncoming(list) { - const el = document.getElementById('fr-incoming'); + const el = _container.querySelector('#fr-incoming'); if (!list.length) { el.innerHTML = ''; return; } el.innerHTML = ` -

- Anfragen (${list.length}) -

- ${list.map(r => ` -
-
${_initial(r.requester_name)}
-
-
${_esc(r.requester_name)}
-
möchte mit dir befreundet sein
+
+ + ${list.map(r => ` +
+
+ ${_userAvatar(r.requester_name, r.dogs?.[0])} +
+
+ ${_esc(r.requester_name)} +
+ ${_dogPills(r.dogs, 2)} +
+
+ + +
+
-
- - -
-
- `).join('')} + `).join('')} +
`; } + // ---------------------------------------------------------- + // GESENDETE ANFRAGEN // ---------------------------------------------------------- function _renderOutgoing(list) { - const el = document.getElementById('fr-outgoing'); + const el = _container.querySelector('#fr-outgoing'); if (!list.length) { el.innerHTML = ''; return; } el.innerHTML = ` -

- Gesendete Anfragen -

- ${list.map(r => ` -
-
${_initial(r.addressee_name)}
-
${_esc(r.addressee_name)}
- ausstehend - -
- `).join('')} +
+ + ${list.map(r => ` +
+
+
+ ${_esc((r.addressee_name || '?')[0].toUpperCase())} +
+
+
${_esc(r.addressee_name)}
+
Anfrage ausstehend
+
+ +
+
+ `).join('')} +
`; } + // ---------------------------------------------------------- + // FREUNDESLISTE // ---------------------------------------------------------- function _renderFriends(list) { - const el = document.getElementById('fr-list'); + const el = _container.querySelector('#fr-list'); + if (!list.length) { el.innerHTML = ` -
- -

- Noch keine Freunde. Suche oben nach Nutzern! +

+ +

Noch keine Hundefreunde

+

+ Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.

`; return; } + el.innerHTML = ` -

- Freunde (${list.length}) -

- ${list.map(f => ` -
-
${_initial(f.friend_name)}
-
${_esc(f.friend_name)}
-
+
+ + ${list.map(f => _friendCard(f)).join('')} +
+ `; + + // Klick auf Karte → Mini-Profil + el.querySelectorAll('.fr-card').forEach(card => { + card.addEventListener('click', e => { + if (e.target.closest('button')) return; // Buttons nicht überschreiben + const fid = parseInt(card.dataset.friendId); + const fname = card.dataset.friendName; + const fdogs = JSON.parse(card.dataset.dogs || '[]'); + _showProfile(fid, fname, fdogs); + }); + }); + } + + function _friendCard(f) { + const dogs = f.dogs || []; + return ` +
+
+ + + ${_userAvatar(f.friend_name, dogs[0])} + + +
+
+ ${_esc(f.friend_name)} +
+ ${dogs.length + ? `
+ ${_dogPills(dogs, 3)} +
` + : `
Noch kein Hund eingetragen
` + } +
+ + +
- +
+
- `).join('')} + + + ${_dogPhotoRow(dogs)} + +
+ `; + } + + function _dogPhotoRow(dogs) { + const withPhotos = dogs.filter(d => d.foto_url); + if (withPhotos.length < 2) return ''; // 1 Foto schon im Avatar, < 2 lohnt sich nicht + return ` +
+ ${withPhotos.slice(0, 4).map(d => ` +
+ ${_esc(d.name)} +
+ ${_esc(d.name)} +
+
+ `).join('')} +
`; } + // ---------------------------------------------------------- + // MINI-PROFIL MODAL + // ---------------------------------------------------------- + function _showProfile(friendId, friendName, dogs) { + const dogsHTML = dogs.length + ? `
+ ${dogs.map(d => ` +
+ ${d.foto_url + ? `${_esc(d.name)}` + : `
🐕
` + } +
${_esc(d.name)}
+ ${d.rasse + ? `
${_esc(d.rasse)}
` + : ''} +
+ `).join('')} +
` + : `

+ Noch kein Hund eingetragen. +

`; + + UI.modal.open({ + title: _esc(friendName), + body: ` +
+ + ${dogsHTML} +
+ `, + footer: ` + + + `, + }); + + document.getElementById('modal-chat-btn')?.addEventListener('click', () => { + UI.modal.close(); + _openChat(friendId); + }); + document.getElementById('modal-remove-btn')?.addEventListener('click', async () => { + UI.modal.close(); + await _removeFriend(friendId, friendName); + }); + } + + // ---------------------------------------------------------- + // SUCHE // ---------------------------------------------------------- async function _doSearch(q) { - const el = document.getElementById('fr-search-results'); - if (q.length < 2) { el.innerHTML = ''; return; } + const el = _container.querySelector('#fr-search-results'); try { const results = await API.friends.search(q); if (!results.length) { - el.innerHTML = `
-
- Keine Nutzer gefunden. -
-
`; + el.innerHTML = ` +
+ Kein Nutzer gefunden. +
`; return; } el.innerHTML = ` -
- ${results.map(u => ` -
-
${_initial(u.name)}
-
${_esc(u.name)}
- @@ -195,77 +450,110 @@ window.Page_friends = (() => { `).join('')}
`; - } catch (e) { - el.innerHTML = ''; - } + + el.querySelectorAll('.fr-add-btn').forEach(btn => { + btn.addEventListener('click', () => _sendRequest(parseInt(btn.dataset.userId), btn)); + }); + + } catch { el.innerHTML = ''; } } + // ---------------------------------------------------------- + // AKTIONEN // ---------------------------------------------------------- async function _sendRequest(userId, btn) { btn.disabled = true; + btn.innerHTML = ``; try { await API.friends.sendRequest(userId); - UI.toast('Freundschaftsanfrage gesendet!', 'success'); - document.getElementById('fr-search').value = ''; - document.getElementById('fr-search-results').innerHTML = ''; + UI.toast.success('Freundschaftsanfrage gesendet!'); + _container.querySelector('#fr-search').value = ''; + _container.querySelector('#fr-search-results').innerHTML = ''; await _loadFriends(); } catch (e) { - UI.toast(e.message, 'danger'); + UI.toast.error(e.message || 'Fehler beim Senden.'); btn.disabled = false; + btn.innerHTML = ` Anfrage`; } } async function _accept(id) { try { await API.friends.accept(id); - UI.toast('Freundschaft angenommen!', 'success'); + UI.toast.success('Freundschaft angenommen!'); await _loadFriends(); - } catch (e) { - UI.toast(e.message, 'danger'); - } + } catch (e) { UI.toast.error(e.message); } } async function _decline(id) { try { await API.friends.decline(id); await _loadFriends(); - } catch (e) { - UI.toast(e.message, 'danger'); - } + } catch (e) { UI.toast.error(e.message); } } async function _cancel(id) { try { await API.friends.decline(id); await _loadFriends(); - } catch (e) { - UI.toast(e.message, 'danger'); - } + } catch (e) { UI.toast.error(e.message); } } async function _removeFriend(userId, name) { - if (!confirm(`${name} als Freund entfernen?`)) return; + const ok = await UI.modal.confirm({ + title: 'Freund entfernen?', + message: `${name} wird aus deiner Freundesliste entfernt.`, + confirmText: 'Entfernen', + }); + if (!ok) return; try { await API.friends.remove(userId); - UI.toast('Freund entfernt.', 'info'); + UI.toast.info('Freund entfernt.'); await _loadFriends(); - } catch (e) { - UI.toast(e.message, 'danger'); - } + } catch (e) { UI.toast.error(e.message); } } async function _openChat(userId) { try { const { conversation_id } = await API.chat.start(userId); App.navigate('chat', true, { conversation_id }); - } catch (e) { - UI.toast(e.message, 'danger'); - } + } catch (e) { UI.toast.error(e.message); } } // ---------------------------------------------------------- - function _initial(name) { - return (name || '?')[0].toUpperCase(); + // RENDER-HELPERS + // ---------------------------------------------------------- + function _userAvatar(name, firstDog) { + if (firstDog?.foto_url) { + return `${_esc(firstDog.name)}`; + } + return ` +
+ ${_esc((name || '?')[0].toUpperCase())} +
`; + } + + function _dogPills(dogs, max) { + if (!dogs?.length) return ''; + const visible = dogs.slice(0, max); + const rest = dogs.length - max; + return ` +
+ ${visible.map(d => ` + + 🐕 ${_esc(d.name)}${d.rasse ? ` · ${_esc(d.rasse)}` : ''} + + `).join('')} + ${rest > 0 ? `+${rest}` : ''} +
+ `; } function _esc(s) { @@ -274,9 +562,6 @@ window.Page_friends = (() => { } // ---------------------------------------------------------- - return { - init, - _sendRequest, _accept, _decline, _cancel, _removeFriend, _openChat, - }; + return { init, refresh, onDogChange, _accept, _decline, _cancel, _removeFriend, _openChat }; })(); diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index ab26762..05d345d 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -8,11 +8,11 @@ window.Page_health = (() => { let _container = null; let _appState = null; - let _data = {}; // { impfung:[], tierarzt:[], gewicht:[], medikament:[], allergie:[], dokument:[] } + let _data = {}; let _praxen = []; let _activeTab = 'impfung'; - const TABS = [ + const BASE_TABS = [ { key: 'impfung', label: 'Impfpass', icon: '' }, { key: 'tierarzt', label: 'Besuche', icon: '' }, { key: 'gewicht', label: 'Gewicht', icon: '' }, @@ -22,6 +22,16 @@ window.Page_health = (() => { { key: 'praxen', label: 'Praxen', icon: '' }, { key: 'symptomcheck', label: 'Symptom-Check', icon: '' }, ]; + const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '' }; + + function _getTabs() { + const tabs = [...BASE_TABS]; + if (_appState?.activeDog?.geschlecht === 'w') tabs.splice(2, 0, LAEUFIGKEIT_TAB); + return tabs; + } + + // Backwards-compat alias + const TABS = BASE_TABS; // ---------------------------------------------------------- // LIFECYCLE @@ -118,14 +128,14 @@ window.Page_health = (() => { // ---------------------------------------------------------- async function _renderHealth() { _container.innerHTML = ` -
+
-
-
+
+
`; _renderTabBar(); @@ -141,7 +151,7 @@ window.Page_health = (() => { // ERINNERUNGEN — Banner über den Tabs // ---------------------------------------------------------- function _getErinnerungen() { - const REMINDER_TABS = ['impfung', 'entwurmung', 'medikament']; + const REMINDER_TABS = ['impfung', 'entwurmung', 'medikament', 'laeufigkeit']; const now = Date.now(); const items = []; REMINDER_TABS.forEach(typ => { @@ -167,9 +177,10 @@ window.Page_health = (() => { if (!items.length) { el.innerHTML = ''; return; } const ICONS = { - impfung: '', - entwurmung: '', - medikament: '', + impfung: '', + entwurmung: '', + medikament: '', + laeufigkeit: '', }; el.innerHTML = ` @@ -258,17 +269,17 @@ window.Page_health = (() => { } function _renderTabBar() { - const tabsEl = _container.querySelector('#health-tabs'); - tabsEl.innerHTML = TABS.map(t => ` - `).join(''); - tabsEl.querySelectorAll('.health-tab').forEach(btn => { + tabsEl.querySelectorAll('.by-tab').forEach(btn => { btn.addEventListener('click', () => { _activeTab = btn.dataset.tab; - tabsEl.querySelectorAll('.health-tab').forEach(b => b.classList.remove('active')); + tabsEl.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _renderTab(); }); @@ -283,9 +294,10 @@ window.Page_health = (() => { try { const all = await API.health.list(dogId); _data = {}; - TABS.forEach(t => { _data[t.key] = []; }); + _getTabs().forEach(t => { _data[t.key] = []; }); + _data['laeufigkeit'] = _data['laeufigkeit'] || []; all.forEach(e => { - if (_data[e.typ]) _data[e.typ].push(e); + if (_data[e.typ] !== undefined) _data[e.typ].push(e); }); } catch (err) { UI.toast.error('Gesundheitsdaten konnten nicht geladen werden.'); @@ -301,7 +313,7 @@ window.Page_health = (() => { // TAB-INHALT RENDERN // ---------------------------------------------------------- function _renderTab() { - const content = _container.querySelector('#health-tab-content'); + const content = _container.querySelector('#by-tab-content'); if (!content) return; const entries = _data[_activeTab] || []; @@ -310,6 +322,7 @@ window.Page_health = (() => { case 'impfung': content.innerHTML = _renderImpfungen(entries); break; case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break; case 'gewicht': content.innerHTML = _renderGewicht(entries); break; + case 'laeufigkeit': content.innerHTML = _renderLaeufigkeit(entries); break; case 'medikament': content.innerHTML = _renderMedikamente(entries); break; case 'allergie': content.innerHTML = _renderAllergien(entries); break; case 'dokument': content.innerHTML = _renderDokumente(entries); break; @@ -529,6 +542,98 @@ window.Page_health = (() => { `; } + // ---------------------------------------------------------- + // LÄUFIGKEIT — Timeline + Vorhersage + // ---------------------------------------------------------- + function _renderLaeufigkeit(entries) { + const addBtn = ``; + + const sorted = [...entries].sort((a, b) => b.datum.localeCompare(a.datum)); // neueste zuerst + + // Durchschnittlicher Abstand berechnen + let avgInterval = null; + if (sorted.length >= 2) { + const asc = [...sorted].reverse(); + const diffs = []; + for (let i = 1; i < asc.length; i++) { + diffs.push(Math.round((new Date(asc[i].datum) - new Date(asc[i-1].datum)) / 86400000)); + } + avgInterval = Math.round(diffs.reduce((a, b) => a + b, 0) / diffs.length); + } + + // Nächste vorhergesagte Läufigkeit + const last = sorted[0]; + let nextPrediction = null; + if (last?.naechstes) { + nextPrediction = last.naechstes; + } else if (last?.datum && (last?.intervall_tage || avgInterval)) { + const iv = last.intervall_tage || avgInterval; + const d = new Date(last.datum); + d.setDate(d.getDate() + iv); + nextPrediction = d.toISOString().slice(0, 10); + } + + // Banner für nächste Läufigkeit + let banner = ''; + if (nextPrediction) { + const ampel = _impfAmpel(nextPrediction); + const tage = Math.ceil((new Date(nextPrediction) - Date.now()) / 86400000); + const label = tage < 0 ? `Überfällig seit ${Math.abs(tage)} Tagen` + : tage === 0 ? 'Könnte heute beginnen' + : tage <= 14 ? `In ${tage} Tagen` + : UI.time.format(nextPrediction + 'T00:00:00'); + banner = ` +
+ ${UI.icon('gender-female')} +
+
Nächste Läufigkeit erwartet
+
${label} + ${avgInterval ? ` · Ø ${avgInterval} Tage Abstand` : ''} +
+
+
`; + } + + if (!sorted.length) return ` + ${UI.emptyState({ + icon: UI.icon('gender-female'), + title: 'Noch keine Läufigkeit eingetragen', + text: 'Trage Läufigkeiten ein, um den Zyklus zu verfolgen.', + action: addBtn, + })}`; + + const items = sorted.map((e, i) => { + const prev = sorted[i + 1]; + const interval = prev + ? Math.round((new Date(e.datum) - new Date(prev.datum)) / 86400000) + : null; + return ` +
+
+ ${UI.icon('gender-female')} +
+
+
Läufigkeit · ${UI.time.format(e.datum + 'T00:00:00')}
+
+ ${e.wert ? `Dauer: ${e.wert} Tage` : 'Dauer nicht angegeben'} + ${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''} +
+ ${e.notiz ? `
${_esc(e.notiz)}
` : ''} +
+
`; + }).join(''); + + return ` + ${banner} +
${items}
+
${addBtn}
`; + } + // ---------------------------------------------------------- // MEDIKAMENTE // ---------------------------------------------------------- @@ -542,7 +647,7 @@ window.Page_health = (() => { const inaktive = entries.filter(e => !e.aktiv); const renderGroup = (items, label) => items.length ? ` -
${label}
+ ${items.map(e => `
@@ -686,7 +791,9 @@ window.Page_health = (() => { const modalTitle = entry.typ === 'gewicht' ? `${tabInfo.icon} ${entry.wert} ${entry.einheit || 'kg'}` - : `${tabInfo.icon} ${_esc(entry.bezeichnung)}`; + : entry.typ === 'laeufigkeit' + ? `${tabInfo.icon} Läufigkeit ${UI.time.format(entry.datum + 'T00:00:00')}` + : `${tabInfo.icon} ${_esc(entry.bezeichnung)}`; UI.modal.open({ title: modalTitle, body }); document.getElementById('health-detail-edit')?.addEventListener('click', () => { @@ -717,7 +824,8 @@ window.Page_health = (() => { function _detailFields(e) { const rows = []; if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]); - if (e.naechstes) rows.push(['Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]); + if (e.typ === 'laeufigkeit' && e.wert) rows.push(['Dauer', `${e.wert} Tage`]); + if (e.naechstes) rows.push([e.typ === 'laeufigkeit' ? 'Nächste erwartet' : 'Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]); if (e.tierarzt_id) { const praxis = _praxen.find(p => p.id === e.tierarzt_id); if (praxis) { @@ -753,7 +861,7 @@ window.Page_health = (() => { const t = typ || _activeTab; const commonFields = ` - ${t !== 'gewicht' ? ` + ${t !== 'gewicht' && t !== 'laeufigkeit' ? `
{ placeholder="${_formPlaceholder(t)}">
` : ''}
- +
@@ -854,12 +962,13 @@ window.Page_health = (() => { function _formPlaceholder(typ) { const ph = { - impfung: 'z.B. Tollwut, DHPP, Leptospirose', - tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung', - gewicht: '', - medikament: 'z.B. Frontline, Milbemax', - allergie: 'z.B. Hühnchen, Gras, Hausstaub', - dokument: 'z.B. Impfpass, Blutbild', + impfung: 'z.B. Tollwut, DHPP, Leptospirose', + tierarzt: 'z.B. Vorsorgeuntersuchung, Impfung', + gewicht: '', + medikament: 'z.B. Frontline, Milbemax', + allergie: 'z.B. Hühnchen, Gras, Hausstaub', + dokument: 'z.B. Impfpass, Blutbild', + laeufigkeit: 'Läufigkeit', }; return ph[typ] || ''; } @@ -1019,6 +1128,60 @@ window.Page_health = (() => {
`; + case 'laeufigkeit': { + const prevCycles = (_data['laeufigkeit'] || []).filter(e => e !== entry && e?.datum); + let avgInterval = 0; + if (prevCycles.length >= 2) { + const sorted = [...prevCycles].sort((a, b) => a.datum.localeCompare(b.datum)); + const intervals = []; + for (let i = 1; i < sorted.length; i++) { + intervals.push(Math.round((new Date(sorted[i].datum) - new Date(sorted[i-1].datum)) / 86400000)); + } + avgInterval = Math.round(intervals.reduce((a, b) => a + b, 0) / intervals.length); + } + const defaultInterval = avgInterval || (entry?.intervall_tage) || 180; + // Auto-berechne nächstes Datum aus Startdatum + Interval + return ` +
+
+ + +
+
+ + +
+
+
+ + +
+ + `; + } default: return ''; } } @@ -1039,9 +1202,12 @@ window.Page_health = (() => { reaktion: fd.reaktion || null, }; if (fd.wert) { - p.wert = parseFloat(fd.wert.replace(',', '.')); + p.wert = parseFloat(fd.wert.toString().replace(',', '.')); if (typ === 'gewicht') p.bezeichnung = `${p.wert} kg`; } + if (typ === 'laeufigkeit') { + p.bezeichnung = p.bezeichnung || 'Läufigkeit'; + } if (fd.kosten) p.kosten = parseFloat(fd.kosten.toString().replace(',', '.')); if (fd.tierarzt_id) { p.tierarzt_id = parseInt(fd.tierarzt_id); @@ -1278,14 +1444,14 @@ window.Page_health = (() => { } catch (err) { if (err.status === 402) { resultEl.innerHTML = ` -
+

Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.

`; } else if (err.status === 503) { resultEl.innerHTML = ` -
+

KI-Server nicht erreichbar. Bitte später versuchen.

diff --git a/backend/static/js/pages/knigge.js b/backend/static/js/pages/knigge.js index de7378e..f518189 100644 --- a/backend/static/js/pages/knigge.js +++ b/backend/static/js/pages/knigge.js @@ -275,8 +275,8 @@ window.Page_knigge = (() => { }).join(''); const badge = isCorrect - ? `${UI.icon('check')} Richtig!` - : `${UI.icon('x')} Nicht ganz — `; + ? `${UI.icon('check')} Richtig!` + : `${UI.icon('x')} Nicht ganz — `; resEl.innerHTML = `
${bars}
diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index d41ff31..af0b351 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -68,6 +68,21 @@ window.Page_poison = (() => { © OpenStreetMap-Mitwirkende
+ +

diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 4d4835d..4216836 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -174,14 +174,16 @@ window.Page_routes = (() => { sec.className = 'rk-map-section'; sec.innerHTML = `

+ - +
-
+
Route antippen um Details zu sehen
`; document.body.appendChild(sec); + document.getElementById('rk-map-back')?.addEventListener('click', () => _switchView('list')); // Wie _initMiniMaps: pollen bis window.L bereit ist _pollAndInitSearchMap(); @@ -392,14 +394,14 @@ window.Page_routes = (() => {

Deine erste Gassi-Route

Zeichne deine Lieblingsstrecken auf — mit Streckendaten, Fotos und Hundetauglichkeit.

-
🗺️GPS-Aufzeichnung
-
📷Fotos entlang der Strecke
+
${UI.icon('map-trifold')}GPS-Aufzeichnung
+
${UI.icon('camera')}Fotos entlang der Strecke
🐾Hundetauglichkeit bewerten
-
⬇️GPX-Download für Navi
-
📍Restaurants & Parkplätze
-
🔒Privat oder öffentlich
+
${UI.icon('download-simple')}GPX-Download für Navi
+
${UI.icon('map-pin')}Restaurants & Parkplätze
+
${UI.icon('lock')}Privat oder öffentlich
- +
`; document.getElementById('rk-empty-rec')?.addEventListener('click', () => { App.navigate('map'); @@ -438,7 +440,7 @@ window.Page_routes = (() => { // Karte HTML // ---------------------------------------------------------- function _cardHTML(r) { - const privBadge = !r.is_public ? '🔒 Privat' : ''; + const privBadge = !r.is_public ? `${UI.icon('lock')} Privat` : ''; const diffLabel = DIFFICULTY_LABEL[r.schwierigkeit] || ''; const terrain = TERRAIN_LABEL[r.untergrund] || ''; const paws = HUNDE_LABEL[r.hunde_tauglichkeit] || ''; @@ -457,22 +459,22 @@ window.Page_routes = (() => {
${_esc(r.name)}
- ${dist ? `🗺️ ${dist}` : ''} - ${dur ? `⏱ ${dur}` : ''} + ${dist ? `${UI.icon('map-trifold')} ${dist}` : ''} + ${dur ? `${UI.icon('timer')} ${dur}` : ''} ${terrain ? `${terrain}` : ''} ${paws ? `${paws}` : ''}
${privBadge} ${diffLabel ? `${diffLabel}` : ''} - ${r.schatten ? '🌳 Schatten' : ''} - ${r.leine_empfohlen ? '🔗 Leine' : ''} + ${r.schatten ? `${UI.icon('tree')} Schatten` : ''} + ${r.leine_empfohlen ? `${UI.icon('link')} Leine` : ''}
@@ -579,7 +581,7 @@ window.Page_routes = (() => { ` : ''}
` : isOwn ? `` : ''; @@ -588,14 +590,14 @@ window.Page_routes = (() => { margin-bottom:var(--space-3);background:var(--c-surface-2)">
${photoGallery}
- ${route.distanz_km ? `🗺️ ${route.distanz_km.toFixed(2)} km` : ''} - ${route.dauer_min ? `⏱ ${_fmtDur(route.dauer_min)}` : ''} + ${route.distanz_km ? `${UI.icon('map-trifold')} ${route.distanz_km.toFixed(2)} km` : ''} + ${route.dauer_min ? `${UI.icon('timer')} ${_fmtDur(route.dauer_min)}` : ''} ${route.schwierigkeit ? `${DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit}` : ''} ${route.untergrund ? `${TERRAIN_LABEL[route.untergrund]||route.untergrund}` : ''} ${paws ? `${paws}` : ''} - ${route.schatten ? '🌳 Schatten' : ''} - ${route.leine_empfohlen ? '🔗 Leine empfohlen' : ''} - ${!route.is_public ? '🔒 Privat' : ''} + ${route.schatten ? `${UI.icon('tree')} Schatten` : ''} + ${route.leine_empfohlen ? `${UI.icon('link')} Leine empfohlen` : ''} + ${!route.is_public ? `${UI.icon('lock')} Privat` : ''}
${route.beschreibung ? `

${_esc(route.beschreibung)}

` : ''}
@@ -603,16 +605,16 @@ window.Page_routes = (() => {

${track.length} GPS-Punkte · von ${_esc(route.user_name||'Anonym')} - ${route.bewertung ? ` · ⭐ ${route.bewertung.toFixed(1)} (${route.anz_bewertungen})` : ''} + ${route.bewertung ? ` · ${UI.icon('star')} ${route.bewertung.toFixed(1)} (${route.anz_bewertungen})` : ''}

`; const footer = ` - + ${isOwn ? ` - ` : ''} + ` : ''} `; @@ -627,7 +629,7 @@ window.Page_routes = (() => { await API.routes.update(route.id, { is_public: !route.is_public }); route.is_public = !route.is_public; const btn = document.getElementById('rd-vis'); - if (btn) btn.textContent = route.is_public ? '🔒 Privat' : '🌍 Öffentlich'; + if (btn) btn.innerHTML = route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'; const r = _data.find(x => x.id === route.id); if (r) r.is_public = route.is_public; _applyFilter(); @@ -744,15 +746,15 @@ window.Page_routes = (() => { }); el.innerHTML = ` -
📍 Entlang der Route
+
${UI.icon('map-pin')} Entlang der Route
${Object.values(byType).map(group => `
${group.icon} ${_esc(group.label)} (${group.items.length})
${group.items.slice(0, 5).map(p => `
${_esc(p.name || group.label)} - ${p.opening_hours ? `🕐 ${_esc(p.opening_hours)}` : ''} - ${p.phone ? `📞 ${_esc(p.phone)}` : ''} + ${p.opening_hours ? `${UI.icon('clock')} ${_esc(p.opening_hours)}` : ''} + ${p.phone ? `${UI.icon('phone')} ${_esc(p.phone)}` : ''}
`).join('')} ${group.items.length > 5 ? `
+${group.items.length-5} weitere
` : ''} @@ -977,9 +979,9 @@ window.Page_routes = (() => { const body = `
${preview}
- 📍 ${track.length} Punkte - 🗺️ ${distanz_km.toFixed(2)} km - ${dauer_min ? `⏱ ${_fmtDur(dauer_min)}` : ''} + ${UI.icon('map-pin')} ${track.length} Punkte + ${UI.icon('map-trifold')} ${distanz_km.toFixed(2)} km + ${dauer_min ? `${UI.icon('timer')} ${_fmtDur(dauer_min)}` : ''} ${source}
@@ -1036,7 +1038,7 @@ window.Page_routes = (() => { const footer = ` - + `; UI.modal.open({ title: '📥 Route importieren', body, footer }); @@ -1083,7 +1085,7 @@ window.Page_routes = (() => { } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); saveBtn.disabled = false; - saveBtn.textContent = '💾 Route speichern'; + saveBtn.innerHTML = UI.icon('floppy-disk') + ' Route speichern'; } }); } diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index d9b3aab..ad90261 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -114,6 +114,24 @@ window.Page_settings = (() => {
+ +
+
+ App installieren +
+
+ +
+
+
Ban Yaro · banyaro.app
@@ -140,6 +158,10 @@ window.Page_settings = (() => { _render(); }); + document.getElementById('settings-install-btn')?.addEventListener('click', () => { + App.navigate('welcome'); + }); + document.getElementById('settings-push-btn')?.addEventListener('click', async () => { try { await API.subscribeToPush(); diff --git a/backend/static/js/pages/sitting.js b/backend/static/js/pages/sitting.js index e54f422..4573799 100644 --- a/backend/static/js/pages/sitting.js +++ b/backend/static/js/pages/sitting.js @@ -43,14 +43,16 @@ window.Page_sitting = (() => { // ---------------------------------------------------------- function _render() { _container.innerHTML = ` -
- - ${_state.user ? ` - - - ` : ''} +
+
+ + ${_state.user ? ` + + + ` : ''} +
+
-
`; _container.addEventListener('click', _onClick); } @@ -95,7 +97,7 @@ window.Page_sitting = (() => { // ---- Tab: Sitter suchen ---- function _renderSuchen(el) { if (!_sitters.length) { - el.innerHTML = UI.emptyState({ icon: 'dog', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' }); + el.innerHTML = UI.emptyState({ icon: UI.icon('dog'), title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' }); return; } el.innerHTML = ` @@ -138,7 +140,7 @@ window.Page_sitting = (() => {
${UI.icon('paw-print')}

Werde Hundesitter

Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.

- +
`; return; @@ -149,7 +151,7 @@ window.Page_sitting = (() => {
- ${s.aktiv ? `${UI.icon('check')} Aktiv` : 'Pausiert'} + ${s.aktiv ? `${UI.icon('check')} Aktiv` : `${UI.icon('pause')} Pausiert`}
@@ -172,31 +174,36 @@ window.Page_sitting = (() => { let html = ''; if (inbox.length) { - html += ``; + html += ``; html += inbox.map(r => _requestCardHTML(r, 'inbox')).join(''); } if (myReqs.length) { - html += ``; + html += ``; html += myReqs.map(r => _requestCardHTML(r, 'sent')).join(''); } if (!inbox.length && !myReqs.length) { - html = UI.emptyState({ icon: 'bell', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' }); + html = UI.emptyState({ icon: UI.icon('bell'), title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' }); } el.innerHTML = html; } + const STATUS_ICON = { offen: 'clock', angenommen: 'check-circle', abgelehnt: 'x-circle', abgebrochen: 'minus-circle' }; + const STATUS_LABEL = { offen: 'Offen', angenommen: 'Angenommen', abgelehnt: 'Abgelehnt', abgebrochen: 'Abgebrochen' }; + const STATUS_COLOR = { offen: '#f59e0b', angenommen: '#10b981', abgelehnt: '#ef4444', abgebrochen: '#6b7280' }; + function _requestCardHTML(r, mode) { - const STATUS_COLOR = { offen: '#f59e0b', angenommen: '#10b981', abgelehnt: '#ef4444', abgebrochen: '#6b7280' }; const color = STATUS_COLOR[r.status] || '#6b7280'; + const icon = STATUS_ICON[r.status] || 'question'; + const label = STATUS_LABEL[r.status] || r.status; const name = mode === 'inbox' ? r.anfragender_name : r.sitter_name; return `
${UI.escHtml(name || '?')} - ${r.status} + ${UI.icon(icon)} ${label}
${UI.icon('calendar-dots')} ${r.von} – ${r.bis}
${r.nachricht ? `
${UI.escHtml(r.nachricht)}
` : ''} @@ -295,7 +302,7 @@ window.Page_sitting = (() => { `; const footer = ` - + `; UI.modal.open({ title: 'Anfrage senden', body, footer }); @@ -313,7 +320,7 @@ window.Page_sitting = (() => { dog_ids: dogIds, nachricht: fd.get('nachricht') || null, }; - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '…'; } try { await API.sitting.sendRequest(data); UI.modal.close(); @@ -322,7 +329,7 @@ window.Page_sitting = (() => { } catch (err) { UI.toast(err.message, 'error'); } finally { - if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Anfrage senden'; } + if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('paper-plane-tilt')} Anfrage senden`; } } }); } @@ -385,7 +392,7 @@ window.Page_sitting = (() => { const footer = ` `; UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer }); @@ -416,7 +423,7 @@ window.Page_sitting = (() => { }; if (s) data.aktiv = form.querySelector('[name="aktiv"]')?.checked ? 1 : 0; - if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '…'; } try { if (s) { await API.sitting.updateMe(data); @@ -429,7 +436,7 @@ window.Page_sitting = (() => { } catch (err) { UI.toast(err.message, 'error'); } finally { - if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = s ? 'Speichern' : 'Profil erstellen'; } + if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = s ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Profil erstellen`; } } }); } diff --git a/backend/static/js/pages/trainingsplaene.js b/backend/static/js/pages/trainingsplaene.js new file mode 100644 index 0000000..b074ad4 --- /dev/null +++ b/backend/static/js/pages/trainingsplaene.js @@ -0,0 +1,611 @@ +/* ============================================================ + BAN YARO — Trainingspläne + Seiten-Modul: Welpe, Junior, Erwachsener Hund + Alle Inhalte hardcoded, Checkboxen via localStorage + ============================================================ */ + +window.Page_trainingsplaene = (() => { + + let _container = null; + let _appState = null; + let _activePlan = 'welpe'; // welpe | junior | erwachsen + let _activeAdultTab = 'grundkurs'; // grundkurs | aufbaukurs + + // ---------------------------------------------------------- + // HELPER + // ---------------------------------------------------------- + function _esc(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function _icon(name) { + return ``; + } + + function _lsKey(planId, goalIdx) { + return `tp_${planId}_${goalIdx}`; + } + + function _saveGoal(key, checked) { + localStorage.setItem(key, checked ? 'true' : 'false'); + } + + function _loadGoal(key) { + return localStorage.getItem(key) === 'true'; + } + + // ---------------------------------------------------------- + // RENDER HELPERS + // ---------------------------------------------------------- + + function _renderTable(headers, rows) { + const ths = headers.map(h => `${_esc(h)}`).join(''); + const trs = rows.map(row => { + const tds = row.map((cell, i) => `${_esc(cell)}`).join(''); + return `${tds}`; + }).join(''); + return ` +
+ + ${ths} + ${trs} +
+
`; + } + + function _renderGoals(planId, goals) { + const total = goals.length; + let doneCount = 0; + const items = goals.map((goal, idx) => { + const key = _lsKey(planId, idx); + const checked = _loadGoal(key); + if (checked) doneCount++; + return ` + `; + }).join(''); + + const progress = total > 0 ? Math.round((doneCount / total) * 100) : 0; + return ` +
+
+ + ${_icon('check-circle')} Lernziele + + + ${doneCount} von ${total} erreicht + +
+
+
+
+ ${items} +
`; + } + + function _renderAccordionPhase(id, title, content) { + return ` +
+ + +
`; + } + + function _renderHintBox(text) { + return ` +
+ ${_icon('info')} ${_esc(text)} +
`; + } + + // ---------------------------------------------------------- + // PLAN SELECTOR + // ---------------------------------------------------------- + function _renderPlanSelector() { + const plans = [ + { id: 'welpe', label: '🐶 Welpe', sub: '0–6 Monate' }, + { id: 'junior', label: '🐕 Junior', sub: '6–18 Monate' }, + { id: 'erwachsen',label: '🦮 Erwachsener Hund', sub: 'Grund- & Aufbaukurs' }, + ]; + const btns = plans.map(p => ` + `).join(''); + return `
${btns}
`; + } + + // ---------------------------------------------------------- + // WELPENPLAN + // ---------------------------------------------------------- + function _renderWelpe() { + const intro = ` +

+ Voraussetzungen: Welpe ist eingezogen, Grundvertrauen wird aufgebaut.
+ Ziel am Ende: Sitz, Platz, Hier, Warte, Leine ohne Ziehen, Alleine bleiben bis 1 Stunde, keine Begrüßungssprünge. +

+ ${_renderHintBox('Welpen sind schnell überfordert. Lieber 3×5 Minuten täglich als einmal 20 Minuten. Sozialisation (Menschen, Geräusche, Orte) hat in dieser Phase genauso Priorität wie Training.')}`; + + const w12 = ` +

Fokus: Name, erste Bindung, Stubenreinheit, keine Übungen erzwingen.

+ ${_renderTable( + ['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'], + [['Mo–So', 'Name lernen', 'Markerwort einführen', 'Freies Erkunden + Beobachten']] + )} +
    +
  • Name lernen: Namen sagen → Hund schaut → sofort Markerwort + Leckerli. 10–15× täglich.
  • +
  • Markerwort einführen: Markerwort sagen → sofort Leckerli (10× wiederholen). Ab jetzt: Markerwort immer vor dem Leckerli.
  • +
  • Stubenreinheit: Alle 1–2 Stunden raus, nach Schlafen/Fressen/Spielen. Kein Schimpfen bei Unfällen.
  • +
`; + + const w34 = ` +

Fokus: Sitz, Warte (Futter), Nicht springen.

+ ${_renderTable( + ['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'], + [ + ['Mo', 'Sitz einführen', 'Sitz wiederholen', 'Warte vor Futter'], + ['Di', 'Sitz mit Handsignal', 'Nicht springen üben', 'Sitz aus verschiedenen Positionen'], + ['Mi', 'Warte vor Futter', 'Sitz 5×', 'Freispiel + Name'], + ['Do', 'Sitz festigen', 'Warte 5×', 'Nicht springen'], + ['Fr', 'Sitz + Warte kombiniert', 'Freispiel', 'Leckerli-Suche im Gras'], + ['Sa', 'Wiederholung Woche', 'Spaziergang mit Leine (ohne Ziel)', 'Entspannen auf Decke'], + ['So', 'Ruhiger Tag', 'Kurze Sitz-Einheit', 'Sozialisation (Geräusche, Menschen)'], + ] + )} + ${_renderGoals('welpe_w34', [ + 'Sitz auf Handsignal (ohne Wort)', + 'Sitz auf Wort "Sitz"', + 'Wartet vor der Futterschüssel bis "Okay"', + 'Springt nicht mehr bei Begrüßung (in Übung)', + ])}`; + + const w56 = ` +

Fokus: Platz, Leinengewöhnung, Hier auf kurze Distanz.

+ ${_renderTable( + ['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'], + [ + ['Mo', 'Platz einführen', 'Sitz wiederholen', 'Leine anlegen + Leckerli'], + ['Di', 'Platz üben', 'Hier (2 Meter, innen)', 'Leine: erste Schritte'], + ['Mi', 'Platz festigen', 'Sitz + Platz abwechselnd', 'Hier aus anderem Zimmer'], + ['Do', 'Leine im Garten', 'Hier mit Freude', 'Platz 5×'], + ['Fr', 'Sitz + Platz + Hier kombiniert', 'Spaziergang kurz', 'Nasenarbeit (Leckerli unter Becher)'], + ['Sa', 'Wiederholungstag', 'Sozialisation (Markt, Park)', 'Freispiel'], + ['So', 'Ruhiger Tag', 'Kurze Einheit nach Wahl', 'Entspannen'], + ] + )} + ${_renderGoals('welpe_w56', [ + 'Platz auf Handsignal', + 'Platz auf Wort "Platz"', + 'Kommt auf "Hier" aus 3 Metern', + 'Läuft 5 Schritte an lockerer Leine', + ])}`; + + const w78 = ` +

Fokus: Bleib (Dauer), Alleine bleiben aufbauen, Fuß vorbereiten.

+ ${_renderTable( + ['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'], + [ + ['Mo', 'Bleib einführen (5 Sek)', 'Alleine bleiben (30 Sek)', 'Sitz + Platz Wiederholung'], + ['Di', 'Bleib (10 Sek)', 'Alleine bleiben (1 Min)', 'Fuß: erste Schritte'], + ['Mi', 'Bleib mit 1 Schritt Distanz', 'Hier aus Garten', 'Nasenarbeit'], + ['Do', 'Bleib (20 Sek)', 'Alleine bleiben (2 Min)', 'Leine üben'], + ['Fr', 'Sitz + Bleib + Hier kombiniert', 'Fuß 10 Schritte', 'Freispiel'], + ['Sa', 'Ausflug (neue Umgebung)', 'Kurze Übungen unterwegs', 'Sozialisation'], + ['So', 'Ruhiger Tag', 'Wiederholung nach Wahl', 'Entspannen'], + ] + )} + ${_renderGoals('welpe_w78', [ + 'Bleibt 30 Sekunden im Sitz', + 'Bleibt alleine bis 5 Minuten ohne Stress', + 'Kommt zuverlässig auf "Hier" (innen + Garten)', + 'Fuß: 10 Schritte an lockerer Leine', + ])}`; + + const w912 = ` +

Fokus: Alle Kommandos in Alltagssituationen, erste leichte Ablenkungen.

+ ${_renderTable( + ['Woche', 'Schwerpunkt'], + [ + ['9', 'Alle Kommandos im Garten mit leichter Ablenkung'], + ['10', 'Sitz + Bleib auf der Straße, Fuß auf kurzen Spaziergängen'], + ['11', 'Hier mit Schleppleine im Park, Alleine bleiben bis 30 Min'], + ['12', 'Wiederholung + erste Tricks (Pfote, Dreh)'], + ] + )} + ${_renderGoals('welpe_w912', [ + 'Alle Grundkommandos auf Wort und Handsignal', + 'Bleibt alleine bis 1 Stunde ohne Stress', + 'Kommt zuverlässig auf "Hier" im Garten', + 'Läuft entspannt an der Leine in ruhiger Umgebung', + 'Erster Trick gelernt (Pfote oder Dreh)', + ])}`; + + return ` + ${intro} +
+ ${_renderAccordionPhase('welpe-w12', 'Woche 1–2: Ankommen & Vertrauen', w12)} + ${_renderAccordionPhase('welpe-w34', 'Woche 3–4: Erste Kommandos', w34)} + ${_renderAccordionPhase('welpe-w56', 'Woche 5–6: Platz & erste Leine', w56)} + ${_renderAccordionPhase('welpe-w78', 'Woche 7–8: Bleib & Alleine bleiben', w78)} + ${_renderAccordionPhase('welpe-w912', 'Woche 9–12: Festigung & Alltagsintegration', w912)} +
`; + } + + // ---------------------------------------------------------- + // JUNIORPLAN + // ---------------------------------------------------------- + function _renderJunior() { + const intro = ` +

+ Voraussetzungen: Grundkommandos bekannt, aber pubertätsbedingt unzuverlässig.
+ Ziel am Ende: Alle Grundkommandos auch bei Ablenkung, zuverlässiger Rückruf, Leinenführigkeit in der Stadt. +

+ ${_renderHintBox('Die Pubertät (ca. 6–12 Monate) ist normal. Der Hund "vergisst" scheinbar alles — er testet Grenzen und Reize sind überwältigend. Konsequenz und Geduld, kein Rückschritt im Denken.')}`; + + const m1 = ` +

Fokus: Alle bekannten Kommandos mit höherer Ablenkung neu festigen.

+ ${_renderTable( + ['Tag', 'Einheit 1 (5 Min)', 'Einheit 2 (5–10 Min)'], + [ + ['Mo', 'Sitz + Platz mit Ablenkung (Ball auf dem Boden)', 'Leine üben in neuer Umgebung'], + ['Di', 'Hier mit Schleppleine im Park', 'Bleib mit Distanz (3–5 Meter)'], + ['Mi', 'Fuß auf belebtem Gehweg', 'Nasenarbeit / Trick'], + ['Do', 'Aus + Warte kombiniert', 'Alleine bleiben (bis 2 Stunden)'], + ['Fr', 'Alle Kommandos — kurze Runde', 'Freispiel'], + ['Sa', 'Ausflug + Training in neuer Umgebung', 'Sozialisation'], + ['So', 'Ruhiger Tag — leichte Einheit', 'Entspannen'], + ] + )} + ${_renderGoals('junior_m1', [ + 'Sitz + Platz trotz Ball/anderen Hunden in der Nähe', + 'Bleibt 1 Minute mit 5 Metern Distanz', + 'Kommt auf "Hier" im Park (mit Schleppleine)', + 'Fuß auf 50 Meter in ruhiger Straße', + ])}`; + + const m2 = ` +

Fokus: Zuverlässiger Rückruf — das wichtigste Kommando in dieser Phase.

+
    +
  1. Rückruf mit Schleppleine (10 Meter): Hund beschäftigt sich → "Hier!" → leichtes Anziehen wenn nötig → große Belohnung beim Ankommen
  2. +
  3. Versteckspiel: Im Wald/Park verstecken → "Hier!" rufen → Hund sucht und findet → größte Belohnung
  4. +
  5. Rückruf aus der Gruppe: Hund spielt mit anderen Hunden → "Hier!" → kommt er: Jackpot (5 Leckerlis + Streicheln)
  6. +
  7. Notfallrückruf einführen: Anderes Wort (z.B. "Rapid!") nur für echte Notfälle — immer mit höchster Belohnung
  8. +
+ ${_renderGoals('junior_m2', [ + 'Kommt auf "Hier" aus 20 Metern mit Schleppleine', + 'Kommt aus Spielsituation mit anderen Hunden zurück', + 'Notfallrückruf eingeführt', + ])}`; + + const m3 = ` +

Fokus: Ruhiges Gehen auch bei Fahrrädern, anderen Hunden, Kinderwagen.

+
    +
  1. Ruhige Straße → belohnen alle 10 Schritte bei locker hängender Leine
  2. +
  3. Fahrrad kommt entgegen → Sitz → warten → Fahrrad vorbei → weitergehen + Leckerli
  4. +
  5. Anderer Hund in Sichtweite → Fokus auf dich (Augenkontakt trainieren) → Leckerli
  6. +
  7. Belebte Fußgängerzone: erst beobachten lassen, dann durch
  8. +
+

Augenkontakt: "Schau" sagen → Hund schaut in deine Augen → sofort Markerwort + Leckerli. In Ablenkungssituationen: "Schau" → fokussiert → dann erst weitergehen.

+ ${_renderGoals('junior_m3', [ + 'Läuft 500 Meter an lockerer Leine in der Stadt', + 'Bleibt bei Ablenkung (Fahrrad, Jogger) fokussiert', + 'Augenkontakt auf Kommando "Schau"', + ])}`; + + const m46 = ` +

Fokus: Festigung aller Kommandos, erste Aufbauübungen, Tricks für mentale Auslastung.

+ ${_renderTable( + ['Monat', 'Schwerpunkt'], + [ + ['4', 'Bleib auf Distanz (10 Meter), Freifolge (ohne Leine in sicherer Umgebung)'], + ['5', 'Platz auf Distanz, Hier vom Freilauf, Trick-Erweiterung (Decke, Suchspiel)'], + ['6', 'Alle Kommandos zuverlässig, erstes Hundesport-Schnuppern optional'], + ] + )} + ${_renderGoals('junior_m46', [ + 'Alle Grundkommandos bei mittlerer Ablenkung', + 'Freifolge auf kurze Distanz (eingezäuntes Gelände)', + '2–3 Tricks beherrscht', + 'Rückruf vom Freilauf (Schleppleine)', + ])}`; + + return ` + ${intro} +
+ ${_renderAccordionPhase('junior-m1', 'Monat 1: Grundkommandos neu aufbauen', m1)} + ${_renderAccordionPhase('junior-m2', 'Monat 2: Rückruf vertiefen', m2)} + ${_renderAccordionPhase('junior-m3', 'Monat 3: Leinenführigkeit in der Stadt', m3)} + ${_renderAccordionPhase('junior-m46', 'Monat 4–6: Aufbau & Tricks', m46)} +
`; + } + + // ---------------------------------------------------------- + // ERWACHSENER HUND + // ---------------------------------------------------------- + function _renderErwachsenTabs() { + return ` +
+ + + +
`; + } + + function _renderGrundkurs() { + const intro = ` +

+ Voraussetzungen: Keine oder wenige Vorkenntnisse.
+ Ziel: Alle Grundkommandos in 8 Wochen. +

+ ${_renderHintBox('Erwachsene Hunde lernen genauso gut wie Welpen — oft sogar konzentrierter. Alte Gewohnheiten brauchen länger zum Überschreiben, aber mit konsequentem Training klappt es.')}`; + + const gw12 = ` + ${_renderTable( + ['Tag', 'Einheit 1', 'Einheit 2'], + [ + ['Mo', 'Markerwort einführen', 'Sitz einführen'], + ['Di', 'Sitz festigen', 'Warte vor Futter'], + ['Mi', 'Sitz aus Bewegung', 'Markerwort in Alltagssituationen'], + ['Do', 'Sitz + Warte kombiniert', 'Leine: erste Einschätzung'], + ['Fr', 'Wiederholung', 'Freispiel'], + ['Sa/So', 'Alltagsintegration', 'Kurze Einheit'], + ] + )}`; + + const gw34 = ` + ${_renderTable( + ['Tag', 'Einheit 1', 'Einheit 2'], + [ + ['Mo', 'Platz einführen', 'Hier (Wohnung, 3 Meter)'], + ['Di', 'Platz festigen', 'Leinenführigkeit einschätzen + üben'], + ['Mi', 'Sitz + Platz abwechselnd', 'Hier aus anderem Zimmer'], + ['Do', 'Hier im Garten', 'Fuß vorbereiten'], + ['Fr', 'Leine 10 Minuten üben', 'Platz + Hier kombiniert'], + ['Sa/So', 'Spaziergang mit Training', 'Nasenarbeit'], + ] + )}`; + + const gw56 = ` + ${_renderTable( + ['Tag', 'Einheit 1', 'Einheit 2'], + [ + ['Mo', 'Bleib einführen (10 Sek)', 'Fuß einführen'], + ['Di', 'Bleib (30 Sek, 1 Schritt)', 'Aus einführen'], + ['Mi', 'Fuß 20 Schritte', 'Bleib mit Distanz'], + ['Do', 'Aus mit Spielzeug', 'Fuß in der Straße'], + ['Fr', 'Alle Kommandos kombiniert', 'Trick nach Wahl'], + ['Sa/So', 'Ausflug mit Training', 'Sozialisation'], + ] + )}`; + + const gw78 = ` +

Alle Kommandos in Alltagssituationen:

+
    +
  • Sitz vor dem Überqueren der Straße
  • +
  • Platz wenn Besuch kommt
  • +
  • Warte vor der Haustür
  • +
  • Hier beim Freilauf im Garten
  • +
  • Fuß auf dem Bürgersteig
  • +
  • Aus bei gefundenem Gegenstand
  • +
+ ${_renderGoals('erwachsen_gk', [ + 'Sitz, Platz, Bleib (30 Sek), Hier, Fuß, Aus, Warte', + 'Alle Kommandos im Alltag einsetzbar', + 'Leine ohne starkes Ziehen', + ])}`; + + return ` + ${intro} +
+ ${_renderAccordionPhase('gk-w12', 'Woche 1–2: Markerwort + Sitz + Warte', gw12)} + ${_renderAccordionPhase('gk-w34', 'Woche 3–4: Platz + Hier + Leine', gw34)} + ${_renderAccordionPhase('gk-w56', 'Woche 5–6: Bleib + Fuß + Aus', gw56)} + ${_renderAccordionPhase('gk-w78', 'Woche 7–8: Festigung + Alltagsintegration', gw78)} +
`; + } + + function _renderAufbaukurs() { + const intro = ` +

+ Voraussetzungen: Grundkurs abgeschlossen oder Kommandos bekannt.
+ Dauer: 8 Wochen  |  Ziel: Kommandos bei Ablenkung, Distanzkommandos, Tricks, mentale Auslastung. +

`; + + const aw12 = ` +

Alle bekannten Kommandos werden mit steigender Ablenkung trainiert. Immer bei der niedrigsten Stufe anfangen die noch klappt.

+ ${_renderTable( + ['Ablenkungsstufe', 'Beispiele'], + [ + ['Stufe 1', 'Leckerli auf dem Boden, Ball in Sichtweite'], + ['Stufe 2', 'Anderer Mensch im Raum, Geräusche'], + ['Stufe 3', 'Anderer Hund in Sichtweite'], + ['Stufe 4', 'Belebter Park, Straße'], + ['Stufe 5', 'Freilauf, Spielsituation unterbrechen'], + ] + )}`; + + const aw34 = ` +
    +
  • Sitz aus 5 Metern → 10 Metern
  • +
  • Platz aus 5 Metern → 10 Metern
  • +
  • Bleib mit 10 Metern Distanz
  • +
  • Hier vom Freilauf (mit Schleppleine absichern)
  • +
+

Übung "Fernbedienung": Hund steht 5 Meter entfernt → "Sitz" → kurz warten → "Platz" → kurz warten → "Sitz" → "Hier". Wirkt beeindruckend, festigt Kommandos enorm.

`; + + const aw56 = ` + ${_renderTable( + ['Woche', 'Trick', 'Nasenarbeit'], + [ + ['5', 'Pfote + Dreh', 'Leckerli unter 3 Bechern suchen'], + ['6', 'Decke/Matte', 'Leckerli im Gras suchen, Gegenstand apportieren'], + ] + )}`; + + const aw78 = ` +
    +
  • Schwächstes Kommando intensiv üben
  • +
  • Neue Umgebungen aufsuchen (anderer Wald, andere Stadt)
  • +
  • Hundesport ausprobieren: Agility, Mantrailing, Obedience, Trickdogging
  • +
+ ${_renderGoals('erwachsen_ak', [ + 'Alle Kommandos bei Stufe 3–4 Ablenkung', + 'Sitz + Platz auf 10 Meter Distanz', + 'Hier vom Freilauf (Schleppleine)', + '3–4 Tricks', + 'Nasenarbeit als regelmäßige Beschäftigung', + ])}`; + + return ` + ${intro} +
+ ${_renderAccordionPhase('ak-w12', 'Woche 1–2: Ablenkungstraining', aw12)} + ${_renderAccordionPhase('ak-w34', 'Woche 3–4: Distanzkommandos', aw34)} + ${_renderAccordionPhase('ak-w56', 'Woche 5–6: Tricks & Nasenarbeit', aw56)} + ${_renderAccordionPhase('ak-w78', 'Woche 7–8: Freie Gestaltung', aw78)} +
`; + } + + function _renderUebersicht() { + return ` +

+ ${_icon('clipboard-text')} Welcher Plan für welchen Hund? +

+ ${_renderTable( + ['Situation', 'Empfohlener Plan'], + [ + ['Neuer Welpe (8–16 Wochen)', 'Welpenplan Woche 1–4'], + ['Welpe 4–6 Monate', 'Welpenplan Woche 5–12'], + ['Junghund 6–12 Monate (Pubertät)', 'Juniorplan Monat 1–3'], + ['Junghund 12–18 Monate', 'Juniorplan Monat 4–6'], + ['Erwachsener Hund ohne Training', 'Grundkurs Erwachsener'], + ['Erwachsener Hund mit Grundwissen', 'Aufbaukurs Erwachsener'], + ['Neu eingezogener Hund (unbekannte Vorgeschichte)', 'Grundkurs Erwachsener, Tempo anpassen'], + ] + )}`; + } + + function _renderErwachsen() { + const intro = ` +

+ Wähle deinen Kurs oder sieh dir die Schnellübersicht an. +

`; + + let content = ''; + if (_activeAdultTab === 'grundkurs') { + content = _renderGrundkurs(); + } else if (_activeAdultTab === 'aufbaukurs') { + content = _renderAufbaukurs(); + } else { + content = `
${_renderUebersicht()}
`; + } + + return `${intro}${_renderErwachsenTabs()}${content}`; + } + + // ---------------------------------------------------------- + // BIND EVENTS + // ---------------------------------------------------------- + function _bindEvents() { + // Plan selector + _container.querySelectorAll('[data-plan]').forEach(btn => { + btn.addEventListener('click', () => { + _activePlan = btn.dataset.plan; + _render(); + }); + }); + + // Adult sub-tabs + _container.querySelectorAll('[data-tab]').forEach(btn => { + btn.addEventListener('click', () => { + _activeAdultTab = btn.dataset.tab; + _render(); + }); + }); + + // Accordion + _container.querySelectorAll('.tp-acc-head').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.acc; + const body = document.getElementById(`tp-acc-body-${id}`); + const arrow = btn.querySelector('.tp-acc-arrow'); + if (!body) return; + const isOpen = !body.hidden; + body.hidden = isOpen; + arrow.innerHTML = isOpen ? _icon('caret-down') : _icon('caret-up'); + }); + }); + + // Checkboxes + _container.querySelectorAll('input[data-lskey]').forEach(cb => { + cb.addEventListener('change', () => { + const key = cb.dataset.lskey; + _saveGoal(key, cb.checked); + _updateProgress(cb); + }); + }); + } + + function _updateProgress(cb) { + const goalsEl = cb.closest('.tp-goals'); + if (!goalsEl) return; + const total = parseInt(goalsEl.dataset.total, 10) || 0; + const done = goalsEl.querySelectorAll('input[type=checkbox]:checked').length; + const label = goalsEl.querySelector('.tp-progress-label'); + const bar = goalsEl.querySelector('.tp-progress-bar'); + if (label) label.textContent = `${done} von ${total} erreicht`; + if (bar) bar.style.width = total > 0 ? `${Math.round((done / total) * 100)}%` : '0%'; + } + + // ---------------------------------------------------------- + // MAIN RENDER + // ---------------------------------------------------------- + function _render() { + let planContent = ''; + if (_activePlan === 'welpe') planContent = _renderWelpe(); + else if (_activePlan === 'junior') planContent = _renderJunior(); + else planContent = _renderErwachsen(); + + _container.innerHTML = ` +
+

+ ${_icon('clipboard-text')} Trainingspläne +

+ ${_renderPlanSelector()} + ${planContent} +
`; + + _bindEvents(); + } + + // ---------------------------------------------------------- + // PUBLIC API + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _render(); + } + + function refresh() {} + function onDogChange() {} + + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js new file mode 100644 index 0000000..026ce0a --- /dev/null +++ b/backend/static/js/pages/uebungen.js @@ -0,0 +1,884 @@ +/* ============================================================ + BAN YARO — Übungsbibliothek + Seiten-Modul: Grundkommandos, Tricks, Problemverhalten, Grundlagen. + ============================================================ */ + +window.Page_uebungen = (() => { + + let _container = null; + let _appState = null; + let _activeTab = 'grundkommandos'; + + // ---------------------------------------------------------- + // DATEN + // ---------------------------------------------------------- + + const TABS = [ + { id: 'grundkommandos', label: 'Grundkommandos' }, + { id: 'tricks', label: 'Tricks & Beschäftigung' }, + { id: 'problemverhalten', label: 'Problemverhalten' }, + { id: 'grundlagen', label: 'Trainingsgrundlagen' }, + { id: 'ki-trainer', label: 'KI-Trainer' }, + ]; + + // ---------------------------------------------------------- + // ÜBUNGS-STATUS + // ---------------------------------------------------------- + const STATUS = [ + { id: null, icon: 'flag', color: 'var(--c-border)', label: 'Noch nicht geübt' }, + { id: 'noch-nicht', icon: 'x', color: 'var(--c-danger)', label: 'Klappt noch nicht' }, + { id: 'manchmal', icon: 'fire', color: '#f59e0b', label: 'Manchmal klappt es' }, + { id: 'meistens', icon: 'star', color: '#eab308', label: 'Meistens klappt es' }, + { id: 'sitzt', icon: 'trophy', color: 'var(--c-primary)', label: 'Sitzt!' }, + ]; + + function _statusKey(tab, name) { + return `ub_status_${tab}_${name.replace(/\s+/g, '_')}`; + } + + function _getStatus(tab, name) { + return localStorage.getItem(_statusKey(tab, name)) || null; + } + + function _setStatus(tab, name, statusId) { + if (statusId === null) { + localStorage.removeItem(_statusKey(tab, name)); + } else { + localStorage.setItem(_statusKey(tab, name), statusId); + } + } + + function _nextStatus(currentId) { + const idx = STATUS.findIndex(s => s.id === currentId); + const next = (idx + 1) % STATUS.length; + return STATUS[next].id; + } + + function _statusMeta(statusId) { + return STATUS.find(s => s.id === statusId) || STATUS[0]; + } + + const DIFF_META = { + 'Anfänger': { label: 'Anfänger', color: 'var(--c-success)' }, + 'Fortgeschrittener Anfänger': { label: 'Fortgeschr. Anfänger', color: '#eab308' }, + 'Mittel': { label: 'Mittel', color: 'var(--c-primary)' }, + 'Anfänger bis Fortgeschrittener': { label: 'Anfänger–Fortgeschr.', color: '#eab308' }, + }; + + const GRUNDKOMMANDOS = [ + { + name: 'Sitz', + schwierigkeit: 'Anfänger', + alter: 'Ab 8 Wochen', + dauer: '3–5 Minuten', + material: 'Leckerlis, ruhige Umgebung', + beschreibung: 'Der Hund setzt sich auf ein Signal hin. Das ist meist das erste Kommando und bildet die Basis für viele weitere Übungen.', + schritte: [ + 'Halte ein Leckerli knapp vor die Nase deines Hundes.', + 'Führe das Leckerli langsam nach oben und leicht nach hinten über seinen Kopf.', + 'Der Hund folgt mit der Nase — sein Hinterteil senkt sich automatisch.', + 'Sobald er sitzt: sofort Markerwort ("Ja!" oder Klicker) + Leckerli.', + 'Wiederhole 5–10x, bevor du das Wort "Sitz" hinzufügst.', + 'Ab Wiederholung 10: Sage "Sitz" kurz bevor du die Handbewegung machst.', + ], + fehler: [ + 'Leckerli zu hoch halten → Hund springt hoch statt zu sitzen', + 'Kommando zu früh einführen → Hund lernt das Wort bevor er die Bewegung kennt', + 'Zu lange Einheiten → Hund wird unkonzentriert', + ], + steigerung: 'Sitz mit Ablenkung → Sitz aus Bewegung → Sitz auf Distanz', + }, + { + name: 'Platz', + schwierigkeit: 'Anfänger', + alter: 'Ab 10 Wochen', + dauer: '3–5 Minuten', + material: 'Leckerlis, ruhige Umgebung', + beschreibung: 'Der Hund legt sich auf ein Signal hin. Wichtig für Ruhephasen, Wartesituationen und als Basis für "Bleib".', + schritte: [ + 'Beginne mit dem Hund im Sitz.', + 'Halte ein Leckerli vor seine Nase und führe es langsam senkrecht nach unten zwischen seine Vorderpfoten.', + 'Der Hund folgt mit der Nase und legt sich ab.', + 'Sobald Ellenbogen und Hinterteil den Boden berühren: Markerwort + Leckerli.', + 'Klappt es nicht: Leckerli unter ein angewinkeltes Knie halten — der Hund kriecht darunter durch und legt sich dabei ab.', + 'Wort "Platz" erst nach 10–15 erfolgreichen Wiederholungen einführen.', + ], + fehler: [ + 'Hund steht auf statt sich hinzulegen → Leckerli-Führung zu weit weg', + 'Hund liegt nur kurz → zu früh belohnen, Dauer schrittweise aufbauen', + ], + steigerung: 'Platz mit Dauer → Platz mit Distanz → Platz bei Ablenkung', + }, + { + name: 'Bleib', + schwierigkeit: 'Anfänger bis Fortgeschrittener', + alter: 'Ab 12 Wochen', + dauer: '5 Minuten', + material: 'Leckerlis, Geduld', + beschreibung: 'Der Hund hält eine Position (Sitz oder Platz) bis er freigegeben wird. Drei Dimensionen: Dauer, Distanz, Ablenkung — immer nur eine auf einmal steigern.', + schritte: [ + 'Hund ins Sitz oder Platz bringen.', + 'Einen Moment warten (2 Sekunden) → Markerwort + Leckerli.', + 'Freigabewort einführen: "Okay" oder "Frei" — danach darf der Hund aufstehen.', + 'Dauer schrittweise erhöhen: 2 → 5 → 10 → 30 Sekunden.', + 'Erst wenn Dauer stabil ist: einen kleinen Schritt zurücktreten.', + 'Zurückkehren zum Hund, belohnen — nicht den Hund zu dir kommen lassen.', + ], + fehler: [ + 'Zu schnell Distanz aufbauen → Hund bricht ab', + 'Hund wird zur Person gelobt statt an Ort → Hund kommt herangelaufen', + 'Freigabewort vergessen → Hund weiß nicht wann er aufstehen darf', + ], + steigerung: 'Bleib 1 Min → Bleib mit Sichtkontaktverlust → Bleib bei Ablenkung (Ball rollen, andere Person)', + }, + { + name: 'Hier / Komm', + schwierigkeit: 'Anfänger', + alter: 'Ab 8 Wochen', + dauer: '5 Minuten', + material: 'Leckerlis oder Spielzeug, Schleppleine empfohlen', + beschreibung: 'Der Hund kommt zuverlässig zurück wenn er gerufen wird. Eines der wichtigsten Kommandos — im Zweifel lebensrettend.', + schritte: [ + 'Beginne in der Wohnung auf kurze Distanz (2–3 Meter).', + 'Knie dich hin, öffne die Arme, freudige Stimme: "Hier!" oder "Komm!"', + 'Sobald der Hund ankommt: große Freude, Leckerli, Streicheln.', + 'Niemals rufen und dann etwas Unangenehmes tun (Bad, Krallen schneiden).', + 'Im Garten: Schleppleine verwenden — Hund kann nicht wegbleiben, Erfolg ist garantiert.', + 'Ruf nur einmal — wer mehrfach ruft trainiert den Hund aufs Ignorieren.', + ], + fehler: [ + 'Hund rufen wenn er sicher nicht kommt → schlechte Gewohnheit', + 'Hund bestrafen nach dem Kommen → nächstes Mal kommt er nicht', + 'Monotone Stimme → Hund motiviert sich nicht', + ], + steigerung: 'Kurze Distanz innen → Garten mit Schleppleine → Park mit wenig Ablenkung → Freilauf', + }, + { + name: 'Fuß', + schwierigkeit: 'Fortgeschrittener Anfänger', + alter: 'Ab 12 Wochen', + dauer: '5–10 Minuten', + material: 'Leckerlis, Leine', + beschreibung: 'Der Hund läuft ruhig an der Leine neben dir, ohne zu ziehen. Die Leine hängt locker durch.', + schritte: [ + 'Hund an deine linke Seite stellen (klassisch), Leckerli in der linken Hand.', + 'Einen Schritt vorwärts gehen, Leckerli auf Höhe deiner linken Hüfte halten.', + 'Hund folgt dem Leckerli → Markerwort + belohnen.', + 'Schrittweise mehr Schritte, immer wieder belohnen wenn Leine locker ist.', + 'Zieht der Hund: stehen bleiben oder Richtung wechseln — nie mitziehen lassen.', + 'Wort "Fuß" einführen sobald der Hund die Position versteht.', + ], + fehler: [ + 'Leine straff halten → Hund lernt Zug als Normalzustand', + 'Zu selten belohnen am Anfang → Hund verliert Interesse an der Position', + 'Zu lange Einheiten → Überforderung', + ], + steigerung: 'Fuß in der Wohnung → ruhige Straße → belebte Umgebung → ohne Leckerli in der Hand', + }, + { + name: 'Aus / Lass es', + schwierigkeit: 'Anfänger', + alter: 'Ab 10 Wochen', + dauer: '3–5 Minuten', + material: 'Leckerlis, Spielzeug oder Gegenstand', + beschreibung: 'Der Hund lässt einen Gegenstand auf Kommando los. Wichtig für Sicherheit (Gefährliches fallen lassen) und Spielsituationen.', + schritte: [ + 'Gib dem Hund ein Spielzeug oder lass ihn etwas halten.', + 'Halte ein Leckerli vor seine Nase — er lässt den Gegenstand fallen.', + 'Sofort Markerwort + Leckerli geben.', + 'Gegenstand kurz aufheben, dann wieder zurückgeben → Hund lernt: Loslassen lohnt sich.', + 'Wort "Aus" kurz vor dem Leckerli-Zeigen einführen.', + ], + fehler: [ + 'Gegenstand wegziehen → Hund lernt festhalten', + 'Immer wegnehmen nach "Aus" → Hund gibt nicht mehr freiwillig her', + ], + steigerung: 'Spielzeug → Leckerli auf dem Boden → Gegenstand unterwegs → hochwertige Beute', + }, + { + name: 'Warte', + schwierigkeit: 'Anfänger', + alter: 'Ab 10 Wochen', + dauer: '3–5 Minuten', + material: 'Leckerlis, Türschwelle oder Futterschüssel', + beschreibung: 'Der Hund wartet kurz in einer Situation bis er freigegeben wird — z.B. vor der Tür, vor dem Futter, beim Aussteigen aus dem Auto.', + schritte: [ + 'Stelle die Futterschüssel auf den Boden — Hund stürmt drauf zu.', + 'Schüssel mit der Hand abdecken oder hochhalten.', + 'Sobald der Hund zurückweicht oder sitzt: Schüssel freigeben + "Okay".', + 'Alternativ: an der Türschwelle — Tür öffnen, Hund wartet bis "Okay".', + 'Wort "Warte" einführen sobald das Verhalten klar ist.', + ], + fehler: [ + 'Hund wird nie freigegeben → verliert Vertrauen ins System', + 'Zu lange warten lassen am Anfang → Frustration', + ], + steigerung: null, + }, + ]; + + const TRICKS = [ + { + name: 'Pfote / Schütteln', + schwierigkeit: 'Anfänger', + alter: 'Ab 12 Wochen', + dauer: '3 Minuten', + material: 'Leckerlis', + beschreibung: null, + schritte: [ + 'Hund ins Sitz bringen.', + 'Leckerli in der Faust verstecken, Faust auf Kniehöhe halten.', + 'Hund schnuppert, kratzt irgendwann mit der Pfote an der Faust.', + 'Sofort öffnen: Markerwort + Leckerli.', + 'Wort "Pfote" einführen sobald die Bewegung zuverlässig kommt.', + 'Auf flache offene Hand umstellen → Hund legt Pfote rein.', + ], + fehler: [], + steigerung: null, + }, + { + name: 'Dreh / Runde', + schwierigkeit: 'Anfänger', + alter: 'Ab 12 Wochen', + dauer: '3–5 Minuten', + material: 'Leckerlis', + beschreibung: null, + schritte: [ + 'Leckerli vor die Nase des Hundes halten.', + 'Langsam einen Kreis in der Luft führen — der Hund folgt mit der Nase.', + 'Volle Drehung → Markerwort + Leckerli.', + 'Wort "Dreh" (links) und "Runde" (rechts) einführen.', + 'Handbewegung schrittweise kleiner machen.', + ], + fehler: [], + steigerung: null, + }, + { + name: 'Platz auf Decke / Matte', + schwierigkeit: 'Fortgeschrittener Anfänger', + alter: 'Ab 4 Monate', + dauer: '5–10 Minuten', + material: 'Leckerlis, Decke oder Matte', + beschreibung: 'Der Hund geht selbstständig auf seine Decke und legt sich. Ideal für Besuche, Restaurant, ruhige Phasen.', + schritte: [ + 'Decke auf den Boden legen. Hund beschnuppert sie → Leckerli auf die Decke werfen.', + 'Jedes Mal wenn der Hund die Decke betritt: Markerwort + Leckerli.', + 'Warten bis der Hund sich spontan auf die Decke legt → große Belohnung.', + '"Platz" zeigen sobald er auf der Decke steht.', + 'Decke schrittweise weiter wegstellen.', + 'Wort "Decke" oder "Platz geh" einführen.', + ], + fehler: [], + steigerung: null, + }, + { + name: 'Suchspiel / Nasenarbeit', + schwierigkeit: 'Anfänger', + alter: 'Ab 8 Wochen', + dauer: '5–10 Minuten', + material: 'Leckerlis, optional Döschen', + beschreibung: 'Nasenarbeit ist mentale Auslastung — 10 Minuten Suchen ermüdet mehr als 1 Stunde Spaziergang.', + schritte: [ + 'Leckerli vor den Augen des Hundes unter einem Becher verstecken.', + 'Hund darf suchen → findet er es: Markerwort + Leckerli.', + 'Steigerung: mehrere Becher, Hund muss den richtigen finden.', + 'Später: Leckerlis im Gras verstecken → "Such!"', + 'Noch später: Spielzeug oder Schlüssel suchen lassen.', + ], + fehler: [], + steigerung: null, + }, + ]; + + const PROBLEMVERHALTEN = [ + { + name: 'Nicht springen / Begrüßung', + schwierigkeit: 'Anfänger', + alter: 'Ab 8 Wochen', + dauer: 'Bei jeder Begrüßung', + material: 'Konsequenz aller Haushaltsmitglieder', + beschreibung: 'Der Hund begrüßt Menschen mit allen vier Pfoten auf dem Boden.', + schritte: [ + 'Springt der Hund hoch: keine Reaktion (kein "Nein", kein Wegdrücken, kein Augenkontakt).', + 'Sobald alle vier Pfoten unten sind: sofort Markerwort + Leckerli + Aufmerksamkeit.', + 'Konsequenz ist entscheidend — alle im Haushalt müssen gleich reagieren.', + 'Alternative: Hund ins Sitz schicken bei Begrüßung → dann begrüßen.', + ], + fehler: [], + steigerung: null, + }, + { + name: 'Alleine bleiben', + schwierigkeit: 'Mittel', + alter: 'Ab 10 Wochen', + dauer: 'Mehrmals täglich kurze Einheiten', + material: 'Geduld, Zeit, Kong oder Kauartikel', + beschreibung: 'Der Hund bleibt ruhig wenn er allein ist — ohne Stress, Bellen oder Zerstören.', + schritte: [ + 'Hund beschäftigen (Kong mit Futter gefüllt, Kauartikel).', + 'Zimmer verlassen für 10 Sekunden → zurückkommen, ruhig begrüßen.', + 'Zeit schrittweise erhöhen: 30 Sek → 2 Min → 5 Min → 15 Min.', + 'Nie dramatisch verabschieden oder begrüßen — Kommen und Gehen normalisieren.', + 'Hund nicht bestrafen wenn er etwas angestellt hat — er versteht den Zusammenhang nicht mehr.', + ], + fehler: [], + steigerung: null, + hinweis: 'Welpen sollten nie länger als 1–2 Stunden allein gelassen werden. Erwachsene Hunde maximal 4–6 Stunden.', + }, + { + name: 'Leinenführigkeit — Nicht ziehen', + schwierigkeit: 'Mittel', + alter: 'Ab 12 Wochen', + dauer: 'Jeder Spaziergang', + material: 'Leine, Leckerlis, Geduld', + beschreibung: null, + schritte: [ + 'Beginne den Spaziergang ruhig — aufgeregte Starts fördern das Ziehen.', + 'Zieht der Hund: stehen bleiben. Warten bis Leine locker ist.', + 'Oder: Richtung wechseln sobald die Leine straff wird.', + 'Locker Leine = Bewegung vorwärts + gelegentlich Leckerli.', + 'Kein Ruck an der Leine — Hund lernt dadurch nicht.', + ], + fehler: [], + steigerung: null, + hinweis: 'Hilfsmittel bei hartnäckigem Ziehen: Brustgeschirr mit vorderer Befestigung (kein Würge- oder Stachelband).', + }, + ]; + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _render(); + } + + function refresh() {} + function onDogChange() {} + + // ---------------------------------------------------------- + // HAUPT-RENDER + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ ${_renderTabs()} +
+
+ `; + _bindTabs(); + _renderContent(); + } + + function _renderTabs() { + return ` +
+ ${TABS.map(t => ` + + `).join('')} +
+ `; + } + + function _bindTabs() { + _container.querySelectorAll('#ueb-tabs .by-tab').forEach(btn => { + btn.addEventListener('click', () => { + _activeTab = btn.dataset.tab; + _container.querySelectorAll('#ueb-tabs .by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === _activeTab) + ); + _renderContent(); + }); + }); + } + + function _renderContent() { + const el = _container.querySelector('#ueb-content'); + if (!el) return; + switch (_activeTab) { + case 'grundkommandos': el.innerHTML = _renderUebungsList(GRUNDKOMMANDOS); break; + case 'tricks': el.innerHTML = _renderUebungsList(TRICKS); break; + case 'problemverhalten': el.innerHTML = _renderUebungsList(PROBLEMVERHALTEN); break; + case 'grundlagen': el.innerHTML = _renderGrundlagen(); break; + case 'ki-trainer': el.innerHTML = _renderKiTrainer(); break; + } + _bindAccordions(); + _bindStatusButtons(); + if (_activeTab === 'ki-trainer') _bindKiTrainer(); + } + + // ---------------------------------------------------------- + // ÜBUNGS-CARDS + // ---------------------------------------------------------- + function _renderUebungsList(list) { + return ` +
+ ${list.map((u, i) => _renderCard(u, i)).join('')} +
+ `; + } + + function _renderCard(u, i) { + const diff = DIFF_META[u.schwierigkeit] || { label: u.schwierigkeit, color: 'var(--c-text-secondary)' }; + const uid = `ueb-acc-${_activeTab}-${i}`; + const currentId = _getStatus(_activeTab, u.name); + const sm = _statusMeta(currentId); + + const hasBody = u.schritte.length > 0 || u.fehler.length > 0 || u.steigerung; + + return ` +
+ +
+
+ + ${_esc(u.name)} + +
+ + + + + ${_esc(diff.label)} + +
+
+ +
+ + + ${_esc(u.dauer)} + + + + ${_esc(u.alter)} + + + + ${_esc(u.material)} + +
+ ${u.beschreibung ? ` +

+ ${_esc(u.beschreibung)} +

+ ` : ''} + ${u.hinweis ? ` +
+ + ${_esc(u.hinweis)} +
+ ` : ''} +
+ ${hasBody ? ` + + + + ` : ''} +
+ `; + } + + function _bindStatusButtons() { + _container.querySelectorAll('.ueb-status-btn').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const tab = btn.dataset.tab; + const name = btn.dataset.name; + const cur = _getStatus(tab, name); + const next = _nextStatus(cur); + _setStatus(tab, name, next); + + // Update button in place (no full re-render) + const sm = _statusMeta(next); + btn.title = sm.label; + btn.style.color = sm.color; + btn.innerHTML = ` + + ${next ? `${_esc(sm.label)}` : ''} + `; + }); + }); + } + + // ---------------------------------------------------------- + // KI-TRAINER + // ---------------------------------------------------------- + function _renderKiTrainer() { + return ` +
+ + +
+
+ +
+

+ KI-Hundetrainer +

+

+ Beschreibe ein konkretes Problem oder Verhalten deines Hundes — + du bekommst individuelle Trainingstipps. +

+
+
+
+ + +
+ +
+ + +
+ + + +
+ 0 / 1000 + +
+
+ + + + + +

+ KI-Tipps ersetzen keinen professionellen Hundetrainer. Bei Aggression oder starker Angst + wende dich an einen zertifizierten Trainer vor Ort. +

+ +
+ `; + } + + function _bindKiTrainer() { + const textarea = _container.querySelector('#ki-problem'); + const charCount = _container.querySelector('#ki-char-count'); + const submitBtn = _container.querySelector('#ki-submit'); + const result = _container.querySelector('#ki-result'); + if (!textarea || !submitBtn) return; + + textarea.addEventListener('input', () => { + charCount.textContent = `${textarea.value.length} / 1000`; + }); + + submitBtn.addEventListener('click', async () => { + const problem = textarea.value.trim(); + if (problem.length < 10) { + UI.toast('Bitte beschreibe das Problem etwas genauer.', 'warning'); + return; + } + const rasse = _container.querySelector('#ki-rasse')?.value.trim() || null; + const alter = _container.querySelector('#ki-alter')?.value.trim() || null; + + submitBtn.disabled = true; + submitBtn.innerHTML = ` Denke nach…`; + + result.hidden = true; + result.innerHTML = ''; + + try { + const resp = await API.post('/ki/training', { problem, rasse, alter }); + const text = resp.antwort || ''; + + // Render with simple markdown-like formatting (text already escaped by API) + const safeText = text + .replace(/&/g, '&').replace(//g, '>'); + const html = safeText + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/^(\d+)\. (.+)$/gm, '
  • $1. $2
  • ') + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/\n\n/g, '

    ') + .replace(/\n/g, '
    '); + + result.innerHTML = ` +

    +
    + + + Empfehlung des KI-Trainers + +
    +
    +

    ${html}

    +
    +
    + `; + result.hidden = false; + } catch (err) { + UI.toast(err.message || 'KI momentan nicht verfügbar.', 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.innerHTML = ` Tipps holen`; + } + }); + } + + function _bindAccordions() { + _container.querySelectorAll('.ueb-acc-btn').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.acc; + const body = document.getElementById(id); + const chevron = _container.querySelector(`.ueb-chevron[data-acc="${id}"]`); + if (!body) return; + const isOpen = !body.hidden; + body.hidden = isOpen; + if (chevron) chevron.style.transform = isOpen ? '' : 'rotate(180deg)'; + btn.querySelector('span').textContent = isOpen ? 'Anleitung anzeigen' : 'Anleitung ausblenden'; + }); + }); + } + + // ---------------------------------------------------------- + // TRAININGSGRUNDLAGEN + // ---------------------------------------------------------- + function _renderGrundlagen() { + return ` +
    + + +
    +

    + + Das Markerwort +

    +

    + Ein Markerwort (z.B. "Ja!" oder ein Klicker) signalisiert dem Hund den exakten Moment + des richtigen Verhaltens. Es überbrückt die Zeit bis das Leckerli in seinem Mund ist. +

    +
      +
    • + Einmalig einführen: Markerwort sagen → sofort Leckerli (10x wiederholen) +
    • +
    • + Immer nur ein Markerwort verwenden +
    • +
    • + Das Leckerli kommt immer nach dem Markerwort — sonst verliert es seinen Wert +
    • +
    +
    + + +
    +

    + + Wann belohnen? +

    +
    + + + + + + + + + ${[ + ['Neue Übung lernen', 'Jede korrekte Wiederholung'], + ['Übung bekannt', 'Jede 2.–3. Wiederholung'], + ['Übung gefestigt', 'Unregelmäßig (stärkt Motivation)'], + ['Ablenkungstraining', 'Wieder häufiger (höhere Anforderung)'], + ].map(([p, b], i) => ` + + + + + `).join('')} + +
    PhaseBelohnungshäufigkeit
    ${_esc(p)}${_esc(b)}
    +
    +
    + + +
    +

    + + Leckerli-Hierarchie +

    +

    + Nicht alle Leckerlis sind gleich. Bei Ablenkung oder schwierigen Übungen brauchst du hochwertigere Belohnungen: +

    +
    + + + + + + + + + ${[ + ['Niedrig', 'Trockenfutter, normale Hundekekse', '#22c55e'], + ['Mittel', 'Käse, Wurst, Hühnchen (gekocht)', '#eab308'], + ['Hoch', 'Leberwurst, Lachs, Pansen (für besondere Momente)', '#ef4444'], + ].map(([s, b, c], i) => ` + + + + + `).join('')} + +
    StufeBeispiele
    + ${_esc(s)} + ${_esc(b)}
    +
    +
    + + +
    +

    + + Trainingsregeln auf einen Blick +

    +
      + ${[ + [true, 'Kurze Einheiten (5–10 Min), lieber mehrmals täglich'], + [true, 'Immer mit Erfolg beenden'], + [true, 'Ein Kommando — eine Bedeutung'], + [true, 'Konsequenz bei allen Haushaltsmitgliedern'], + [false, 'Nie bestrafen, schreien oder Zwang anwenden'], + [false, 'Kommando nicht wiederholen wenn der Hund nicht reagiert'], + [false, 'Nicht trainieren wenn Hund müde, krank oder aufgewühlt ist'], + ].map(([ok, text]) => ` +
    • + + ${_esc(text)} +
    • + `).join('')} +
    +
    + +
    + `; + } + + // ---------------------------------------------------------- + // HELPER + // ---------------------------------------------------------- + function _esc(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ---------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------- + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 21b7688..e22f3ff 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -63,7 +63,7 @@ window.Page_walks = (() => {
    -
    +
    @@ -113,6 +113,7 @@ window.Page_walks = (() => { _loadLeaflet().then(() => { _initMap(); setTimeout(() => _map?.invalidateSize(), 50); + setTimeout(() => _map?.invalidateSize(), 300); }); } } @@ -160,12 +161,12 @@ window.Page_walks = (() => { let html = ''; if (heute.length) { - html += ``; + html += ``; html += heute.map(w => _walkCardHTML(w)).join(''); } if (upcoming.length) { - html += ``; + html += ``; html += upcoming.map(w => _walkCardHTML(w)).join(''); } @@ -235,7 +236,7 @@ window.Page_walks = (() => { _markers = []; _data.forEach(w => { const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; - const color = _isToday(w.datum) ? '#C4843A' : (isFull ? '#6B7280' : '#22C55E'); + const color = _isToday(w.datum) ? 'var(--c-primary)' : (isFull ? '#6B7280' : '#22C55E'); const icon = L.divIcon({ className: '', html: `
    + + +
    + Ban Yaro +

    Ban Yaro

    +

    + Die Plattform für Hundebesitzer —
    Tagebuch, Gesundheit, Community und mehr. +

    +
    + + +
    +
    + Was Ban Yaro kann +
    +
    + ${[ + ['book-open', 'Tagebuch', 'Momente, Fotos und Meilensteine festhalten'], + ['syringe', 'Gesundheit', 'Impfungen, Tierarztbesuche & Medikamente'], + ['map-trifold', 'Karte & Routen', 'Hundefreundliche Orte und Spazierwege'], + ['warning-octagon','Giftköder-Alarm', 'Community-Warnungen in deiner Nähe'], + ['paw-print', 'Gassi-Treffen', 'Hunde-Dates mit anderen Besitzern'], + ['house-line', 'Sitting', 'Dogsitter finden oder selbst anbieten'], + ['target', 'Training', 'Übungen, Pläne und KI-Trainer'], + ['books', 'Wiki & Wissen', 'Rassen, Ernährung, Erste Hilfe'], + ].map(([icon, title, desc], i) => ` +
    +
    + +
    +
    +
    ${title}
    +
    ${desc}
    +
    +
    + `).join('')} +
    +
    + + +
    +
    + App installieren +
    +
    + ${isInstalled + ? `
    + + + Ban Yaro ist bereits installiert. + +
    ` + : _installHTML() + } +
    +
    + + + ${!_appState.user ? ` +
    + + +
    + ` : ''} + + +

    + Ban Yaro · Deine Daten auf eigenem Server in Deutschland. +

    + +
    + `; + + _bindEvents(); + } + + // ---------------------------------------------------------- + // INSTALLATIONS-ANLEITUNG (plattformabhängig) + // ---------------------------------------------------------- + function _installHTML() { + const ua = navigator.userAgent; + const isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream; + const isSafari = /^((?!chrome|android).)*safari/i.test(ua); + const isAndroid = /android/i.test(ua); + const hasPrompt = !!App.getInstallPrompt(); + + // Android/Chrome mit nativem Prompt + if ((isAndroid || hasPrompt) && hasPrompt) { + return ` +

    + Installiere Ban Yaro direkt auf deinem Gerät — kein App Store nötig. + Die App verhält sich wie eine native App und funktioniert auch offline. +

    + + `; + } + + // iOS Safari + if (isIOS && isSafari) { + return ` +

    + Installiere Ban Yaro auf deinem iPhone oder iPad: +

    + ${_steps([ + ['share', 'Tippe auf das Teilen-Symbol in Safari (Rechteck mit Pfeil nach oben)'], + ['plus', 'Scrolle nach unten und tippe auf „Zum Home-Bildschirm"'], + ['check', 'Tippe rechts oben auf „Hinzufügen" — fertig!'], + ])} +

    + Funktioniert nur in Safari, nicht in anderen Browsern auf iOS. +

    + `; + } + + // Desktop oder andere Browser + return ` +

    + Ban Yaro lässt sich in Chrome, Edge und anderen modernen Browsern installieren: +

    + ${_steps([ + ['globe', 'Öffne Ban Yaro in Chrome oder Edge'], + ['download-simple','Klicke in der Adressleiste auf das Installieren-Symbol (↓ mit Kreis)'], + ['check', 'Bestätige die Installation — Ban Yaro öffnet sich dann wie eine Desktop-App'], + ])} +

    + Auf Android: Menü (⋮) → „App installieren" oder + „Zum Startbildschirm hinzufügen". +

    + `; + } + + function _steps(list) { + return ` +
      + ${list.map(([icon, text], i) => ` +
    1. +
      + ${i + 1} +
      + ${text} +
    2. + `).join('')} +
    + `; + } + + // ---------------------------------------------------------- + // EVENTS + // ---------------------------------------------------------- + function _bindEvents() { + // Android-Install-Button + _container.querySelector('#install-android-btn')?.addEventListener('click', async () => { + const prompt = App.getInstallPrompt(); + if (!prompt) return; + prompt.prompt(); + const { outcome } = await prompt.userChoice; + if (outcome === 'accepted') { + UI.toast.success('Ban Yaro wird installiert!'); + _render(); // zeigt "bereits installiert" + } + }); + + // CTAs für nicht-eingeloggte User + _container.querySelector('#welcome-register-btn')?.addEventListener('click', () => { + App.navigate('settings'); + }); + _container.querySelector('#welcome-login-btn')?.addEventListener('click', () => { + App.navigate('settings'); + }); + } + + // ---------------------------------------------------------- + // PUBLIC + // ---------------------------------------------------------- + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index fe31466..f21273c 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -33,7 +33,7 @@ window.Page_wiki = (() => { { titel: 'Vergiftungen — Sofortmaßnahmen', icon: 'skull', - text: 'Verdacht auf Vergiftung: Sofort Tierarzt oder Tiergiftzentrale (Berlin: 030 19240, München: 089 19240).\n\nNICHT versuchen den Hund zum Erbrechen zu bringen ohne tierärztlichen Rat.\n\nHäufige Giftquellen: Schokolade, Trauben/Rosinen, Zwiebeln, Xylitol (Süßungsmittel), Ibuprofen.', + text: 'Verdacht auf Vergiftung: Sofort Tierarzt oder Tiergiftzentrale (Berlin: 030 19240, München: 089 19240, Wien: 01 4064343).\n\nNICHT versuchen den Hund zum Erbrechen zu bringen ohne tierärztlichen Rat.\n\nHäufige Giftquellen: Schokolade, Trauben/Rosinen, Zwiebeln, Xylitol (Süßungsmittel), Ibuprofen.', }, { titel: 'Hitzschlag', @@ -97,12 +97,12 @@ window.Page_wiki = (() => { const isMod = _appState.user && (_appState.user.is_moderator || _appState.user.rolle === 'admin'); _container.innerHTML = ` -
    - - - - - ${isMod ? `` : ''} +
    + + + + + ${isMod ? `` : ''}
    `; @@ -111,7 +111,7 @@ window.Page_wiki = (() => { const btn = e.target.closest('[data-tab]'); if (!btn) return; _tab = btn.dataset.tab; - _container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab)); + _container.querySelectorAll('.by-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab)); _renderTab(); }); @@ -174,7 +174,7 @@ window.Page_wiki = (() => { ? `
    Aktuelles Foto:
    ` - : `
    Kein Foto vorhanden
    ` + : `
    Kein Foto vorhanden
    ` }
    @@ -227,7 +227,7 @@ window.Page_wiki = (() => { try { stats = await _apiFetch('/api/wiki/stats'); } catch { - el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Wiki konnte nicht geladen werden.' }); + el.innerHTML = UI.emptyState({ icon: UI.icon('warning'), title: 'Fehler', text: 'Wiki konnte nicht geladen werden.' }); return; } @@ -753,7 +753,7 @@ window.Page_wiki = (() => { try { data = await _apiFetch(`/api/wiki/quiz/result?${params}`); } catch { - el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Ergebnis konnte nicht geladen werden.' }); + el.innerHTML = UI.emptyState({ icon: UI.icon('warning'), title: 'Fehler', text: 'Ergebnis konnte nicht geladen werden.' }); return; } @@ -796,7 +796,7 @@ window.Page_wiki = (() => { el.querySelectorAll('.wiki-quiz-mehr').forEach(btn => { btn.addEventListener('click', () => { _tab = 'rassen'; - _container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'rassen')); + _container.querySelectorAll('.by-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'rassen')); _openBreedDetail(btn.dataset.slug); }); }); diff --git a/backend/static/sw.js b/backend/static/sw.js index de75268..92b02ce 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v90'; +const CACHE_VERSION = 'by-v103'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten