Sprint 12: UI-Vereinheitlichung + Läufigkeits-Tracker

- by-tabs/by-tab: einheitliche Tab/Pill-Navigation in allen Seiten
- by-section-label, by-toolbar: einheitliche Section-Labels und Toolbars
- Design-Tokens: fehlende --c-amber, --c-primary-soft ergänzt, Fallback-Werte entfernt
- sitting.js: sitting-layout für konsistentes flush-Layout (wie walks)
- Läufigkeits-Tracker: neuer Health-Tab für Hündinnen mit Zyklusvorhersage,
  Timeline vergangener Läufigkeiten, Erinnerungen und auto-berechnetem Nächst-Datum
- emptyState-Bug: icon-Parameter muss SVG sein, nicht Icon-Name (dog/bell/warning gefixt)
- SW-Cache: by-v103, APP_VER: 79
This commit is contained in:
rene 2026-04-16 22:31:33 +02:00
parent 32d630d5a1
commit b58789373c
30 changed files with 4344 additions and 523 deletions

View file

@ -163,7 +163,69 @@
}
/* ------------------------------------------------------------
3. BADGES & STATUS-PILLS
3. BY-TABS Einheitliche Tab/Filter-Navigation (app-weit)
------------------------------------------------------------ */
.by-tabs {
display: flex;
gap: var(--space-2);
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.by-tabs::-webkit-scrollbar { display: none; }
.by-tab {
flex-shrink: 0;
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--c-border);
border-radius: var(--radius-full);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
touch-action: manipulation;
}
.by-tab.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: var(--c-text-inverse);
}
.by-tab:hover:not(.active) {
border-color: var(--c-primary);
color: var(--c-primary);
}
/* ------------------------------------------------------------
4. BY-SECTION-LABEL + BY-TOOLBAR weitere gemeinsame Elemente
------------------------------------------------------------ */
/* Kleines Überschriften-Label über Gruppen ("Aktuelle Medikamente" etc.) */
.by-section-label {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: var(--space-3) 0 var(--space-1);
}
/* Toolbar-Leiste oben auf einer Seite (Background + Border + Flex) */
.by-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
}
/* ------------------------------------------------------------
5. BADGES & STATUS-PILLS
------------------------------------------------------------ */
.badge {
display: inline-flex;
@ -863,12 +925,8 @@ textarea.form-control {
GESUNDHEIT
============================================================ */
/* Header mit KI-Button */
.health-header {
display: flex;
justify-content: flex-end;
padding: var(--space-3) 0 var(--space-2);
}
/* .health-header → by-toolbar with flex-end override */
.health-header { justify-content: flex-end; padding: var(--space-3) 0 var(--space-2); background: none; border-bottom: none; }
/* Tab-Leiste — Mobile: horizontal scrollbar, Desktop: umbrechen */
.health-tabs {
@ -878,36 +936,7 @@ textarea.form-control {
padding-bottom: var(--space-2);
margin-bottom: var(--space-3);
}
/* Auf sehr kleinen Screens: scrollen statt umbrechen */
@media (max-width: 480px) {
.health-tabs {
flex-wrap: nowrap;
overflow-x: auto;
padding-right: var(--space-4);
scrollbar-width: none;
}
.health-tabs::-webkit-scrollbar { display: none; }
}
.health-tab {
flex-shrink: 0;
padding: var(--space-2) var(--space-3);
border: 2px solid var(--c-border);
border-radius: var(--radius-full);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
touch-action: manipulation;
}
.health-tab.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: var(--c-text-inverse);
}
/* .health-tabs / .health-tab → now use .by-tabs / .by-tab */
/* Karten-Liste */
.health-list {
@ -954,15 +983,7 @@ textarea.form-control {
.ampel-text-yellow { color: #d97706; }
.ampel-text-red { color: #dc2626; }
/* Gruppen-Label (z.B. "Aktuelle Medikamente") */
.health-group-label {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: var(--space-3) 0 var(--space-1);
}
/* .health-group-label → now uses .by-section-label */
/* Gewicht-Diagramm-Wrapper */
.health-chart-wrap {
@ -1464,10 +1485,10 @@ textarea.form-control {
background: var(--c-bg);
}
.rk-header {
background: var(--c-surface);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border-light);
padding: var(--space-3) var(--space-4);
flex-shrink: 0;
padding: var(--space-3) var(--space-4);
flex-shrink: 0;
}
.rk-search-row {
display: flex;
@ -2118,15 +2139,18 @@ textarea.form-control {
/* ------------------------------------------------------------
GASSI-TREFFEN (walks.js)
------------------------------------------------------------ */
.walks-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
.walks-layout {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.walks-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* .walks-toolbar → now uses .by-toolbar */
.walks-view-toggle {
display: flex;
gap: var(--space-1);
@ -2158,13 +2182,7 @@ textarea.form-control {
flex-direction: column;
gap: var(--space-3);
}
.walks-section-label {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-text-secondary);
padding: var(--space-1) 0;
margin-bottom: var(--space-1);
}
/* .walks-section-label → now uses .by-section-label */
.walks-card {
background: var(--c-surface);
border-radius: var(--radius-lg);
@ -2239,15 +2257,7 @@ textarea.form-control {
/* ------------------------------------------------------------
EVENTS (events.js)
------------------------------------------------------------ */
.events-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
}
/* .events-toolbar → now uses .by-toolbar */
.events-view-toggle {
display: flex;
gap: var(--space-1);
@ -2272,33 +2282,12 @@ textarea.form-control {
box-shadow: var(--shadow-xs);
}
.events-filter-bar {
display: flex;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
overflow-x: auto;
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
scrollbar-width: none;
}
.events-filter-bar::-webkit-scrollbar { display: none; }
.events-filter-btn {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
border: 1.5px solid var(--c-border);
padding: var(--space-2) var(--space-4);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
}
.events-filter-btn.active {
background: var(--c-primary);
color: #fff;
border-color: var(--c-primary);
}
/* .events-filter-btn → now uses .by-tab */
.events-list {
flex: 1;
overflow-y: auto;
@ -2398,42 +2387,12 @@ textarea.form-control {
}
/* Quelle-Filter-Leiste */
.events-source-bar {
display: flex;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
overflow-x: auto;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
scrollbar-width: none;
}
.events-source-bar::-webkit-scrollbar { display: none; }
.events-source-btn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
border: 1.5px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.events-source-btn.active,
.events-source-btn:hover {
border-color: var(--c-primary);
background: var(--c-primary-soft, #e8f0fe);
color: var(--c-primary);
}
.events-source-vdh.active,
.events-source-vdh:hover {
border-color: #1a4fa0;
background: #e8eef8;
color: #1a4fa0;
padding: var(--space-2) var(--space-4);
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
}
/* .events-source-btn → now uses .by-tab */
/* Events-Karten: Aktions-Zeile */
.events-card-actions {
margin-top: var(--space-1);
@ -2465,28 +2424,19 @@ textarea.form-control {
/* ------------------------------------------------------------
SITTING (sitting.js)
------------------------------------------------------------ */
.sitting-layout {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sitting-tabs {
display: flex;
padding: var(--space-3) var(--space-4) var(--space-2);
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
flex-shrink: 0;
}
.sitting-tab {
flex: 1;
padding: var(--space-3);
border: none;
background: transparent;
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.sitting-tab.active {
color: var(--c-primary);
border-bottom-color: var(--c-primary);
background: var(--c-surface);
flex-shrink: 0;
}
/* .sitting-tab → now uses .by-tab */
.sitting-content {
flex: 1;
overflow-y: auto;
@ -2566,12 +2516,7 @@ textarea.form-control {
gap: var(--space-4);
}
.sitting-profil-fact { font-size: var(--text-sm); color: var(--c-text-secondary); }
.sitting-section-label {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-text-secondary);
margin-bottom: var(--space-2);
}
/* .sitting-section-label → now uses .by-section-label */
.sitting-request-card {
background: var(--c-surface);
border-radius: var(--radius-lg);
@ -2707,23 +2652,7 @@ textarea.form-control {
flex-wrap: wrap;
}
.forum-tab {
background: var(--c-surface-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
color: var(--c-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.forum-tab:hover { background: var(--c-surface-3); }
.forum-tab.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
}
/* .forum-tab → now uses .by-tab */
.forum-list-inner { display: flex; flex-direction: column; gap: var(--space-3); }
@ -2853,16 +2782,10 @@ textarea.form-control {
align-items: center;
}
/* Category tabs — horizontal scroll */
/* Category tabs — extends .by-tabs with extra bottom padding */
.forum-category-tabs {
display: flex;
gap: var(--space-1);
overflow-x: auto;
scrollbar-width: none;
flex-wrap: nowrap;
padding-bottom: var(--space-1);
}
.forum-category-tabs::-webkit-scrollbar { display: none; }
/* Category badge (colored pill) */
.forum-category-badge {
@ -3229,39 +3152,7 @@ textarea.form-control {
WIKI
============================================================ */
/* Tab-Bar */
.wiki-tab-bar {
display: flex;
gap: var(--space-1);
overflow-x: auto;
padding: var(--space-3) 0 var(--space-2);
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.wiki-tab-bar::-webkit-scrollbar { display: none; }
.wiki-tab-btn {
flex-shrink: 0;
padding: var(--space-2) var(--space-3);
border: 1.5px solid var(--c-border);
border-radius: var(--radius-full);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
white-space: nowrap;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.wiki-tab-btn.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
}
.wiki-tab-btn:hover:not(.active) {
border-color: var(--c-primary);
color: var(--c-primary);
}
/* .wiki-tab-bar / .wiki-tab-btn → now use .by-tabs / .by-tab */
/* Search */
.wiki-search-wrap {
@ -4349,7 +4240,7 @@ textarea.form-control {
color: #fff;
font-size: var(--text-xs);
font-weight: var(--weight-bold);
border-radius: 99px;
border-radius: var(--radius-full);
padding: 1px 6px;
min-width: 18px;
text-align: center;

View file

@ -43,9 +43,13 @@
--c-success-subtle: #EBF4E7;
--c-warning: #D4923A;
--c-warning-subtle: #FDF3E3;
--c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */
--c-info: #4A7A9B;
--c-info-subtle: #E8F2F8;
/* Primär-Akzentfläche (für Hover-Hintergründe über Primary) */
--c-primary-soft: #FDF0E3;
/* Typografie */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "SF Mono", "Fira Code", Consolas, monospace;

View file

@ -129,6 +129,32 @@
height: 100%;
}
/* Gassi-Treffen + Sitting: volle Höhe, internes Scroll */
#page-walks,
#page-sitting {
height: 100%;
overflow: hidden;
}
#page-walks > .page-body,
#page-sitting > .page-body {
padding: 0 !important;
gap: 0 !important;
overflow: hidden;
height: 100%;
}
/* Routen: volle Höhe damit .rk-layout height:100% auflöst und
das Grid intern scrollt (nicht die gesamte Seite via #page-content) */
#page-routes {
height: 100%;
overflow: hidden;
}
#page-routes > .page-body {
padding: 0 !important;
overflow: hidden;
height: 100%;
}
/* ------------------------------------------------------------
3. BOTTOM NAVIGATION (Mobile)
------------------------------------------------------------ */

View file

@ -22,8 +22,8 @@
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/layout.css?v=65">
<link rel="stylesheet" href="/css/components.css?v=65">
<link rel="stylesheet" href="/css/layout.css?v=79">
<link rel="stylesheet" href="/css/components.css?v=79">
</head>
<body>
@ -33,7 +33,7 @@
<nav id="sidebar" role="navigation" aria-label="Hauptnavigation">
<div class="sidebar-logo" id="sidebar-dog-switcher">
<img class="sidebar-logo-img" src="/icons/icon-180.png" alt="Ban Yaro">
<span class="sidebar-logo-text">Ban Yaro</span>
<span class="sidebar-logo-text" style="cursor:pointer" title="Über Ban Yaro">Ban Yaro</span>
</div>
<div class="sidebar-add">
@ -88,6 +88,14 @@
<span class="sidebar-item-badge" id="lost-badge" style="display:none">0</span>
</div>
<span class="sidebar-section-label">Training</span>
<div class="sidebar-item" data-page="uebungen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg> Übungen
</div>
<div class="sidebar-item" data-page="trainingsplaene">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Trainingspläne
</div>
<span class="sidebar-section-label">Wissen</span>
<div class="sidebar-item" data-page="wiki">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#books"></use></svg> Wiki
@ -98,6 +106,14 @@
<div class="sidebar-item" data-page="movies">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme
</div>
<div class="sidebar-item" data-page="erste-hilfe">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
</div>
<div class="sidebar-item" data-page="admin" id="sidebar-admin"
style="display:none;color:var(--c-danger,#ef4444)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg> Admin
</div>
<div class="sidebar-item sidebar-item--user" id="sidebar-user">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
@ -125,6 +141,10 @@
<main id="page-content" role="main">
<!-- Jede Seite ist ein <section class="page"> -->
<section class="page" id="page-welcome">
<div class="page-body page-container"></div>
</section>
<section class="page active" id="page-diary">
<div class="page-body page-container">
<!-- wird von diary.js befüllt -->
@ -179,6 +199,18 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-uebungen">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-trainingsplaene">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-erste-hilfe">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-lost">
<div class="page-body page-container"></div>
</section>
@ -187,6 +219,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-admin">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-friends">
<div class="page-body page-container"></div>
</section>
@ -233,9 +269,9 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=65"></script>
<script src="/js/ui.js?v=65"></script>
<script src="/js/app.js?v=65"></script>
<script src="/js/api.js?v=79"></script>
<script src="/js/ui.js?v=79"></script>
<script src="/js/app.js?v=79"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -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 = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
min-height:60vh;padding:var(--space-8) var(--space-5);text-align:center;gap:var(--space-5)">
<!-- Icon -->
<div style="width:72px;height:72px;border-radius:50%;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:36px;height:36px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(gate.icon)}"></use>
</svg>
</div>
<!-- Text -->
<div style="max-width:300px">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">
${_esc(title)}
</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
line-height:1.6;margin:0">
${_esc(gate.text)}
</p>
</div>
<!-- CTAs -->
<div style="display:flex;flex-direction:column;gap:var(--space-3);width:100%;max-width:280px">
<button class="btn btn-primary" id="gate-login-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Anmelden
</button>
<button class="btn btn-secondary" id="gate-register-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Kostenlos registrieren
</button>
</div>
<!-- Hinweis was sonst frei ist -->
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
Karte, Wiki, Übungen, Erste Hilfe und mehr sind ohne Account zugänglich.
</p>
<!-- Install-Hinweis -->
<button id="gate-install-hint"
style="background:none;border:none;cursor:pointer;padding:0;
font-size:var(--text-xs);color:var(--c-primary);
display:flex;align-items:center;gap:4px;text-decoration:underline">
<svg style="width:13px;height:13px" aria-hidden="true">
<use href="/icons/phosphor.svg#download-simple"></use>
</svg>
Ban Yaro als App installieren
</button>
</div>
`;
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
? `<button class="btn ${cls} w-full" data-quick="${quick}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${icon}"></use></svg> ${label}
</button>`
: `<button class="btn ${cls} w-full" style="opacity:0.5" data-quick="auth-${quick}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#lock"></use></svg> ${label}
</button>`;
UI.modal.open({
title: 'Was möchtest du hinzufügen?',
body: `
<div class="flex flex-col gap-3">
<button class="btn btn-secondary w-full" data-quick="diary">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch-Eintrag
</button>
<button class="btn btn-secondary w-full" data-quick="health">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg> Gesundheits-Eintrag
</button>
${authBtn('diary', 'btn-secondary', 'book-open', 'Tagebuch-Eintrag')}
${authBtn('health', 'btn-secondary', 'syringe', 'Gesundheits-Eintrag')}
<button class="btn btn-danger w-full" data-quick="poison">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
</button>
<button class="btn btn-nature w-full" data-quick="walk">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen erstellen
</button>
${authBtn('walk', 'btn-nature', 'paw-print', 'Gassi-Treffen erstellen')}
</div>
${!loggedIn ? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-top:var(--space-3)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
Einige Funktionen erfordern einen Account.
</p>` : ''}
`,
});
@ -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 };
})();

View file

@ -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 = `
<div style="max-width:720px;margin:0 auto;padding:var(--space-4)">
<!-- Tabs -->
<div class="by-tabs" style="margin-bottom:var(--space-5)" id="adm-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<!-- Inhalt -->
<div id="adm-content"></div>
</div>
`;
_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 = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
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 = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:var(--space-3);
margin-bottom:var(--space-5)">
${_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)')}
</div>
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)">
<use href="/icons/phosphor.svg#info"></use>
</svg>
Ersten Admin per SQL setzen:
<code style="background:var(--c-surface-2);padding:2px 6px;border-radius:4px;font-size:var(--text-xs)">
UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de';
</code>
</p>
</div>
`;
}
function _statCard(icon, label, value, color) {
return `
<div class="card" style="padding:var(--space-4);text-align:center">
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div style="font-size:var(--text-2xl);font-weight:var(--weight-bold);color:var(--c-text)">
${value ?? '—'}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${label}</div>
</div>
`;
}
// ------------------------------------------------------------------
// TAB: NUTZER
// ------------------------------------------------------------------
async function _renderUsers(el) {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
<input id="adm-user-q" type="search" placeholder="Name oder E-Mail…"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
<select id="adm-user-rolle" style="padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;background:var(--c-surface);color:var(--c-text)">
<option value="">Alle Rollen</option>
<option value="user">user</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
</div>
<div id="adm-user-list">Lade</div>
`;
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 = `
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted)">
${total} Nutzer gefunden
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${users.map(u => `
<div class="card" style="padding:var(--space-3) var(--space-4);
${u.is_banned ? 'opacity:0.6;border-left:3px solid var(--c-danger)' : ''}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar -->
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary)">
${_esc(u.name[0].toUpperCase())}
</div>
<!-- Info -->
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(u.name)}
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;border-radius:3px;
background:var(--c-danger);color:#fff;margin-left:4px">
GESPERRT</span>` : ''}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(u.email)} ·
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
${_esc(u.rolle)}
</span>
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
</div>
</div>
<!-- Aktionen -->
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
${u.is_banned
? `<button class="btn btn-sm btn-ghost adm-unban" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperre aufheben" style="color:var(--c-success)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock-open"></use></svg>
</button>`
: `<button class="btn btn-sm btn-ghost adm-ban" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperren" style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock"></use></svg>
</button>`
}
${isAdmin ? `
<button class="btn btn-sm btn-ghost adm-rolle" data-uid="${u.id}"
data-name="${_esc(u.name)}" data-rolle="${_esc(u.rolle)}"
title="Rolle ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-delete" data-uid="${u.id}"
data-name="${_esc(u.name)}" title="Löschen"
style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
` : ''}
</div>
</div>
</div>
`).join('')}
</div>
`;
// 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: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Aktuelle Rolle: <strong>${currentRolle}</strong>
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${rollen.map(r => `
<button class="btn btn-secondary adm-rolle-choice" data-rolle="${r}" form="">
${r === 'admin' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg>` : ''}
${r === 'moderator' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>` : ''}
${r === 'user' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>` : ''}
Als <strong>${r}</strong> setzen
</button>
`).join('')}
</div>
`,
});
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 = `
<!-- Unternavigation -->
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
<button class="btn btn-primary btn-sm adm-forum-nav" data-view="reports" id="adm-fn-reports">
Offene Meldungen
</button>
<button class="btn btn-ghost btn-sm adm-forum-nav" data-view="threads" id="adm-fn-threads">
Alle Threads
</button>
</div>
<div id="adm-forum-content">Lade</div>
`;
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 = '<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>';
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 = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${reports.map(r => `
<div class="card" style="padding:var(--space-4);
${r.resolved ? 'opacity:0.5' : 'border-left:3px solid var(--c-danger)'}">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">
${r.resolved ? '✓ Erledigt · ' : ''}
${_esc(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${_esc(r.melder_name)}</strong>
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm)">
${_esc(r.content_preview)}
</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-sm ${r.resolved ? 'btn-ghost' : 'btn-primary'} adm-resolve-btn"
data-rid="${r.id}" data-resolved="${r.resolved}"
title="${r.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#${r.resolved ? 'arrow-square-out' : 'check'}"></use></svg>
</button>
${!r.resolved ? `
<button class="btn btn-sm btn-ghost adm-del-content"
data-type="${r.target_type}" data-id="${r.target_id}"
title="Inhalt löschen" style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>` : ''}
</div>
</div>
</div>
`).join('')}
</div>
`;
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 = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3)">
<input id="adm-thread-q" type="search" placeholder="Threads durchsuchen…"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
<label style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
<input type="checkbox" id="adm-show-deleted"> Gelöschte
</label>
</div>
<div id="adm-thread-list">Lade</div>
`;
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 = `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${threads.map(t => `
<div class="card" style="padding:var(--space-3) var(--space-4);
${t.is_deleted ? 'opacity:0.5;border-left:3px solid var(--c-danger)' : ''}
${t.is_locked ? 'border-left:3px solid #f59e0b' : ''}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${t.is_deleted ? '<s>' : ''}${_esc(t.titel)}${t.is_deleted ? '</s>' : ''}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${_esc(t.autor_name)} ·
${t.antworten} Antworten ·
${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''}
</div>
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
${!t.is_deleted ? `
<button class="btn btn-sm btn-ghost adm-pin" data-tid="${t.id}"
data-pinned="${t.is_pinned}" title="${t.is_pinned ? 'Entpinnen' : 'Anpinnen'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#push-pin"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-lock" data-tid="${t.id}"
data-locked="${t.is_locked}" title="${t.is_locked ? 'Entsperren' : 'Sperren'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#${t.is_locked ? 'lock-open' : 'lock'}"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-del-thread" data-tid="${t.id}"
title="Löschen" style="color:var(--c-danger)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
` : `
<button class="btn btn-sm btn-ghost adm-restore-thread" data-tid="${t.id}"
title="Wiederherstellen" style="color:var(--c-success)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg>
</button>
`}
</div>
</div>
</div>
`).join('')}
</div>
`;
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: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">${msg}</p>
<input id="adm-prompt-input" type="text"
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
`,
footer: `
<button class="btn btn-primary" id="adm-prompt-ok" form="">OK</button>
<button class="btn btn-ghost" id="adm-prompt-cancel" form="">Abbrechen</button>
`,
});
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 `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<p style="font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">${title}</p>
${text ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">${text}</p>` : ''}
</div>
`;
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -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: '<p style="margin-top:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary)"><strong>Häufige Giftquellen:</strong> Rattengift, Schneckenkorn (Metaldehyd), Ibuprofen, Paracetamol, Schokolade, Weintrauben/Rosinen, Zwiebeln, Xylit (Kaugummi, Erdnussbutter), präparierte Köder.</p>',
},
{
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: '<div style="margin-top:var(--space-3);padding:var(--space-3);background:var(--c-surface-2);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--c-text)"><strong>Herzdruckmassage:</strong> Hund auf die rechte Seite, Hände auf breiteste Stelle des Brustkorbs hinter dem Ellenbogen, 100120 Kompressionen/min, ca. 1/3 eindrücken. Bei kleinen Hunden: eine Hand oder zwei Finger.<br><br><strong>Beatmung:</strong> Nach je 30 Kompressionen 2 Atemzüge — Maul schließen, durch die Nase blasen bis der Brustkorb sich hebt.<br><br>Weiterführen bis: Hund selbst atmet, Tierarzt übernimmt oder nach 10 Min. ohne Reaktion.</div>',
},
{
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: '<p style="margin-top:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary)"><strong>Übertragende Krankheiten (DE):</strong> Borreliose (häufig), FSME (selten), Babesiose (Süddeutschland, zunehmend), Anaplasmose.</p>',
},
{
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: '<p style="margin-top:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary)"><strong>Zum Tierarzt wenn:</strong> Wunde klafft, Blutung nicht stoppt, tiefer Einstich, oder Hund nach 24 h noch nicht belastet.</p>',
},
{
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: '<p style="margin-top:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary)"><strong>Heimlich-Manöver:</strong> 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.</p>',
},
{
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: '<p style="margin-top:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary)"><strong>Sofort zum Tierarzt:</strong> erster Anfall überhaupt, Dauer > 5 Min, mehrere Anfälle in 24 h, Hund kommt nach 30 Min nicht zu sich.</p>',
},
{
titel: 'Verbrennung / Verbrühung',
icon: 'fire',
symptome: [],
massnahmen: ['Betroffene Stelle 1015 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: `<div style="overflow-x:auto;margin-top:var(--space-2)">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead><tr style="background:var(--c-surface-2)">
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Medikament</th>
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Wirkung beim Hund</th>
</tr></thead>
<tbody>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Ibuprofen</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Magenblutungen, Nierenversagen schon 1 Tablette gefährlich</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Paracetamol</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Leberschäden, tödlich</td></tr>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Aspirin</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Magenblutungen</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Diclofenac</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Nieren- und Magenprobleme</td></tr>
<tr><td style="padding:var(--space-2) var(--space-3)">Antidepressiva</td><td style="padding:var(--space-2) var(--space-3)">Krämpfe, Herzprobleme</td></tr>
</tbody>
</table></div>`,
},
{
titel: 'Giftige Pflanzen (Auswahl)',
icon: 'plant',
symptome: [],
massnahmen: [],
extra: `<div style="overflow-x:auto;margin-top:var(--space-2)">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead><tr style="background:var(--c-surface-2)">
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Pflanze</th>
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Giftigkeit</th>
</tr></thead>
<tbody>
${[['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) => `<tr${i%2?' style="background:var(--c-surface-2)"':''}><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">${r[0]}</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">${r[1]}</td></tr>`).join('')}
</tbody>
</table></div>`,
},
{
titel: 'Schleimhäute prüfen',
icon: 'stethoscope',
symptome: [],
massnahmen: [],
extra: `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">Zahnfleisch anheben, Finger andrücken, loslassen — Farbe muss binnen 2 Sek. zurückkehren (kapilläre Füllungszeit).</p>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead><tr style="background:var(--c-surface-2)">
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Farbe</th>
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Bedeutung</th>
</tr></thead>
<tbody>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Rosa, feucht</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:#22c55e;font-weight:var(--weight-semibold)">Normal</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blass / weiß</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger)">Schock, Blutverlust, Vergiftung</td></tr>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blau / grau</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger);font-weight:var(--weight-semibold)">Sauerstoffmangel NOTFALL</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Gelb</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-warning)">Leberprobleme</td></tr>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Ziegelrot</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger)">Hitzschlag, Vergiftung</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3)">Trocken</td><td style="padding:var(--space-2) var(--space-3);color:var(--c-warning)">Austrocknung</td></tr>
</tbody>
</table></div>`,
},
{
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 = `
<div id="eh-wrap" style="padding-bottom:var(--space-8)">
${_renderNotfallbanner()}
${_renderSchnell()}
<div style="margin:var(--space-6) 0 var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap" id="eh-tabs">
${KATEGORIEN.map(k => `
<button class="btn eh-tab-btn" data-tab="${k.id}"
style="border:2px solid ${k.color};padding:var(--space-2) var(--space-4);border-radius:var(--radius-pill);font-size:var(--text-sm);font-weight:var(--weight-semibold);color:${k.color};background:transparent;cursor:pointer">
<svg class="ph-icon" aria-hidden="true" style="color:${k.color}"><use href="/icons/phosphor.svg#${k.icon}"></use></svg>
${k.label}
</button>
`).join('')}
</div>
${KATEGORIEN.map(k => `
<div class="eh-tab-panel" id="eh-panel-${k.id}" style="display:none">
${k.eintraege.map((e, i) => _renderEintrag(e, k.id, i, k.color)).join('')}
</div>
`).join('')}
<div style="margin-top:var(--space-6);padding:var(--space-4);background:var(--c-surface-2);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#info"></use></svg>
Diese Inhalte ersetzen keinen Tierarztbesuch. Im Zweifel immer sofort zum Tierarzt oder den tierärztlichen Notdienst anrufen.
</div>
</div>
`;
_bindTabs();
_bindAccordions();
_activateTab('lebensgefahr');
}
function _renderNotfallbanner() {
const nums = NOTFALLNUMMERN.map(n => `
<a href="tel:${n.tel}"
style="display:flex;align-items:center;gap:var(--space-2);color:#fff;text-decoration:none;font-size:var(--text-sm);padding:var(--space-2) var(--space-3);background:rgba(255,255,255,0.15);border-radius:var(--radius-md)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg>
<span><strong>${n.label}</strong><br>${n.display}</span>
</a>
`).join('');
return `
<div style="background:var(--c-danger);border-radius:var(--radius-lg);padding:var(--space-4);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);color:#fff;font-weight:var(--weight-bold);font-size:var(--text-base);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg>
Tiergiftzentralen jetzt anrufen
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${nums}
</div>
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)">
Tierärztlicher Notdienst: Über die Tierarztsuche in der Banyaro-Karte
</p>
</div>
`;
}
function _renderSchnell() {
const rows = SCHNELL.map((s, i) => `
<tr style="${i % 2 ? 'background:var(--c-surface-2)' : ''}">
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm);border-bottom:1px solid var(--c-border)">${s.notfall}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm);border-bottom:1px solid var(--c-border)">${s.massnahme}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm);border-bottom:1px solid var(--c-border);font-weight:var(--weight-semibold);color:${s.tierarzt === 'Sofort' ? 'var(--c-danger)' : 'var(--c-warning)'}">${s.tierarzt}</td>
</tr>
`).join('');
return `
<div class="card" style="padding:0;overflow:hidden;margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);background:var(--c-surface-2);font-weight:var(--weight-semibold);font-size:var(--text-sm);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#list-bullets"></use></svg>
Schnellübersicht: Was tun bei
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="background:var(--c-surface-2)">
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:var(--weight-semibold)">Notfall</th>
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:var(--weight-semibold)">Sofortmaßnahme</th>
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:var(--weight-semibold)">Tierarzt</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
`;
}
function _renderEintrag(e, katId, idx, katColor) {
const accId = `eh-acc-${katId}-${idx}`;
const bodyId = `eh-body-${katId}-${idx}`;
const symptomeHtml = e.symptome.length
? `<p style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text-secondary);margin:0 0 var(--space-2)">Symptome</p>
<ul style="margin:0 0 var(--space-3);padding-left:var(--space-5);font-size:var(--text-sm);color:var(--c-text);line-height:1.6">
${e.symptome.map(s => `<li>${s}</li>`).join('')}
</ul>`
: '';
const massnahmenHtml = e.massnahmen.length
? `<p style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text-secondary);margin:0 0 var(--space-2)">Sofortmaßnahmen</p>
<ol style="margin:0 0 var(--space-3);padding-left:var(--space-5);font-size:var(--text-sm);color:var(--c-text);line-height:1.6">
${e.massnahmen.map(m => `<li>${m}</li>`).join('')}
</ol>`
: '';
const warnHtml = (e.warn || []).map(w => `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);margin-bottom:var(--space-2);font-size:var(--text-sm);line-height:1.5;
background:${w.typ === 'danger' ? 'rgba(239,68,68,0.1)' : 'rgba(245,158,11,0.1)'};
color:${w.typ === 'danger' ? 'var(--c-danger)' : 'var(--c-warning)'};
border-left:3px solid ${w.typ === 'danger' ? 'var(--c-danger)' : 'var(--c-warning)'}">
<svg class="ph-icon" aria-hidden="true" style="vertical-align:middle;margin-right:4px"><use href="/icons/phosphor.svg#${w.typ === 'danger' ? 'prohibit' : 'warning-circle'}"></use></svg>
${w.text}
</div>
`).join('');
return `
<div id="${accId}" style="border-bottom:1px solid var(--c-border)">
<button data-acc-id="${bodyId}" data-acc-arrow="arr-${katId}-${idx}"
style="width:100%;display:flex;align-items:center;justify-content:space-between;padding:var(--space-4);background:none;border:none;cursor:pointer;text-align:left;gap:var(--space-3)"
aria-expanded="false">
<span style="display:flex;align-items:center;gap:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:${katColor};flex-shrink:0"><use href="/icons/phosphor.svg#${e.icon}"></use></svg>
<strong style="font-size:var(--text-base);color:var(--c-text)">${e.titel}</strong>
</span>
<svg class="ph-icon" id="arr-${katId}-${idx}" aria-hidden="true" style="flex-shrink:0;color:var(--c-text-secondary);transition:transform 0.2s"><use href="/icons/phosphor.svg#caret-down"></use></svg>
</button>
<div id="${bodyId}" hidden style="padding:0 var(--space-4) var(--space-4)">
${symptomeHtml}
${massnahmenHtml}
${warnHtml}
${e.extra || ''}
</div>
</div>
`;
}
// ----------------------------------------------------------------
// 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 };
})();

View file

@ -65,7 +65,7 @@ window.Page_events = (() => {
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="events-toolbar">
<div class="by-toolbar">
<div class="events-view-toggle">
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
@ -74,20 +74,20 @@ window.Page_events = (() => {
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div>
<div class="events-filter-bar" id="ev-filter-bar">
<div class="events-filter-bar by-tabs" id="ev-filter-bar">
${TYPEN.map(t => `
<button class="events-filter-btn ${t.id === 'alle' ? 'active' : ''}" data-ev-typ="${t.id}">
<button class="by-tab ${t.id === 'alle' ? 'active' : ''}" data-ev-typ="${t.id}">
${t.icon} ${t.label}
</button>
`).join('')}
</div>
<div class="events-source-bar" id="ev-source-bar">
<button class="events-source-btn active" data-ev-quelle="alle">Alle Quellen</button>
<button class="events-source-btn events-source-vdh" data-ev-quelle="vdh">
<div class="events-source-bar by-tabs" id="ev-source-bar">
<button class="by-tab active" data-ev-quelle="alle">Alle Quellen</button>
<button class="by-tab" data-ev-quelle="vdh">
<span class="ev-vdh-badge">VDH</span> VDH-Events
</button>
<button class="events-source-btn" data-ev-quelle="nutzer">Von Nutzern</button>
<button class="by-tab" data-ev-quelle="nutzer">Von Nutzern</button>
</div>
<div class="events-list" id="ev-list"></div>
@ -352,7 +352,7 @@ window.Page_events = (() => {
<input class="form-control" type="number" step="any" name="lon" id="ev-lon" placeholder="11.5678" value="${ev?.lon || ''}">
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="ev-gps-btn">📍 GPS-Position</button>
<button type="button" class="btn btn-secondary btn-sm" id="ev-gps-btn">${_icon('map-pin')} GPS-Position</button>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>

View file

@ -83,17 +83,18 @@ window.Page_forum = (() => {
<h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions">
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''}
<button class="btn btn-ghost btn-sm" id="forum-rules-btn" title="Regeln & Netiquette" style="color:var(--c-text-muted)">${UI.icon('info')} Regeln</button>
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
</div>
</div>
<!-- Kategorie-Tabs -->
<div class="forum-category-tabs" id="forum-tabs">
<div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<button class="forum-tab ${k.key === _aktivKat ? 'active' : ''}"
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}">${_esc(k.label)}</button>
`).join('')}
<button class="forum-tab ${_activeSection === 'map' ? 'active' : ''}"
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
</div>
@ -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: `
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-3)">
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.
</p>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('chat-circle-dots')} Ton & Umgang
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Freundlich und respektvoll bleiben auch bei verschiedenen Meinungen</li>
<li>${UI.icon('check')} Konstruktive Kritik statt persönliche Angriffe</li>
<li>${UI.icon('check')} Andere Haltungsmethoden tolerieren solange der Hund nicht leidet</li>
<li>${UI.icon('prohibit')} Keine Beleidigungen, Drohungen oder Diskriminierung</li>
</ul>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('files')} Inhalte
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Thema passend zur gewählten Kategorie</li>
<li>${UI.icon('check')} Aussagekräftiger Titel kein "Hilfe!!!" ohne Kontext</li>
<li>${UI.icon('prohibit')} Keine Werbung, Spam oder Affiliate-Links</li>
<li>${UI.icon('prohibit')} Keine Doppelposts bestehenden Thread suchen bevor du neu erstellst</li>
</ul>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('syringe')} Gesundheit & Notfälle
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Erfahrungen teilen ist wertvoll bitte immer den Tierarzt empfehlen</li>
<li>${UI.icon('prohibit')} Keine Diagnosen oder Medikamentendosierungen für fremde Hunde</li>
<li>${UI.icon('warning-circle')} Bei Notfällen: direkt zum Tierarzt nicht erst im Forum fragen</li>
</ul>
</div>
<div>
<div style="font-weight:var(--weight-semibold);margin-bottom:var(--space-2);display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('flag')} Moderation
</div>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
<li>${UI.icon('check')} Regelverstoß? Melde-Funktion nutzen statt selbst zu reagieren</li>
<li>${UI.icon('check')} Moderatoren können Beiträge bearbeiten, verstecken oder löschen</li>
<li>${UI.icon('check')} Bei Unklarheiten freundlich nachfragen</li>
</ul>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);border-top:1px solid var(--c-border-light);padding-top:var(--space-3);margin:0">
Wer wiederholt gegen die Regeln verstößt, kann vorübergehend gesperrt werden.
Das Ziel ist ein freundliches Forum nicht Kontrolle um der Kontrolle willen. 🐾
</p>
</div>`,
footer: `<button class="btn btn-primary flex-1" onclick="UI.modal.close()">Verstanden</button>`,
});
}
// ----------------------------------------------------------
function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
@ -687,7 +763,12 @@ window.Page_forum = (() => {
</div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div>
</form>`;
</form>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
${UI.icon('info')} Bitte die
<button type="button" style="background:none;border:none;padding:0;color:var(--c-primary);cursor:pointer;font-size:inherit;text-decoration:underline" id="ff-rules-link">Regeln & Netiquette</button>
beachten.
</p>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="ff-cancel">Abbrechen</button>
@ -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 => {

View file

@ -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 = `
<div style="padding:var(--space-4)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin-bottom:var(--space-4)">
Freunde
</h2>
<div style="max-width:520px;margin:0 auto;padding:var(--space-4)">
<!-- Mein Freundes-Link -->
<div class="card" style="margin-bottom:var(--space-5);padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#link"></use>
</svg>
</div>
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">Dein Freundes-Link</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Teile ihn der andere tippt drauf und findet dich sofort.
</div>
</div>
</div>
<div style="display:flex;gap:var(--space-2)">
<div style="flex:1;padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
banyaro.app/#friends?suche=${_esc(encodeURIComponent(myName))}
</div>
<button class="btn btn-ghost btn-sm" id="fr-copy-btn" title="Link kopieren">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
</button>
<button class="btn btn-primary btn-sm" id="fr-share-btn" title="Link teilen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
Teilen
</button>
</div>
</div>
<!-- Suche -->
<div class="friends-search-row">
<input id="fr-search" class="form-input" type="search"
placeholder="Namen suchen…" autocomplete="off" style="flex:1">
<div style="position:relative;margin-bottom:var(--space-2)">
<svg class="ph-icon" style="position:absolute;left:var(--space-3);top:50%;
transform:translateY(-50%);color:var(--c-text-muted);pointer-events:none"
aria-hidden="true">
<use href="/icons/phosphor.svg#magnifying-glass"></use>
</svg>
<input id="fr-search" type="search" autocomplete="off"
placeholder="Namen eines Hundebesitzers suchen…"
value="${_esc(prefill || '')}"
style="width:100%;box-sizing:border-box;
padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);
margin:0 0 var(--space-4) var(--space-1)">
Tipp: Lass dir den Freundes-Link einer anderen Person schicken dann klappt die Suche automatisch.
</p>
<div id="fr-search-results"></div>
<!-- Incoming requests -->
<!-- Eingehende Anfragen -->
<div id="fr-incoming"></div>
<!-- Outgoing -->
<!-- Ausstehende Anfragen -->
<div id="fr-outgoing"></div>
<!-- Friends list -->
<!-- Freundesliste -->
<div id="fr-list"></div>
</div>
`;
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 =
`<div class="empty-state"><p>Bitte melde dich an, um Freunde zu verwalten.</p></div>`;
}
}
_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 = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
Anfragen (${list.length})
</h3>
${list.map(r => `
<div class="friend-request-card">
<div class="friend-avatar">${_initial(r.requester_name)}</div>
<div style="flex:1">
<div style="font-weight:var(--weight-semibold)">${_esc(r.requester_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">möchte mit dir befreundet sein</div>
<div style="margin-bottom:var(--space-5)">
<div class="by-section-label">Anfragen · ${list.length}</div>
${list.map(r => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-3)">
${_userAvatar(r.requester_name, r.dogs?.[0])}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(r.requester_name)}
</div>
${_dogPills(r.dogs, 2)}
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-primary btn-sm"
onclick="Page_friends._accept(${r.id})" title="Annehmen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._decline(${r.id})" title="Ablehnen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
</div>
<div class="friend-item-actions">
<button class="btn btn-primary btn-sm"
onclick="Page_friends._accept(${r.id})">
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._decline(${r.id})">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
`).join('')}
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// 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 = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
Gesendete Anfragen
</h3>
${list.map(r => `
<div class="friend-item">
<div class="friend-avatar">${_initial(r.addressee_name)}</div>
<div class="friend-item-name">${_esc(r.addressee_name)}</div>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">ausstehend</span>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})"
title="Anfrage zurückziehen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
`).join('')}
<div style="margin-bottom:var(--space-5)">
<div class="by-section-label">Gesendet</div>
${list.map(r => `
<div class="card" style="padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="width:36px;height:36px;border-radius:50%;
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary);
font-size:var(--text-sm);flex-shrink:0">
${_esc((r.addressee_name || '?')[0].toUpperCase())}
</div>
<div style="flex:1">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(r.addressee_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Anfrage ausstehend</div>
</div>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// FREUNDESLISTE
// ----------------------------------------------------------
function _renderFriends(list) {
const el = document.getElementById('fr-list');
const el = _container.querySelector('#fr-list');
if (!list.length) {
el.innerHTML = `
<div class="empty-state" style="margin-top:var(--space-6)">
<svg class="ph-icon" style="font-size:3rem;opacity:0.3"><use href="/icons/phosphor.svg#users"></use></svg>
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
Noch keine Freunde. Suche oben nach Nutzern!
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
<p style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-2)">Noch keine Hundefreunde</p>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Suche oben nach anderen Hundebesitzern und schick ihnen eine Anfrage.
</p>
</div>
`;
return;
}
el.innerHTML = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
Freunde (${list.length})
</h3>
${list.map(f => `
<div class="friend-item">
<div class="friend-avatar">${_initial(f.friend_name)}</div>
<div class="friend-item-name">${_esc(f.friend_name)}</div>
<div class="friend-item-actions">
<div>
<div class="by-section-label">Freunde · ${list.length}</div>
${list.map(f => _friendCard(f)).join('')}
</div>
`;
// 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 `
<div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer;
transition:box-shadow 0.15s"
data-friend-id="${f.friend_id}"
data-friend-name="${_esc(f.friend_name)}"
data-dogs="${_esc(JSON.stringify(dogs))}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar (erstes Hunde-Foto oder Initiale) -->
${_userAvatar(f.friend_name, dogs[0])}
<!-- Name + Hunde -->
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
margin-bottom:var(--space-1)">
${_esc(f.friend_name)}
</div>
${dogs.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
${_dogPills(dogs, 3)}
</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch kein Hund eingetragen</div>`
}
</div>
<!-- Aktionen -->
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._openChat(${f.friend_id})"
title="Nachricht senden">
title="Nachricht schreiben">
<svg class="ph-icon"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._removeFriend(${f.friend_id}, '${_esc(f.friend_name)}')"
title="Freund entfernen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-minus"></use></svg>
</button>
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-border);
align-self:center;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</div>
</div>
`).join('')}
<!-- Hunde-Foto-Zeile wenn mehrere Hunde Fotos haben -->
${_dogPhotoRow(dogs)}
</div>
`;
}
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 `
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);
padding-top:var(--space-3);border-top:1px solid var(--c-border)">
${withPhotos.slice(0, 4).map(d => `
<div style="text-align:center">
<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-surface)">
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px;
max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(d.name)}
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// MINI-PROFIL MODAL
// ----------------------------------------------------------
function _showProfile(friendId, friendName, dogs) {
const dogsHTML = dogs.length
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
gap:var(--space-3);margin-top:var(--space-4)">
${dogs.map(d => `
<div style="text-align:center">
${d.foto_url
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);margin-bottom:var(--space-2)">`
: `<div style="width:72px;height:72px;border-radius:50%;
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-size:1.75rem;margin:0 auto var(--space-2)">🐕</div>`
}
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(d.name)}</div>
${d.rasse
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>`
: ''}
</div>
`).join('')}
</div>`
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-4)">
Noch kein Hund eingetragen.
</p>`;
UI.modal.open({
title: _esc(friendName),
body: `
<div>
<div class="by-section-label">${dogs.length === 1 ? 'Hund' : 'Hunde'}</div>
${dogsHTML}
</div>
`,
footer: `
<button class="btn btn-primary" id="modal-chat-btn" form="">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
Nachricht schreiben
</button>
<button class="btn btn-ghost" id="modal-remove-btn" form=""
style="color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-minus"></use></svg>
Entfernen
</button>
`,
});
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 = `<div class="friends-search-results">
<div style="padding:var(--space-3) var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">
Keine Nutzer gefunden.
</div>
</div>`;
el.innerHTML = `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4);
text-align:center;color:var(--c-text-muted);
font-size:var(--text-sm)">
Kein Nutzer gefunden.
</div>`;
return;
}
el.innerHTML = `
<div class="friends-search-results">
${results.map(u => `
<div class="friend-result-item">
<div class="friend-avatar">${_initial(u.name)}</div>
<div style="flex:1;font-size:var(--text-sm)">${_esc(u.name)}</div>
<button class="btn btn-primary btn-sm"
onclick="Page_friends._sendRequest(${u.id}, this)">
<div class="card" style="margin-bottom:var(--space-4);overflow:hidden">
${results.map((u, i) => `
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3) var(--space-4);
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
${_userAvatar(u.name, null)}
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(u.name)}</div>
${u.dogs?.length
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join(' &nbsp;|&nbsp; ')}
</div>`
: ''}
</div>
<button class="btn btn-primary btn-sm fr-add-btn"
data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Anfrage
</button>
@ -195,77 +450,110 @@ window.Page_friends = (() => {
`).join('')}
</div>
`;
} 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 = `<svg class="ph-icon"><use href="/icons/phosphor.svg#spinner"></use></svg>`;
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 = `<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg> 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 `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
return `
<div style="width:44px;height:44px;border-radius:50%;flex-shrink:0;
background:var(--c-primary-subtle);
border:2px solid var(--c-primary);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-primary)">
${_esc((name || '?')[0].toUpperCase())}
</div>`;
}
function _dogPills(dogs, max) {
if (!dogs?.length) return '';
const visible = dogs.slice(0, max);
const rest = dogs.length - max;
return `
<div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center">
${visible.map(d => `
<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full);
background:var(--c-surface-2);color:var(--c-text-secondary)">
🐕 ${_esc(d.name)}${d.rasse ? ` · ${_esc(d.rasse)}` : ''}
</span>
`).join('')}
${rest > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">+${rest}</span>` : ''}
</div>
`;
}
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 };
})();

View file

@ -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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
@ -22,6 +22,16 @@ window.Page_health = (() => {
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
];
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
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 = `
<div class="health-header">
<div class="by-toolbar health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
${UI.icon('star')} KI-Zusammenfassung
</button>
</div>
<div id="health-reminders"></div>
<div class="health-tabs" id="health-tabs"></div>
<div id="health-tab-content"></div>
<div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div>
`;
_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: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
laeufigkeit: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>',
};
el.innerHTML = `
@ -258,17 +269,17 @@ window.Page_health = (() => {
}
function _renderTabBar() {
const tabsEl = _container.querySelector('#health-tabs');
tabsEl.innerHTML = TABS.map(t => `
<button class="health-tab${t.key === _activeTab ? ' active' : ''}"
const tabsEl = _container.querySelector('#by-tabs');
tabsEl.innerHTML = _getTabs().map(t => `
<button class="by-tab${t.key === _activeTab ? ' active' : ''}"
data-tab="${t.key}">
${t.icon} ${t.label}
</button>
`).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 = `<button class="btn btn-primary btn-sm" data-action="add-entry">${UI.icon('plus')} Läufigkeit eintragen</button>`;
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 = `
<div style="margin:var(--space-4) var(--space-4) 0;padding:var(--space-3) var(--space-4);
background:var(--c-surface);border-radius:var(--radius-lg);
border-left:4px solid ${ampel.color === 'red' ? 'var(--c-danger)' : ampel.color === 'yellow' ? 'var(--c-warning)' : 'var(--c-primary)'};
display:flex;align-items:center;gap:var(--space-3)">
<span style="font-size:1.5rem">${UI.icon('gender-female')}</span>
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Nächste Läufigkeit erwartet</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${label}
${avgInterval ? ` · Ø ${avgInterval} Tage Abstand` : ''}
</div>
</div>
</div>`;
}
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 `
<div class="health-card" data-id="${e.id}" data-action="open-entry">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
display:flex;align-items:center;justify-content:center;
flex-shrink:0;color:var(--c-text-inverse)">
${UI.icon('gender-female')}
</div>
<div class="health-card-body">
<div class="health-card-title">Läufigkeit · ${UI.time.format(e.datum + 'T00:00:00')}</div>
<div class="health-card-meta">
${e.wert ? `Dauer: ${e.wert} Tage` : 'Dauer nicht angegeben'}
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
</div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
</div>
</div>`;
}).join('');
return `
${banner}
<div class="health-list" style="margin-top:var(--space-4)">${items}</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
}
// ----------------------------------------------------------
// MEDIKAMENTE
// ----------------------------------------------------------
@ -542,7 +647,7 @@ window.Page_health = (() => {
const inaktive = entries.filter(e => !e.aktiv);
const renderGroup = (items, label) => items.length ? `
<div class="health-group-label">${label}</div>
<div class="by-section-label">${label}</div>
${items.map(e => `
<div class="health-card${e.aktiv ? '' : ' health-card--inactive'}" data-id="${e.id}" data-action="open-entry">
<div class="health-card-body">
@ -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' ? `
<div class="form-group">
<label class="form-label">Bezeichnung *</label>
<input class="form-control" type="text" name="bezeichnung"
@ -761,7 +869,7 @@ window.Page_health = (() => {
placeholder="${_formPlaceholder(t)}">
</div>` : ''}
<div class="form-group">
<label class="form-label">Datum *</label>
<label class="form-label">Start *</label>
<input class="form-control" type="date" name="datum"
value="${entry?.datum || today}" required>
</div>
@ -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 = (() => {
<textarea class="form-control" name="reaktion" rows="2">${_esc(entry?.reaktion || '')}</textarea>
</div>
`;
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 `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Dauer (Tage)</label>
<input class="form-control" type="number" min="1" max="60" name="wert"
value="${entry?.wert ?? ''}" placeholder="z.B. 21"
id="laeufi-dauer">
</div>
<div class="form-group">
<label class="form-label">Zyklusabstand (Tage)</label>
<input class="form-control" type="number" min="60" max="400" name="intervall_tage"
value="${entry?.intervall_tage || defaultInterval}"
id="laeufi-interval">
</div>
</div>
<div class="form-group">
<label class="form-label">Nächste erwartet</label>
<input class="form-control" type="date" name="naechstes"
value="${entry?.naechstes || ''}" id="laeufi-naechstes">
</div>
<script>
(function() {
const datum = document.querySelector('[name="datum"]');
const interval = document.getElementById('laeufi-interval');
const naechstes = document.getElementById('laeufi-naechstes');
function updateNext() {
const d = datum?.value;
const iv = parseInt(interval?.value) || 0;
if (d && iv) {
const next = new Date(d);
next.setDate(next.getDate() + iv);
naechstes.value = next.toISOString().slice(0, 10);
}
}
datum?.addEventListener('change', updateNext);
interval?.addEventListener('change', updateNext);
if (!naechstes?.value) updateNext();
})();
</script>
`;
}
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 = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning,#f59e0b)">
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning)">
<p style="margin:0;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg> Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.
</p>
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger,#ef4444)">
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger)">
<p style="margin:0;font-size:var(--text-sm)">
KI-Server nicht erreichbar. Bitte später versuchen.
</p>

View file

@ -275,8 +275,8 @@ window.Page_knigge = (() => {
}).join('');
const badge = isCorrect
? `<span style="color:var(--c-success,#22c55e);font-weight:var(--weight-semibold)">${UI.icon('check')} Richtig!</span>`
: `<span style="color:var(--c-danger,#ef4444);font-weight:var(--weight-semibold)">${UI.icon('x')} Nicht ganz — </span>`;
? `<span style="color:var(--c-success);font-weight:var(--weight-semibold)">${UI.icon('check')} Richtig!</span>`
: `<span style="color:var(--c-danger);font-weight:var(--weight-semibold)">${UI.icon('x')} Nicht ganz — </span>`;
resEl.innerHTML = `
<div style="margin-bottom:var(--space-4)">${bars}</div>

View file

@ -68,6 +68,21 @@ window.Page_poison = (() => {
© OpenStreetMap-Mitwirkende
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<a href="tel:110" class="btn btn-secondary btn-sm" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('phone')} <strong>110</strong> Polizei
</a>
<a href="tel:+498919240" class="btn btn-secondary btn-sm" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('first-aid')} <strong>089 19240</strong> Tiergift München
</a>
<a href="tel:+493019240" class="btn btn-secondary btn-sm" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('first-aid')} <strong>030 19240</strong> Tiergift Berlin
</a>
<a href="tel:+4314064343" class="btn btn-secondary btn-sm" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('first-aid')} <strong>01 4064343</strong> Tiergift Wien
</a>
</div>
<p id="poison-info"
style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-3)">

View file

@ -174,14 +174,16 @@ window.Page_routes = (() => {
sec.className = 'rk-map-section';
sec.innerHTML = `
<div class="rk-map-bar">
<button class="btn btn-secondary btn-sm" id="rk-map-back" title="Zurück zur Liste">${UI.icon('arrow-left')}</button>
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
placeholder="🔍 Ort suchen…" autocomplete="off">
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">📍</button>
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">${UI.icon('map-pin')}</button>
</div>
<div id="rk-search-map" style="height:${mapH}px;width:100%"></div>
<div id="rk-search-map" style="flex:1;width:100%"></div>
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
`;
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 = (() => {
<h3 class="rk-empty-title">Deine erste Gassi-Route</h3>
<p class="rk-empty-text">Zeichne deine Lieblingsstrecken auf mit Streckendaten, Fotos und Hundetauglichkeit.</p>
<div class="rk-empty-features">
<div class="rk-empty-feature"><span>🗺</span><span>GPS-Aufzeichnung</span></div>
<div class="rk-empty-feature"><span>📷</span><span>Fotos entlang der Strecke</span></div>
<div class="rk-empty-feature">${UI.icon('map-trifold')}<span>GPS-Aufzeichnung</span></div>
<div class="rk-empty-feature">${UI.icon('camera')}<span>Fotos entlang der Strecke</span></div>
<div class="rk-empty-feature"><span>🐾</span><span>Hundetauglichkeit bewerten</span></div>
<div class="rk-empty-feature"><span></span><span>GPX-Download für Navi</span></div>
<div class="rk-empty-feature"><span>📍</span><span>Restaurants & Parkplätze</span></div>
<div class="rk-empty-feature"><span>🔒</span><span>Privat oder öffentlich</span></div>
<div class="rk-empty-feature">${UI.icon('download-simple')}<span>GPX-Download für Navi</span></div>
<div class="rk-empty-feature">${UI.icon('map-pin')}<span>Restaurants & Parkplätze</span></div>
<div class="rk-empty-feature">${UI.icon('lock')}<span>Privat oder öffentlich</span></div>
</div>
<button class="btn btn-primary btn-lg" id="rk-empty-rec">🔴 Erste Route aufzeichnen</button>
<button class="btn btn-primary btn-lg" id="rk-empty-rec">${UI.icon('path')} Erste Route aufzeichnen</button>
</div>`;
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 ? '<span class="rk-badge rk-badge--private">🔒 Privat</span>' : '';
const privBadge = !r.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : '';
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 = (() => {
<div class="rk-card-body">
<div class="rk-card-name">${_esc(r.name)}</div>
<div class="rk-card-stats">
${dist ? `<span>🗺️ ${dist}</span>` : ''}
${dur ? `<span> ${dur}</span>` : ''}
${dist ? `<span>${UI.icon('map-trifold')} ${dist}</span>` : ''}
${dur ? `<span>${UI.icon('timer')} ${dur}</span>` : ''}
${terrain ? `<span>${terrain}</span>` : ''}
${paws ? `<span title="Hundetauglichkeit">${paws}</span>` : ''}
</div>
<div class="rk-card-tags">
${privBadge}
${diffLabel ? `<span class="rk-badge rk-badge--${r.schwierigkeit}">${diffLabel}</span>` : ''}
${r.schatten ? '<span class="rk-badge">🌳 Schatten</span>' : ''}
${r.leine_empfohlen ? '<span class="rk-badge">🔗 Leine</span>' : ''}
${r.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
${r.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine</span>` : ''}
</div>
<div class="rk-card-footer">
<div class="rk-stars">${_starsHTML(r.id, r.bewertung||0, r.anz_bewertungen||0)}</div>
<div class="rk-card-actions">
<span class="rk-card-author">${_esc(r.user_name||'Anonym')}</span>
<button class="rk-dl-btn" data-id="${r.id}"> GPX</button>
<button class="rk-dl-btn" data-id="${r.id}">${UI.icon('download-simple')} GPX</button>
</div>
</div>
</div>
@ -579,7 +581,7 @@ window.Page_routes = (() => {
</label>` : ''}
</div>` :
isOwn ? `<label class="rk-photo-add-empty">
📷 Foto hinzufügen
${UI.icon('camera')} Foto hinzufügen
<input type="file" id="rk-photo-input" accept="image/*" style="display:none">
</label>` : '';
@ -588,14 +590,14 @@ window.Page_routes = (() => {
margin-bottom:var(--space-3);background:var(--c-surface-2)"></div>
${photoGallery}
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin:var(--space-3) 0">
${route.distanz_km ? `<span class="rk-badge rk-badge--info">🗺️ ${route.distanz_km.toFixed(2)} km</span>` : ''}
${route.dauer_min ? `<span class="rk-badge rk-badge--info"> ${_fmtDur(route.dauer_min)}</span>` : ''}
${route.distanz_km ? `<span class="rk-badge rk-badge--info">${UI.icon('map-trifold')} ${route.distanz_km.toFixed(2)} km</span>` : ''}
${route.dauer_min ? `<span class="rk-badge rk-badge--info">${UI.icon('timer')} ${_fmtDur(route.dauer_min)}</span>` : ''}
${route.schwierigkeit ? `<span class="rk-badge rk-badge--${route.schwierigkeit}">${DIFFICULTY_LABEL[route.schwierigkeit]||route.schwierigkeit}</span>` : ''}
${route.untergrund ? `<span class="rk-badge">${TERRAIN_LABEL[route.untergrund]||route.untergrund}</span>` : ''}
${paws ? `<span class="rk-badge rk-badge--dog" title="Hundetauglichkeit">${paws}</span>` : ''}
${route.schatten ? '<span class="rk-badge">🌳 Schatten</span>' : ''}
${route.leine_empfohlen ? '<span class="rk-badge">🔗 Leine empfohlen</span>' : ''}
${!route.is_public ? '<span class="rk-badge rk-badge--private">🔒 Privat</span>' : ''}
${route.schatten ? `<span class="rk-badge">${UI.icon('tree')} Schatten</span>` : ''}
${route.leine_empfohlen ? `<span class="rk-badge">${UI.icon('link')} Leine empfohlen</span>` : ''}
${!route.is_public ? `<span class="rk-badge rk-badge--private">${UI.icon('lock')} Privat</span>` : ''}
</div>
${route.beschreibung ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">${_esc(route.beschreibung)}</p>` : ''}
<div id="rk-nearby" class="rk-nearby-section">
@ -603,16 +605,16 @@ window.Page_routes = (() => {
</div>
<p style="color:var(--c-text-muted);font-size:0.75rem;margin-top:var(--space-2)">
${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})` : ''}
</p>
`;
const footer = `
<button type="button" class="btn btn-secondary" id="rd-gpx"> GPX</button>
<button type="button" class="btn btn-secondary" id="rd-gpx">${UI.icon('download-simple')} GPX</button>
${isOwn ? `<button type="button" class="btn btn-ghost" id="rd-vis" title="${route.is_public?'Privat machen':'Öffentlich machen'}">
${route.is_public?'🔒 Privat':'🌍 Öffentlich'}
${route.is_public ? UI.icon('lock')+' Privat' : UI.icon('globe')+' Öffentlich'}
</button>
<button type="button" class="btn btn-ghost" id="rd-del" style="color:var(--c-danger)">🗑</button>` : ''}
<button type="button" class="btn btn-ghost" id="rd-del" style="color:var(--c-danger)">${UI.icon('trash')}</button>` : ''}
<button type="button" class="btn btn-primary flex-1" id="rd-close">Schließen</button>
`;
@ -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 = `
<div class="rk-nearby-title">📍 Entlang der Route</div>
<div class="rk-nearby-title">${UI.icon('map-pin')} Entlang der Route</div>
${Object.values(byType).map(group => `
<div class="rk-nearby-group">
<div class="rk-nearby-group-label">${group.icon} ${_esc(group.label)} (${group.items.length})</div>
${group.items.slice(0, 5).map(p => `
<div class="rk-nearby-item">
<span class="rk-nearby-name">${_esc(p.name || group.label)}</span>
${p.opening_hours ? `<span class="rk-nearby-detail">🕐 ${_esc(p.opening_hours)}</span>` : ''}
${p.phone ? `<a href="tel:${_esc(p.phone)}" class="rk-nearby-detail rk-nearby-phone">📞 ${_esc(p.phone)}</a>` : ''}
${p.opening_hours ? `<span class="rk-nearby-detail">${UI.icon('clock')} ${_esc(p.opening_hours)}</span>` : ''}
${p.phone ? `<a href="tel:${_esc(p.phone)}" class="rk-nearby-detail rk-nearby-phone">${UI.icon('phone')} ${_esc(p.phone)}</a>` : ''}
</div>
`).join('')}
${group.items.length > 5 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 0">+${group.items.length-5} weitere</div>` : ''}
@ -977,9 +979,9 @@ window.Page_routes = (() => {
const body = `
<div class="rk-import-preview">${preview}</div>
<div class="rk-import-stats">
<span>📍 ${track.length} Punkte</span>
<span>🗺 ${distanz_km.toFixed(2)} km</span>
${dauer_min ? `<span> ${_fmtDur(dauer_min)}</span>` : ''}
<span>${UI.icon('map-pin')} ${track.length} Punkte</span>
<span>${UI.icon('map-trifold')} ${distanz_km.toFixed(2)} km</span>
${dauer_min ? `<span>${UI.icon('timer')} ${_fmtDur(dauer_min)}</span>` : ''}
<span class="rk-badge rk-badge--info">${source}</span>
</div>
<form id="rk-import-form" style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-4)">
@ -1036,7 +1038,7 @@ window.Page_routes = (() => {
const footer = `
<button type="button" class="btn btn-ghost" id="ri-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="ri-save">💾 Route speichern</button>
<button type="button" class="btn btn-primary flex-1" id="ri-save">${UI.icon('floppy-disk')} Route speichern</button>
`;
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';
}
});
}

View file

@ -114,6 +114,24 @@ window.Page_settings = (() => {
</div>
</div>
<!-- App installieren -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
App installieren
</div>
<div class="card-body" style="padding:0">
<div class="sidebar-item" id="settings-install-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
<span>Installations-Anleitung</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
</div>
</div>
<div style="text-align:center;color:var(--c-text-secondary);
font-size:var(--text-xs)">
Ban Yaro · banyaro.app<br>
@ -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();

View file

@ -43,14 +43,16 @@ window.Page_sitting = (() => {
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="sitting-tabs" id="sit-tabs">
<button class="sitting-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
${_state.user ? `
<button class="sitting-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
<button class="sitting-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
` : ''}
<div class="sitting-layout">
<div class="sitting-tabs by-tabs" id="sit-tabs">
<button class="by-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
${_state.user ? `
<button class="by-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
<button class="by-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
` : ''}
</div>
<div id="sit-content" class="sitting-content"></div>
</div>
<div id="sit-content" class="sitting-content"></div>
`;
_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 = (() => {
<div style="font-size:3rem">${UI.icon('paw-print')}</div>
<h3>Werde Hundesitter</h3>
<p>Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.</p>
<button class="btn btn-primary" id="sit-create-profil-btn">Profil erstellen</button>
<button class="btn btn-primary" id="sit-create-profil-btn">${UI.icon('plus')} Profil erstellen</button>
</div>
`;
return;
@ -149,7 +151,7 @@ window.Page_sitting = (() => {
<div class="sitting-my-profil">
<div class="sitting-profil-header">
<div class="sitting-profil-status ${s.aktiv ? 'active' : 'inactive'}">
${s.aktiv ? `${UI.icon('check')} Aktiv` : 'Pausiert'}
${s.aktiv ? `${UI.icon('check')} Aktiv` : `${UI.icon('pause')} Pausiert`}
</div>
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn">${UI.icon('pencil-simple')} Bearbeiten</button>
</div>
@ -172,31 +174,36 @@ window.Page_sitting = (() => {
let html = '';
if (inbox.length) {
html += `<div class="sitting-section-label">${UI.icon('bell')} Eingehende Anfragen (als Sitter)</div>`;
html += `<div class="by-section-label">${UI.icon('bell')} Eingehende Anfragen (als Sitter)</div>`;
html += inbox.map(r => _requestCardHTML(r, 'inbox')).join('');
}
if (myReqs.length) {
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">${UI.icon('upload')} Meine Anfragen</div>`;
html += `<div class="by-section-label" style="margin-top:var(--space-4)">${UI.icon('upload')} Meine Anfragen</div>`;
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 `
<div class="sitting-request-card" data-sit-req="${r.id}" data-sit-req-mode="${mode}">
<div class="sitting-req-header">
<span class="sitting-req-name">${UI.escHtml(name || '?')}</span>
<span class="sitting-req-status" style="color:${color}">${r.status}</span>
<span class="sitting-req-status" style="color:${color}">${UI.icon(icon)} ${label}</span>
</div>
<div class="sitting-req-dates">${UI.icon('calendar-dots')} ${r.von} ${r.bis}</div>
${r.nachricht ? `<div class="sitting-req-msg">${UI.escHtml(r.nachricht)}</div>` : ''}
@ -295,7 +302,7 @@ window.Page_sitting = (() => {
`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" type="submit" form="${id}" id="sit-anfrage-submit">Anfrage senden</button>
<button class="btn btn-primary" type="submit" form="${id}" id="sit-anfrage-submit">${UI.icon('paper-plane-tilt')} Anfrage senden</button>
`;
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 = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" type="submit" form="${id}" id="sit-profil-submit">
${s ? 'Speichern' : 'Profil erstellen'}
${s ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Profil erstellen`}
</button>
`;
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`; }
}
});
}

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _icon(name) {
return `<svg class="ph-icon" aria-hidden="true" style="width:1em;height:1em;vertical-align:-0.15em"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
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 => `<th style="padding:6px 10px;background:var(--c-surface-2);font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap">${_esc(h)}</th>`).join('');
const trs = rows.map(row => {
const tds = row.map((cell, i) => `<td style="padding:6px 10px;font-size:var(--text-sm);color:var(--c-text);border-top:1px solid var(--c-border);${i === 0 ? 'white-space:nowrap;font-weight:var(--weight-semibold)' : ''}">${_esc(cell)}</td>`).join('');
return `<tr>${tds}</tr>`;
}).join('');
return `
<div style="overflow-x:auto;margin:var(--space-3) 0">
<table style="width:100%;border-collapse:collapse;min-width:400px">
<thead><tr>${ths}</tr></thead>
<tbody>${trs}</tbody>
</table>
</div>`;
}
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 `
<label style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;padding:var(--space-1) 0" class="tp-goal-label">
<input type="checkbox" data-lskey="${_esc(key)}" ${checked ? 'checked' : ''}
style="margin-top:2px;flex-shrink:0;accent-color:var(--c-primary);width:16px;height:16px">
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">${_esc(goal)}</span>
</label>`;
}).join('');
const progress = total > 0 ? Math.round((doneCount / total) * 100) : 0;
return `
<div class="tp-goals" data-plan="${_esc(planId)}" data-total="${total}">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">
${_icon('check-circle')} Lernziele
</span>
<span class="tp-progress-label" style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${doneCount} von ${total} erreicht
</span>
</div>
<div style="background:var(--c-surface-2);border-radius:4px;height:6px;overflow:hidden;margin-bottom:var(--space-3)">
<div class="tp-progress-bar" style="width:${progress}%;height:6px;background:var(--c-primary);border-radius:4px;transition:width 0.3s"></div>
</div>
${items}
</div>`;
}
function _renderAccordionPhase(id, title, content) {
return `
<div class="tp-acc" id="tp-acc-${_esc(id)}">
<button class="tp-acc-head" data-acc="${_esc(id)}"
style="width:100%;display:flex;justify-content:space-between;align-items:center;padding:var(--space-3) var(--space-4);background:none;border:none;border-top:1px solid var(--c-border);cursor:pointer;text-align:left">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">${title}</span>
<span class="tp-acc-arrow">${_icon('caret-down')}</span>
</button>
<div class="tp-acc-body" id="tp-acc-body-${_esc(id)}" hidden
style="padding:var(--space-3) var(--space-4) var(--space-4)">
${content}
</div>
</div>`;
}
function _renderHintBox(text) {
return `
<div style="background:var(--c-surface-2);border-left:3px solid var(--c-primary);border-radius:var(--radius-sm);padding:var(--space-3) var(--space-4);margin:var(--space-3) 0;font-size:var(--text-sm);color:var(--c-text);line-height:1.6">
${_icon('info')} ${_esc(text)}
</div>`;
}
// ----------------------------------------------------------
// PLAN SELECTOR
// ----------------------------------------------------------
function _renderPlanSelector() {
const plans = [
{ id: 'welpe', label: '🐶 Welpe', sub: '06 Monate' },
{ id: 'junior', label: '🐕 Junior', sub: '618 Monate' },
{ id: 'erwachsen',label: '🦮 Erwachsener Hund', sub: 'Grund- & Aufbaukurs' },
];
const btns = plans.map(p => `
<button class="by-tab${_activePlan === p.id ? ' active' : ''}" data-plan="${p.id}"
style="flex:1;min-width:90px;flex-direction:column;align-items:center;gap:4px">
<span style="font-size:1.1rem">${p.label}</span>
<span style="font-size:var(--text-xs);opacity:0.8">${_esc(p.sub)}</span>
</button>`).join('');
return `<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap">${btns}</div>`;
}
// ----------------------------------------------------------
// WELPENPLAN
// ----------------------------------------------------------
function _renderWelpe() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Welpe ist eingezogen, Grundvertrauen wird aufgebaut.<br>
<strong>Ziel am Ende:</strong> Sitz, Platz, Hier, Warte, Leine ohne Ziehen, Alleine bleiben bis 1 Stunde, keine Begrüßungssprünge.
</p>
${_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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Name, erste Bindung, Stubenreinheit, keine Übungen erzwingen.</p>
${_renderTable(
['Tag', 'Einheit 1', 'Einheit 2', 'Einheit 3'],
[['MoSo', 'Name lernen', 'Markerwort einführen', 'Freies Erkunden + Beobachten']]
)}
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6;padding-left:var(--space-4);margin:var(--space-2) 0">
<li><strong>Name lernen:</strong> Namen sagen Hund schaut sofort Markerwort + Leckerli. 1015× täglich.</li>
<li><strong>Markerwort einführen:</strong> Markerwort sagen sofort Leckerli (10× wiederholen). Ab jetzt: Markerwort immer vor dem Leckerli.</li>
<li><strong>Stubenreinheit:</strong> Alle 12 Stunden raus, nach Schlafen/Fressen/Spielen. Kein Schimpfen bei Unfällen.</li>
</ul>`;
const w34 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Sitz, Warte (Futter), Nicht springen.</p>
${_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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Platz, Leinengewöhnung, Hier auf kurze Distanz.</p>
${_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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Bleib (Dauer), Alleine bleiben aufbauen, Fuß vorbereiten.</p>
${_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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Alle Kommandos in Alltagssituationen, erste leichte Ablenkungen.</p>
${_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}
<div class="card" style="padding:0;overflow:hidden">
${_renderAccordionPhase('welpe-w12', 'Woche 12: Ankommen &amp; Vertrauen', w12)}
${_renderAccordionPhase('welpe-w34', 'Woche 34: Erste Kommandos', w34)}
${_renderAccordionPhase('welpe-w56', 'Woche 56: Platz &amp; erste Leine', w56)}
${_renderAccordionPhase('welpe-w78', 'Woche 78: Bleib &amp; Alleine bleiben', w78)}
${_renderAccordionPhase('welpe-w912', 'Woche 912: Festigung &amp; Alltagsintegration', w912)}
</div>`;
}
// ----------------------------------------------------------
// JUNIORPLAN
// ----------------------------------------------------------
function _renderJunior() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Grundkommandos bekannt, aber pubertätsbedingt unzuverlässig.<br>
<strong>Ziel am Ende:</strong> Alle Grundkommandos auch bei Ablenkung, zuverlässiger Rückruf, Leinenführigkeit in der Stadt.
</p>
${_renderHintBox('Die Pubertät (ca. 612 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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Alle bekannten Kommandos mit höherer Ablenkung neu festigen.</p>
${_renderTable(
['Tag', 'Einheit 1 (5 Min)', 'Einheit 2 (510 Min)'],
[
['Mo', 'Sitz + Platz mit Ablenkung (Ball auf dem Boden)', 'Leine üben in neuer Umgebung'],
['Di', 'Hier mit Schleppleine im Park', 'Bleib mit Distanz (35 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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Zuverlässiger Rückruf das wichtigste Kommando in dieser Phase.</p>
<ol style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-5);margin:var(--space-2) 0">
<li><strong>Rückruf mit Schleppleine (10 Meter):</strong> Hund beschäftigt sich "Hier!" leichtes Anziehen wenn nötig große Belohnung beim Ankommen</li>
<li><strong>Versteckspiel:</strong> Im Wald/Park verstecken "Hier!" rufen Hund sucht und findet größte Belohnung</li>
<li><strong>Rückruf aus der Gruppe:</strong> Hund spielt mit anderen Hunden "Hier!" kommt er: Jackpot (5 Leckerlis + Streicheln)</li>
<li><strong>Notfallrückruf einführen:</strong> Anderes Wort (z.B. "Rapid!") nur für echte Notfälle immer mit höchster Belohnung</li>
</ol>
${_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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Ruhiges Gehen auch bei Fahrrädern, anderen Hunden, Kinderwagen.</p>
<ol style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-5);margin:var(--space-2) 0">
<li>Ruhige Straße belohnen alle 10 Schritte bei locker hängender Leine</li>
<li>Fahrrad kommt entgegen Sitz warten Fahrrad vorbei weitergehen + Leckerli</li>
<li>Anderer Hund in Sichtweite Fokus auf dich (Augenkontakt trainieren) Leckerli</li>
<li>Belebte Fußgängerzone: erst beobachten lassen, dann durch</li>
</ol>
<p style="font-size:var(--text-sm);color:var(--c-text);margin:var(--space-2) 0"><strong>Augenkontakt:</strong> "Schau" sagen Hund schaut in deine Augen sofort Markerwort + Leckerli. In Ablenkungssituationen: "Schau" fokussiert dann erst weitergehen.</p>
${_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 = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)"><strong>Fokus:</strong> Festigung aller Kommandos, erste Aufbauübungen, Tricks für mentale Auslastung.</p>
${_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)',
'23 Tricks beherrscht',
'Rückruf vom Freilauf (Schleppleine)',
])}`;
return `
${intro}
<div class="card" style="padding:0;overflow:hidden">
${_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 46: Aufbau &amp; Tricks', m46)}
</div>`;
}
// ----------------------------------------------------------
// ERWACHSENER HUND
// ----------------------------------------------------------
function _renderErwachsenTabs() {
return `
<div class="by-tabs" style="margin-bottom:var(--space-4)">
<button class="by-tab${_activeAdultTab === 'grundkurs' ? ' active' : ''}" data-tab="grundkurs">Grundkurs</button>
<button class="by-tab${_activeAdultTab === 'aufbaukurs' ? ' active' : ''}" data-tab="aufbaukurs">Aufbaukurs</button>
<button class="by-tab${_activeAdultTab === 'uebersicht' ? ' active' : ''}" data-tab="uebersicht">Übersicht</button>
</div>`;
}
function _renderGrundkurs() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Keine oder wenige Vorkenntnisse.<br>
<strong>Ziel:</strong> Alle Grundkommandos in 8 Wochen.
</p>
${_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 = `
<p style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">Alle Kommandos in Alltagssituationen:</p>
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-4);margin:0 0 var(--space-3)">
<li>Sitz vor dem Überqueren der Straße</li>
<li>Platz wenn Besuch kommt</li>
<li>Warte vor der Haustür</li>
<li>Hier beim Freilauf im Garten</li>
<li>Fuß auf dem Bürgersteig</li>
<li>Aus bei gefundenem Gegenstand</li>
</ul>
${_renderGoals('erwachsen_gk', [
'Sitz, Platz, Bleib (30 Sek), Hier, Fuß, Aus, Warte',
'Alle Kommandos im Alltag einsetzbar',
'Leine ohne starkes Ziehen',
])}`;
return `
${intro}
<div class="card" style="padding:0;overflow:hidden">
${_renderAccordionPhase('gk-w12', 'Woche 12: Markerwort + Sitz + Warte', gw12)}
${_renderAccordionPhase('gk-w34', 'Woche 34: Platz + Hier + Leine', gw34)}
${_renderAccordionPhase('gk-w56', 'Woche 56: Bleib + Fuß + Aus', gw56)}
${_renderAccordionPhase('gk-w78', 'Woche 78: Festigung + Alltagsintegration', gw78)}
</div>`;
}
function _renderAufbaukurs() {
const intro = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-2)">
<strong>Voraussetzungen:</strong> Grundkurs abgeschlossen oder Kommandos bekannt.<br>
<strong>Dauer:</strong> 8 Wochen &nbsp;|&nbsp; <strong>Ziel:</strong> Kommandos bei Ablenkung, Distanzkommandos, Tricks, mentale Auslastung.
</p>`;
const aw12 = `
<p style="font-size:var(--text-sm);color:var(--c-text);margin-bottom:var(--space-2)">Alle bekannten Kommandos werden mit steigender Ablenkung trainiert. Immer bei der niedrigsten Stufe anfangen die noch klappt.</p>
${_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 = `
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-4);margin:0 0 var(--space-3)">
<li>Sitz aus 5 Metern 10 Metern</li>
<li>Platz aus 5 Metern 10 Metern</li>
<li>Bleib mit 10 Metern Distanz</li>
<li>Hier vom Freilauf (mit Schleppleine absichern)</li>
</ul>
<p style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6"><strong>Übung "Fernbedienung":</strong> Hund steht 5 Meter entfernt "Sitz" kurz warten "Platz" kurz warten "Sitz" "Hier". Wirkt beeindruckend, festigt Kommandos enorm.</p>`;
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 = `
<ul style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;padding-left:var(--space-4);margin:0 0 var(--space-3)">
<li>Schwächstes Kommando intensiv üben</li>
<li>Neue Umgebungen aufsuchen (anderer Wald, andere Stadt)</li>
<li>Hundesport ausprobieren: Agility, Mantrailing, Obedience, Trickdogging</li>
</ul>
${_renderGoals('erwachsen_ak', [
'Alle Kommandos bei Stufe 34 Ablenkung',
'Sitz + Platz auf 10 Meter Distanz',
'Hier vom Freilauf (Schleppleine)',
'34 Tricks',
'Nasenarbeit als regelmäßige Beschäftigung',
])}`;
return `
${intro}
<div class="card" style="padding:0;overflow:hidden">
${_renderAccordionPhase('ak-w12', 'Woche 12: Ablenkungstraining', aw12)}
${_renderAccordionPhase('ak-w34', 'Woche 34: Distanzkommandos', aw34)}
${_renderAccordionPhase('ak-w56', 'Woche 56: Tricks &amp; Nasenarbeit', aw56)}
${_renderAccordionPhase('ak-w78', 'Woche 78: Freie Gestaltung', aw78)}
</div>`;
}
function _renderUebersicht() {
return `
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);margin-bottom:var(--space-3);color:var(--c-text)">
${_icon('clipboard-text')} Welcher Plan für welchen Hund?
</h3>
${_renderTable(
['Situation', 'Empfohlener Plan'],
[
['Neuer Welpe (816 Wochen)', 'Welpenplan Woche 14'],
['Welpe 46 Monate', 'Welpenplan Woche 512'],
['Junghund 612 Monate (Pubertät)', 'Juniorplan Monat 13'],
['Junghund 1218 Monate', 'Juniorplan Monat 46'],
['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 = `
<p style="color:var(--c-text);line-height:1.6;margin-bottom:var(--space-3)">
Wähle deinen Kurs oder sieh dir die Schnellübersicht an.
</p>`;
let content = '';
if (_activeAdultTab === 'grundkurs') {
content = _renderGrundkurs();
} else if (_activeAdultTab === 'aufbaukurs') {
content = _renderAufbaukurs();
} else {
content = `<div class="card">${_renderUebersicht()}</div>`;
}
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 = `
<div style="padding-bottom:var(--space-8)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-4) 0 var(--space-4)">
${_icon('clipboard-text')} Trainingspläne
</h2>
${_renderPlanSelector()}
${planContent}
</div>`;
_bindEvents();
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
}
function refresh() {}
function onDogChange() {}
return { init, refresh, onDogChange };
})();

View file

@ -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ängerFortgeschr.', color: '#eab308' },
};
const GRUNDKOMMANDOS = [
{
name: 'Sitz',
schwierigkeit: 'Anfänger',
alter: 'Ab 8 Wochen',
dauer: '35 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 510x, 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: '35 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 1015 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 (23 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: '510 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: '35 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: '35 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: '35 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: '510 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: '510 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 12 Stunden allein gelassen werden. Erwachsene Hunde maximal 46 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 = `
<div id="ueb-wrap">
${_renderTabs()}
<div id="ueb-content"></div>
</div>
`;
_bindTabs();
_renderContent();
}
function _renderTabs() {
return `
<div class="by-tabs" style="padding:var(--space-4) var(--space-4) 0" id="ueb-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _activeTab ? ' active' : ''}"
data-tab="${t.id}">
${_esc(t.label)}
</button>
`).join('')}
</div>
`;
}
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 `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
${list.map((u, i) => _renderCard(u, i)).join('')}
</div>
`;
}
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 `
<div class="card" style="padding:0;overflow:hidden">
<!-- Header -->
<div style="padding:var(--space-4) var(--space-4) var(--space-3)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(u.name)}
</span>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<!-- Status-Button -->
<button class="ueb-status-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="${_esc(sm.label)}"
style="background:none;border:none;cursor:pointer;padding:2px;
display:flex;align-items:center;gap:4px;
font-size:var(--text-xs);color:${sm.color};
border-radius:var(--radius-sm)">
<svg class="ph-icon" style="width:18px;height:18px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#${sm.icon}"></use>
</svg>
${currentId ? `<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">${_esc(sm.label)}</span>` : ''}
</button>
<!-- Schwierigkeits-Badge -->
<span style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
padding:2px var(--space-2);border-radius:var(--radius-sm);
background:${diff.color}22;color:${diff.color}">
${_esc(diff.label)}
</span>
</div>
</div>
<!-- Meta -->
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:${u.beschreibung ? 'var(--space-3)' : '0'}">
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
${_esc(u.dauer)}
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
${_esc(u.alter)}
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:12px;height:12px" aria-hidden="true"><use href="/icons/phosphor.svg#package"></use></svg>
${_esc(u.material)}
</span>
</div>
${u.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5;margin:0">
${_esc(u.beschreibung)}
</p>
` : ''}
${u.hinweis ? `
<div style="margin-top:var(--space-2);padding:var(--space-2) var(--space-3);
background:var(--c-warning-bg,#fef3c7);border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text);line-height:1.4">
<svg class="ph-icon" style="width:12px;height:12px;color:#d97706" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
${_esc(u.hinweis)}
</div>
` : ''}
</div>
${hasBody ? `
<!-- Akkordeon Toggle -->
<button class="ueb-acc-btn"
data-acc="${uid}"
style="width:100%;display:flex;align-items:center;justify-content:space-between;
padding:var(--space-2) var(--space-4);
background:var(--c-surface-2);border:none;border-top:1px solid var(--c-border);
cursor:pointer;font-size:var(--text-sm);color:var(--c-text-secondary)">
<span>Anleitung anzeigen</span>
<svg class="ph-icon ueb-chevron" data-acc="${uid}" aria-hidden="true" style="width:16px;height:16px;transition:transform 0.2s">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</button>
<div id="${uid}" hidden style="padding:var(--space-4);border-top:1px solid var(--c-border)">
${u.schritte.length ? `
<p style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2);text-transform:uppercase;letter-spacing:0.05em">
Schritt für Schritt
</p>
<ol style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
${u.schritte.map(s => `
<li style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">${_esc(s)}</li>
`).join('')}
</ol>
` : ''}
${u.fehler.length ? `
<p style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2);text-transform:uppercase;letter-spacing:0.05em">
<svg class="ph-icon" style="width:12px;height:12px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
Häufige Fehler
</p>
<ul style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
${u.fehler.map(f => `
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${_esc(f)}</li>
`).join('')}
</ul>
` : ''}
${u.steigerung ? `
<div style="padding:var(--space-3);background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text);line-height:1.5">
<svg class="ph-icon" style="width:14px;height:14px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#arrow-right"></use>
</svg>
<strong>Steigerung:</strong> ${_esc(u.steigerung)}
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
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 = `
<svg class="ph-icon" style="width:18px;height:18px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#${sm.icon}"></use>
</svg>
${next ? `<span style="font-size:10px;font-weight:var(--weight-semibold);white-space:nowrap">${_esc(sm.label)}</span>` : ''}
`;
});
});
}
// ----------------------------------------------------------
// KI-TRAINER
// ----------------------------------------------------------
function _renderKiTrainer() {
return `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Intro -->
<div class="card" style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light)">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<svg class="ph-icon" style="width:24px;height:24px;flex-shrink:0;color:var(--c-primary);margin-top:2px" aria-hidden="true">
<use href="/icons/phosphor.svg#robot"></use>
</svg>
<div>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">
KI-Hundetrainer
</h3>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.5">
Beschreibe ein konkretes Problem oder Verhalten deines Hundes
du bekommst individuelle Trainingstipps.
</p>
</div>
</div>
</div>
<!-- Eingabe -->
<div class="card" style="padding:var(--space-4)">
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
Rasse &amp; Alter (optional)
</label>
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
<input id="ki-rasse" type="text" placeholder="z.B. Labrador"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
background:var(--c-surface);color:var(--c-text);font-family:inherit">
<input id="ki-alter" type="text" placeholder="z.B. 2 Jahre"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
background:var(--c-surface);color:var(--c-text);font-family:inherit">
</div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
Problem beschreiben *
</label>
<textarea id="ki-problem" rows="4"
placeholder="z.B. Mein Hund bellt bei jedem Klingeln an der Tür und lässt sich kaum beruhigen. Er springt Besucher an und ist sehr aufgedreht..."
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;resize:vertical;
background:var(--c-surface);color:var(--c-text);line-height:1.5;
min-height:100px"></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:var(--space-3)">
<span id="ki-char-count" style="font-size:var(--text-xs);color:var(--c-text-muted)">0 / 1000</span>
<button id="ki-submit" class="btn btn-primary">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
Tipps holen
</button>
</div>
</div>
<!-- Antwort -->
<div id="ki-result" hidden></div>
<!-- Hinweis -->
<p style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin:0">
KI-Tipps ersetzen keinen professionellen Hundetrainer. Bei Aggression oder starker Angst
wende dich an einen zertifizierten Trainer vor Ort.
</p>
</div>
`;
}
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 = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = safeText
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/^(\d+)\. (.+)$/gm, '<li><strong>$1.</strong> $2</li>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
result.innerHTML = `
<div class="card" style="border-left:3px solid var(--c-primary)">
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<svg class="ph-icon" style="color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#robot"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
Empfehlung des KI-Trainers
</span>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7">
<p>${html}</p>
</div>
</div>
`;
result.hidden = false;
} catch (err) {
UI.toast(err.message || 'KI momentan nicht verfügbar.', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg> 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 `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Markerwort -->
<div class="card">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#megaphone-simple"></use>
</svg>
Das Markerwort
</h3>
<p style="font-size:var(--text-sm);color:var(--c-text);line-height:1.6;margin:0 0 var(--space-3)">
Ein Markerwort (z.B. <strong>"Ja!"</strong> oder ein Klicker) signalisiert dem Hund den <strong>exakten Moment</strong>
des richtigen Verhaltens. Es überbrückt die Zeit bis das Leckerli in seinem Mund ist.
</p>
<ul style="margin:0;padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">
Einmalig einführen: Markerwort sagen sofort Leckerli (10x wiederholen)
</li>
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">
Immer nur ein Markerwort verwenden
</li>
<li style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">
Das Leckerli kommt <strong>immer</strong> nach dem Markerwort sonst verliert es seinen Wert
</li>
</ul>
</div>
<!-- Wann belohnen -->
<div class="card">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#star"></use>
</svg>
Wann belohnen?
</h3>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="background:var(--c-surface-2)">
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Phase</th>
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Belohnungshäufigkeit</th>
</tr>
</thead>
<tbody>
${[
['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) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text);border-bottom:1px solid var(--c-border)">${_esc(p)}</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">${_esc(b)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
<!-- Leckerli-Hierarchie -->
<div class="card">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#trophy"></use>
</svg>
Leckerli-Hierarchie
</h3>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5;margin:0 0 var(--space-3)">
Nicht alle Leckerlis sind gleich. Bei Ablenkung oder schwierigen Übungen brauchst du hochwertigere Belohnungen:
</p>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="background:var(--c-surface-2)">
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Stufe</th>
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold);
color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">Beispiele</th>
</tr>
</thead>
<tbody>
${[
['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) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">
<span style="font-weight:var(--weight-semibold);color:${c}">${_esc(s)}</span>
</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text-secondary);border-bottom:1px solid var(--c-border)">${_esc(b)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
<!-- Trainingsregeln -->
<div class="card" style="margin-bottom:var(--space-4)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
</svg>
Trainingsregeln auf einen Blick
</h3>
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:var(--space-2)">
${[
[true, 'Kurze Einheiten (510 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]) => `
<li style="display:flex;gap:var(--space-2);align-items:flex-start">
<svg class="ph-icon" style="width:16px;height:16px;flex-shrink:0;margin-top:2px;
color:${ok ? 'var(--c-success)' : 'var(--c-danger)'}" aria-hidden="true">
<use href="/icons/phosphor.svg#${ok ? 'check-circle' : 'x-circle'}"></use>
</svg>
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5">${_esc(text)}</span>
</li>
`).join('')}
</ul>
</div>
</div>
`;
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, onDogChange };
})();

View file

@ -63,7 +63,7 @@ window.Page_walks = (() => {
<div class="walks-layout">
<!-- Toolbar -->
<div class="walks-toolbar">
<div class="by-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
@ -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 += `<div class="walks-section-label">${UI.icon('star')} Heute</div>`;
html += `<div class="by-section-label">${UI.icon('star')} Heute</div>`;
html += heute.map(w => _walkCardHTML(w)).join('');
}
if (upcoming.length) {
html += `<div class="walks-section-label">${UI.icon('calendar-dots')} Demnächst</div>`;
html += `<div class="by-section-label">${UI.icon('calendar-dots')} Demnächst</div>`;
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: `<div style="background:${color};color:#fff;font-size:14px;font-weight:700;

View file

@ -0,0 +1,246 @@
/* ============================================================
BAN YARO Willkommensseite
Über die App, Features, Installations-Anleitung.
============================================================ */
window.Page_welcome = (() => {
let _container = null;
let _appState = null;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
}
function refresh() { _render(); }
function onDogChange() {}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
function _render() {
const isInstalled = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
_container.innerHTML = `
<div style="max-width:480px;margin:0 auto;padding:var(--space-6) var(--space-4) var(--space-8)">
<!-- Hero -->
<div style="text-align:center;margin-bottom:var(--space-8)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:88px;height:88px;border-radius:var(--radius-xl);
box-shadow:var(--shadow-md);margin-bottom:var(--space-4)">
<h1 style="font-size:var(--text-2xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">Ban Yaro</h1>
<p style="font-size:var(--text-base);color:var(--c-text-secondary);
margin:0;line-height:1.5">
Die Plattform für Hundebesitzer <br>Tagebuch, Gesundheit, Community und mehr.
</p>
</div>
<!-- Features -->
<div class="card" style="margin-bottom:var(--space-5)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
Was Ban Yaro kann
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0">
${[
['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) => `
<div style="display:flex;gap:var(--space-3);align-items:flex-start;
padding:var(--space-4);
${i % 2 === 0 ? 'border-right:1px solid var(--c-border);' : ''}
${i < 6 ? 'border-bottom:1px solid var(--c-border);' : ''}">
<div style="width:34px;height:34px;border-radius:var(--radius-md);
background:var(--c-primary-subtle);flex-shrink:0;
display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
</div>
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:2px">${title}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
line-height:1.4">${desc}</div>
</div>
</div>
`).join('')}
</div>
</div>
<!-- App installieren -->
<div class="card" style="margin-bottom:var(--space-5)" id="install-section">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
App installieren
</div>
<div style="padding:var(--space-4)">
${isInstalled
? `<div style="display:flex;gap:var(--space-3);align-items:center;
color:var(--c-success)">
<svg style="width:20px;height:20px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#check"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">
Ban Yaro ist bereits installiert.
</span>
</div>`
: _installHTML()
}
</div>
</div>
<!-- CTA wenn nicht eingeloggt -->
${!_appState.user ? `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-primary" id="welcome-register-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Kostenlos registrieren
</button>
<button class="btn btn-ghost" id="welcome-login-btn">
Bereits registriert? Anmelden
</button>
</div>
` : ''}
<!-- Footer -->
<p style="text-align:center;font-size:var(--text-xs);color:var(--c-text-muted);
margin-top:var(--space-6)">
Ban Yaro · Deine Daten auf eigenem Server in Deutschland.
</p>
</div>
`;
_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 `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">
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.
</p>
<button class="btn btn-primary" id="install-android-btn" style="width:100%">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
Ban Yaro installieren
</button>
`;
}
// iOS Safari
if (isIOS && isSafari) {
return `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-4);line-height:1.5">
Installiere Ban Yaro auf deinem iPhone oder iPad:
</p>
${_steps([
['share', 'Tippe auf das <strong>Teilen-Symbol</strong> in Safari (Rechteck mit Pfeil nach oben)'],
['plus', 'Scrolle nach unten und tippe auf <strong>„Zum Home-Bildschirm"</strong>'],
['check', 'Tippe rechts oben auf <strong>„Hinzufügen"</strong> — fertig!'],
])}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Funktioniert nur in Safari, nicht in anderen Browsern auf iOS.
</p>
`;
}
// Desktop oder andere Browser
return `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-4);line-height:1.5">
Ban Yaro lässt sich in Chrome, Edge und anderen modernen Browsern installieren:
</p>
${_steps([
['globe', 'Öffne Ban Yaro in <strong>Chrome</strong> oder <strong>Edge</strong>'],
['download-simple','Klicke in der Adressleiste auf das <strong>Installieren-Symbol</strong> (↓ mit Kreis)'],
['check', 'Bestätige die Installation — Ban Yaro öffnet sich dann wie eine Desktop-App'],
])}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
Auf Android: Menü () <strong>App installieren"</strong> oder
<strong>Zum Startbildschirm hinzufügen"</strong>.
</p>
`;
}
function _steps(list) {
return `
<ol style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:var(--space-3)">
${list.map(([icon, text], i) => `
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
<div style="width:28px;height:28px;border-radius:50%;flex-shrink:0;
background:var(--c-primary);color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:var(--text-xs);font-weight:var(--weight-bold)">
${i + 1}
</div>
<span style="font-size:var(--text-sm);color:var(--c-text);line-height:1.5;
padding-top:4px">${text}</span>
</li>
`).join('')}
</ol>
`;
}
// ----------------------------------------------------------
// 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 };
})();

View file

@ -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 = `
<div class="wiki-tab-bar" id="wiki-tab-bar">
<button class="wiki-tab-btn${_tab === 'rassen' ? ' active' : ''}" data-tab="rassen">${UI.icon('dog')} Rassen</button>
<button class="wiki-tab-btn${_tab === 'gesundheit'? ' active' : ''}" data-tab="gesundheit">${UI.icon('syringe')} Gesundheit</button>
<button class="wiki-tab-btn${_tab === 'recht' ? ' active' : ''}" data-tab="recht">${UI.icon('handshake')} Recht</button>
<button class="wiki-tab-btn${_tab === 'quiz' ? ' active' : ''}" data-tab="quiz">${UI.icon('star')} Quiz</button>
${isMod ? `<button class="wiki-tab-btn${_tab === 'fotos' ? ' active' : ''}" data-tab="fotos" id="wiki-fotos-tab">${UI.icon('camera')} Fotos <span id="wiki-fotos-badge" style="display:none" class="badge badge-sm">0</span></button>` : ''}
<div class="by-tabs" id="wiki-tab-bar">
<button class="by-tab${_tab === 'rassen' ? ' active' : ''}" data-tab="rassen">${UI.icon('dog')} Rassen</button>
<button class="by-tab${_tab === 'gesundheit'? ' active' : ''}" data-tab="gesundheit">${UI.icon('syringe')} Gesundheit</button>
<button class="by-tab${_tab === 'recht' ? ' active' : ''}" data-tab="recht">${UI.icon('handshake')} Recht</button>
<button class="by-tab${_tab === 'quiz' ? ' active' : ''}" data-tab="quiz">${UI.icon('star')} Quiz</button>
${isMod ? `<button class="by-tab${_tab === 'fotos' ? ' active' : ''}" data-tab="fotos" id="wiki-fotos-tab">${UI.icon('camera')} Fotos <span id="wiki-fotos-badge" style="display:none" class="badge badge-sm">0</span></button>` : ''}
</div>
<div id="wiki-content"></div>
`;
@ -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 = (() => {
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:4px">
Aktuelles Foto: <img src="${_esc(s.aktuell_foto)}" style="height:20px;vertical-align:middle;border-radius:2px">
</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-warning,#e8a000);margin-top:4px">Kein Foto vorhanden</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-warning);margin-top:4px">Kein Foto vorhanden</div>`
}
</div>
</div>
@ -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);
});
});

View file

@ -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