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:
parent
32d630d5a1
commit
b58789373c
30 changed files with 4344 additions and 523 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
})();
|
||||
|
||||
|
|
|
|||
588
backend/static/js/pages/admin.js
Normal file
588
backend/static/js/pages/admin.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
389
backend/static/js/pages/erste-hilfe.js
Normal file
389
backend/static/js/pages/erste-hilfe.js
Normal 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, 100–120 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 10–15 Min mit kühlem (nicht eiskaltem) Wasser kühlen','Kein Öl, keine Butter, keine Zahncreme — verstärken den Schaden','Leichte Rötung: kühlen, beobachten','Blasenbildung oder offene Wunden: sofort Tierarzt'],
|
||||
warn: [{ typ: 'warning', text: 'Heißer Asphalt: Handfläche 5 Sek. auf Boden — zu heiß für dich = zu heiß für Pfoten' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wissen',
|
||||
label: 'Nützliches Wissen',
|
||||
color: '#ca8a04',
|
||||
icon: 'book-open',
|
||||
eintraege: [
|
||||
{
|
||||
titel: 'Verbotene Medikamente für Hunde',
|
||||
icon: 'pill',
|
||||
symptome: [],
|
||||
massnahmen: [],
|
||||
extra: `<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 };
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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(' | ')}
|
||||
</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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
611
backend/static/js/pages/trainingsplaene.js
Normal file
611
backend/static/js/pages/trainingsplaene.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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: '0–6 Monate' },
|
||||
{ id: 'junior', label: '🐕 Junior', sub: '6–18 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'],
|
||||
[['Mo–So', '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. 10–15× 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 1–2 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 1–2: Ankommen & Vertrauen', w12)}
|
||||
${_renderAccordionPhase('welpe-w34', 'Woche 3–4: Erste Kommandos', w34)}
|
||||
${_renderAccordionPhase('welpe-w56', 'Woche 5–6: Platz & erste Leine', w56)}
|
||||
${_renderAccordionPhase('welpe-w78', 'Woche 7–8: Bleib & Alleine bleiben', w78)}
|
||||
${_renderAccordionPhase('welpe-w912', 'Woche 9–12: Festigung & Alltagsintegration', w912)}
|
||||
</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. 6–12 Monate) ist normal. Der Hund "vergisst" scheinbar alles — er testet Grenzen und Reize sind überwältigend. Konsequenz und Geduld, kein Rückschritt im Denken.')}`;
|
||||
|
||||
const m1 = `
|
||||
<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 (5–10 Min)'],
|
||||
[
|
||||
['Mo', 'Sitz + Platz mit Ablenkung (Ball auf dem Boden)', 'Leine üben in neuer Umgebung'],
|
||||
['Di', 'Hier mit Schleppleine im Park', 'Bleib mit Distanz (3–5 Meter)'],
|
||||
['Mi', 'Fuß auf belebtem Gehweg', 'Nasenarbeit / Trick'],
|
||||
['Do', 'Aus + Warte kombiniert', 'Alleine bleiben (bis 2 Stunden)'],
|
||||
['Fr', 'Alle Kommandos — kurze Runde', 'Freispiel'],
|
||||
['Sa', 'Ausflug + Training in neuer Umgebung', 'Sozialisation'],
|
||||
['So', 'Ruhiger Tag — leichte Einheit', 'Entspannen'],
|
||||
]
|
||||
)}
|
||||
${_renderGoals('junior_m1', [
|
||||
'Sitz + Platz trotz Ball/anderen Hunden in der Nähe',
|
||||
'Bleibt 1 Minute mit 5 Metern Distanz',
|
||||
'Kommt auf "Hier" im Park (mit Schleppleine)',
|
||||
'Fuß auf 50 Meter in ruhiger Straße',
|
||||
])}`;
|
||||
|
||||
const m2 = `
|
||||
<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)',
|
||||
'2–3 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 4–6: Aufbau & 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 1–2: Markerwort + Sitz + Warte', gw12)}
|
||||
${_renderAccordionPhase('gk-w34', 'Woche 3–4: Platz + Hier + Leine', gw34)}
|
||||
${_renderAccordionPhase('gk-w56', 'Woche 5–6: Bleib + Fuß + Aus', gw56)}
|
||||
${_renderAccordionPhase('gk-w78', 'Woche 7–8: Festigung + Alltagsintegration', gw78)}
|
||||
</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 | <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 3–4 Ablenkung',
|
||||
'Sitz + Platz auf 10 Meter Distanz',
|
||||
'Hier vom Freilauf (Schleppleine)',
|
||||
'3–4 Tricks',
|
||||
'Nasenarbeit als regelmäßige Beschäftigung',
|
||||
])}`;
|
||||
|
||||
return `
|
||||
${intro}
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
${_renderAccordionPhase('ak-w12', 'Woche 1–2: Ablenkungstraining', aw12)}
|
||||
${_renderAccordionPhase('ak-w34', 'Woche 3–4: Distanzkommandos', aw34)}
|
||||
${_renderAccordionPhase('ak-w56', 'Woche 5–6: Tricks & Nasenarbeit', aw56)}
|
||||
${_renderAccordionPhase('ak-w78', 'Woche 7–8: Freie Gestaltung', aw78)}
|
||||
</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 (8–16 Wochen)', 'Welpenplan Woche 1–4'],
|
||||
['Welpe 4–6 Monate', 'Welpenplan Woche 5–12'],
|
||||
['Junghund 6–12 Monate (Pubertät)', 'Juniorplan Monat 1–3'],
|
||||
['Junghund 12–18 Monate', 'Juniorplan Monat 4–6'],
|
||||
['Erwachsener Hund ohne Training', 'Grundkurs Erwachsener'],
|
||||
['Erwachsener Hund mit Grundwissen', 'Aufbaukurs Erwachsener'],
|
||||
['Neu eingezogener Hund (unbekannte Vorgeschichte)', 'Grundkurs Erwachsener, Tempo anpassen'],
|
||||
]
|
||||
)}`;
|
||||
}
|
||||
|
||||
function _renderErwachsen() {
|
||||
const intro = `
|
||||
<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 };
|
||||
|
||||
})();
|
||||
884
backend/static/js/pages/uebungen.js
Normal file
884
backend/static/js/pages/uebungen.js
Normal 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änger–Fortgeschr.', color: '#eab308' },
|
||||
};
|
||||
|
||||
const GRUNDKOMMANDOS = [
|
||||
{
|
||||
name: 'Sitz',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 8 Wochen',
|
||||
dauer: '3–5 Minuten',
|
||||
material: 'Leckerlis, ruhige Umgebung',
|
||||
beschreibung: 'Der Hund setzt sich auf ein Signal hin. Das ist meist das erste Kommando und bildet die Basis für viele weitere Übungen.',
|
||||
schritte: [
|
||||
'Halte ein Leckerli knapp vor die Nase deines Hundes.',
|
||||
'Führe das Leckerli langsam nach oben und leicht nach hinten über seinen Kopf.',
|
||||
'Der Hund folgt mit der Nase — sein Hinterteil senkt sich automatisch.',
|
||||
'Sobald er sitzt: sofort Markerwort ("Ja!" oder Klicker) + Leckerli.',
|
||||
'Wiederhole 5–10x, bevor du das Wort "Sitz" hinzufügst.',
|
||||
'Ab Wiederholung 10: Sage "Sitz" kurz bevor du die Handbewegung machst.',
|
||||
],
|
||||
fehler: [
|
||||
'Leckerli zu hoch halten → Hund springt hoch statt zu sitzen',
|
||||
'Kommando zu früh einführen → Hund lernt das Wort bevor er die Bewegung kennt',
|
||||
'Zu lange Einheiten → Hund wird unkonzentriert',
|
||||
],
|
||||
steigerung: 'Sitz mit Ablenkung → Sitz aus Bewegung → Sitz auf Distanz',
|
||||
},
|
||||
{
|
||||
name: 'Platz',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 10 Wochen',
|
||||
dauer: '3–5 Minuten',
|
||||
material: 'Leckerlis, ruhige Umgebung',
|
||||
beschreibung: 'Der Hund legt sich auf ein Signal hin. Wichtig für Ruhephasen, Wartesituationen und als Basis für "Bleib".',
|
||||
schritte: [
|
||||
'Beginne mit dem Hund im Sitz.',
|
||||
'Halte ein Leckerli vor seine Nase und führe es langsam senkrecht nach unten zwischen seine Vorderpfoten.',
|
||||
'Der Hund folgt mit der Nase und legt sich ab.',
|
||||
'Sobald Ellenbogen und Hinterteil den Boden berühren: Markerwort + Leckerli.',
|
||||
'Klappt es nicht: Leckerli unter ein angewinkeltes Knie halten — der Hund kriecht darunter durch und legt sich dabei ab.',
|
||||
'Wort "Platz" erst nach 10–15 erfolgreichen Wiederholungen einführen.',
|
||||
],
|
||||
fehler: [
|
||||
'Hund steht auf statt sich hinzulegen → Leckerli-Führung zu weit weg',
|
||||
'Hund liegt nur kurz → zu früh belohnen, Dauer schrittweise aufbauen',
|
||||
],
|
||||
steigerung: 'Platz mit Dauer → Platz mit Distanz → Platz bei Ablenkung',
|
||||
},
|
||||
{
|
||||
name: 'Bleib',
|
||||
schwierigkeit: 'Anfänger bis Fortgeschrittener',
|
||||
alter: 'Ab 12 Wochen',
|
||||
dauer: '5 Minuten',
|
||||
material: 'Leckerlis, Geduld',
|
||||
beschreibung: 'Der Hund hält eine Position (Sitz oder Platz) bis er freigegeben wird. Drei Dimensionen: Dauer, Distanz, Ablenkung — immer nur eine auf einmal steigern.',
|
||||
schritte: [
|
||||
'Hund ins Sitz oder Platz bringen.',
|
||||
'Einen Moment warten (2 Sekunden) → Markerwort + Leckerli.',
|
||||
'Freigabewort einführen: "Okay" oder "Frei" — danach darf der Hund aufstehen.',
|
||||
'Dauer schrittweise erhöhen: 2 → 5 → 10 → 30 Sekunden.',
|
||||
'Erst wenn Dauer stabil ist: einen kleinen Schritt zurücktreten.',
|
||||
'Zurückkehren zum Hund, belohnen — nicht den Hund zu dir kommen lassen.',
|
||||
],
|
||||
fehler: [
|
||||
'Zu schnell Distanz aufbauen → Hund bricht ab',
|
||||
'Hund wird zur Person gelobt statt an Ort → Hund kommt herangelaufen',
|
||||
'Freigabewort vergessen → Hund weiß nicht wann er aufstehen darf',
|
||||
],
|
||||
steigerung: 'Bleib 1 Min → Bleib mit Sichtkontaktverlust → Bleib bei Ablenkung (Ball rollen, andere Person)',
|
||||
},
|
||||
{
|
||||
name: 'Hier / Komm',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 8 Wochen',
|
||||
dauer: '5 Minuten',
|
||||
material: 'Leckerlis oder Spielzeug, Schleppleine empfohlen',
|
||||
beschreibung: 'Der Hund kommt zuverlässig zurück wenn er gerufen wird. Eines der wichtigsten Kommandos — im Zweifel lebensrettend.',
|
||||
schritte: [
|
||||
'Beginne in der Wohnung auf kurze Distanz (2–3 Meter).',
|
||||
'Knie dich hin, öffne die Arme, freudige Stimme: "Hier!" oder "Komm!"',
|
||||
'Sobald der Hund ankommt: große Freude, Leckerli, Streicheln.',
|
||||
'Niemals rufen und dann etwas Unangenehmes tun (Bad, Krallen schneiden).',
|
||||
'Im Garten: Schleppleine verwenden — Hund kann nicht wegbleiben, Erfolg ist garantiert.',
|
||||
'Ruf nur einmal — wer mehrfach ruft trainiert den Hund aufs Ignorieren.',
|
||||
],
|
||||
fehler: [
|
||||
'Hund rufen wenn er sicher nicht kommt → schlechte Gewohnheit',
|
||||
'Hund bestrafen nach dem Kommen → nächstes Mal kommt er nicht',
|
||||
'Monotone Stimme → Hund motiviert sich nicht',
|
||||
],
|
||||
steigerung: 'Kurze Distanz innen → Garten mit Schleppleine → Park mit wenig Ablenkung → Freilauf',
|
||||
},
|
||||
{
|
||||
name: 'Fuß',
|
||||
schwierigkeit: 'Fortgeschrittener Anfänger',
|
||||
alter: 'Ab 12 Wochen',
|
||||
dauer: '5–10 Minuten',
|
||||
material: 'Leckerlis, Leine',
|
||||
beschreibung: 'Der Hund läuft ruhig an der Leine neben dir, ohne zu ziehen. Die Leine hängt locker durch.',
|
||||
schritte: [
|
||||
'Hund an deine linke Seite stellen (klassisch), Leckerli in der linken Hand.',
|
||||
'Einen Schritt vorwärts gehen, Leckerli auf Höhe deiner linken Hüfte halten.',
|
||||
'Hund folgt dem Leckerli → Markerwort + belohnen.',
|
||||
'Schrittweise mehr Schritte, immer wieder belohnen wenn Leine locker ist.',
|
||||
'Zieht der Hund: stehen bleiben oder Richtung wechseln — nie mitziehen lassen.',
|
||||
'Wort "Fuß" einführen sobald der Hund die Position versteht.',
|
||||
],
|
||||
fehler: [
|
||||
'Leine straff halten → Hund lernt Zug als Normalzustand',
|
||||
'Zu selten belohnen am Anfang → Hund verliert Interesse an der Position',
|
||||
'Zu lange Einheiten → Überforderung',
|
||||
],
|
||||
steigerung: 'Fuß in der Wohnung → ruhige Straße → belebte Umgebung → ohne Leckerli in der Hand',
|
||||
},
|
||||
{
|
||||
name: 'Aus / Lass es',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 10 Wochen',
|
||||
dauer: '3–5 Minuten',
|
||||
material: 'Leckerlis, Spielzeug oder Gegenstand',
|
||||
beschreibung: 'Der Hund lässt einen Gegenstand auf Kommando los. Wichtig für Sicherheit (Gefährliches fallen lassen) und Spielsituationen.',
|
||||
schritte: [
|
||||
'Gib dem Hund ein Spielzeug oder lass ihn etwas halten.',
|
||||
'Halte ein Leckerli vor seine Nase — er lässt den Gegenstand fallen.',
|
||||
'Sofort Markerwort + Leckerli geben.',
|
||||
'Gegenstand kurz aufheben, dann wieder zurückgeben → Hund lernt: Loslassen lohnt sich.',
|
||||
'Wort "Aus" kurz vor dem Leckerli-Zeigen einführen.',
|
||||
],
|
||||
fehler: [
|
||||
'Gegenstand wegziehen → Hund lernt festhalten',
|
||||
'Immer wegnehmen nach "Aus" → Hund gibt nicht mehr freiwillig her',
|
||||
],
|
||||
steigerung: 'Spielzeug → Leckerli auf dem Boden → Gegenstand unterwegs → hochwertige Beute',
|
||||
},
|
||||
{
|
||||
name: 'Warte',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 10 Wochen',
|
||||
dauer: '3–5 Minuten',
|
||||
material: 'Leckerlis, Türschwelle oder Futterschüssel',
|
||||
beschreibung: 'Der Hund wartet kurz in einer Situation bis er freigegeben wird — z.B. vor der Tür, vor dem Futter, beim Aussteigen aus dem Auto.',
|
||||
schritte: [
|
||||
'Stelle die Futterschüssel auf den Boden — Hund stürmt drauf zu.',
|
||||
'Schüssel mit der Hand abdecken oder hochhalten.',
|
||||
'Sobald der Hund zurückweicht oder sitzt: Schüssel freigeben + "Okay".',
|
||||
'Alternativ: an der Türschwelle — Tür öffnen, Hund wartet bis "Okay".',
|
||||
'Wort "Warte" einführen sobald das Verhalten klar ist.',
|
||||
],
|
||||
fehler: [
|
||||
'Hund wird nie freigegeben → verliert Vertrauen ins System',
|
||||
'Zu lange warten lassen am Anfang → Frustration',
|
||||
],
|
||||
steigerung: null,
|
||||
},
|
||||
];
|
||||
|
||||
const TRICKS = [
|
||||
{
|
||||
name: 'Pfote / Schütteln',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 12 Wochen',
|
||||
dauer: '3 Minuten',
|
||||
material: 'Leckerlis',
|
||||
beschreibung: null,
|
||||
schritte: [
|
||||
'Hund ins Sitz bringen.',
|
||||
'Leckerli in der Faust verstecken, Faust auf Kniehöhe halten.',
|
||||
'Hund schnuppert, kratzt irgendwann mit der Pfote an der Faust.',
|
||||
'Sofort öffnen: Markerwort + Leckerli.',
|
||||
'Wort "Pfote" einführen sobald die Bewegung zuverlässig kommt.',
|
||||
'Auf flache offene Hand umstellen → Hund legt Pfote rein.',
|
||||
],
|
||||
fehler: [],
|
||||
steigerung: null,
|
||||
},
|
||||
{
|
||||
name: 'Dreh / Runde',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 12 Wochen',
|
||||
dauer: '3–5 Minuten',
|
||||
material: 'Leckerlis',
|
||||
beschreibung: null,
|
||||
schritte: [
|
||||
'Leckerli vor die Nase des Hundes halten.',
|
||||
'Langsam einen Kreis in der Luft führen — der Hund folgt mit der Nase.',
|
||||
'Volle Drehung → Markerwort + Leckerli.',
|
||||
'Wort "Dreh" (links) und "Runde" (rechts) einführen.',
|
||||
'Handbewegung schrittweise kleiner machen.',
|
||||
],
|
||||
fehler: [],
|
||||
steigerung: null,
|
||||
},
|
||||
{
|
||||
name: 'Platz auf Decke / Matte',
|
||||
schwierigkeit: 'Fortgeschrittener Anfänger',
|
||||
alter: 'Ab 4 Monate',
|
||||
dauer: '5–10 Minuten',
|
||||
material: 'Leckerlis, Decke oder Matte',
|
||||
beschreibung: 'Der Hund geht selbstständig auf seine Decke und legt sich. Ideal für Besuche, Restaurant, ruhige Phasen.',
|
||||
schritte: [
|
||||
'Decke auf den Boden legen. Hund beschnuppert sie → Leckerli auf die Decke werfen.',
|
||||
'Jedes Mal wenn der Hund die Decke betritt: Markerwort + Leckerli.',
|
||||
'Warten bis der Hund sich spontan auf die Decke legt → große Belohnung.',
|
||||
'"Platz" zeigen sobald er auf der Decke steht.',
|
||||
'Decke schrittweise weiter wegstellen.',
|
||||
'Wort "Decke" oder "Platz geh" einführen.',
|
||||
],
|
||||
fehler: [],
|
||||
steigerung: null,
|
||||
},
|
||||
{
|
||||
name: 'Suchspiel / Nasenarbeit',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 8 Wochen',
|
||||
dauer: '5–10 Minuten',
|
||||
material: 'Leckerlis, optional Döschen',
|
||||
beschreibung: 'Nasenarbeit ist mentale Auslastung — 10 Minuten Suchen ermüdet mehr als 1 Stunde Spaziergang.',
|
||||
schritte: [
|
||||
'Leckerli vor den Augen des Hundes unter einem Becher verstecken.',
|
||||
'Hund darf suchen → findet er es: Markerwort + Leckerli.',
|
||||
'Steigerung: mehrere Becher, Hund muss den richtigen finden.',
|
||||
'Später: Leckerlis im Gras verstecken → "Such!"',
|
||||
'Noch später: Spielzeug oder Schlüssel suchen lassen.',
|
||||
],
|
||||
fehler: [],
|
||||
steigerung: null,
|
||||
},
|
||||
];
|
||||
|
||||
const PROBLEMVERHALTEN = [
|
||||
{
|
||||
name: 'Nicht springen / Begrüßung',
|
||||
schwierigkeit: 'Anfänger',
|
||||
alter: 'Ab 8 Wochen',
|
||||
dauer: 'Bei jeder Begrüßung',
|
||||
material: 'Konsequenz aller Haushaltsmitglieder',
|
||||
beschreibung: 'Der Hund begrüßt Menschen mit allen vier Pfoten auf dem Boden.',
|
||||
schritte: [
|
||||
'Springt der Hund hoch: keine Reaktion (kein "Nein", kein Wegdrücken, kein Augenkontakt).',
|
||||
'Sobald alle vier Pfoten unten sind: sofort Markerwort + Leckerli + Aufmerksamkeit.',
|
||||
'Konsequenz ist entscheidend — alle im Haushalt müssen gleich reagieren.',
|
||||
'Alternative: Hund ins Sitz schicken bei Begrüßung → dann begrüßen.',
|
||||
],
|
||||
fehler: [],
|
||||
steigerung: null,
|
||||
},
|
||||
{
|
||||
name: 'Alleine bleiben',
|
||||
schwierigkeit: 'Mittel',
|
||||
alter: 'Ab 10 Wochen',
|
||||
dauer: 'Mehrmals täglich kurze Einheiten',
|
||||
material: 'Geduld, Zeit, Kong oder Kauartikel',
|
||||
beschreibung: 'Der Hund bleibt ruhig wenn er allein ist — ohne Stress, Bellen oder Zerstören.',
|
||||
schritte: [
|
||||
'Hund beschäftigen (Kong mit Futter gefüllt, Kauartikel).',
|
||||
'Zimmer verlassen für 10 Sekunden → zurückkommen, ruhig begrüßen.',
|
||||
'Zeit schrittweise erhöhen: 30 Sek → 2 Min → 5 Min → 15 Min.',
|
||||
'Nie dramatisch verabschieden oder begrüßen — Kommen und Gehen normalisieren.',
|
||||
'Hund nicht bestrafen wenn er etwas angestellt hat — er versteht den Zusammenhang nicht mehr.',
|
||||
],
|
||||
fehler: [],
|
||||
steigerung: null,
|
||||
hinweis: 'Welpen sollten nie länger als 1–2 Stunden allein gelassen werden. Erwachsene Hunde maximal 4–6 Stunden.',
|
||||
},
|
||||
{
|
||||
name: 'Leinenführigkeit — Nicht ziehen',
|
||||
schwierigkeit: 'Mittel',
|
||||
alter: 'Ab 12 Wochen',
|
||||
dauer: 'Jeder Spaziergang',
|
||||
material: 'Leine, Leckerlis, Geduld',
|
||||
beschreibung: null,
|
||||
schritte: [
|
||||
'Beginne den Spaziergang ruhig — aufgeregte Starts fördern das Ziehen.',
|
||||
'Zieht der Hund: stehen bleiben. Warten bis Leine locker ist.',
|
||||
'Oder: Richtung wechseln sobald die Leine straff wird.',
|
||||
'Locker Leine = Bewegung vorwärts + gelegentlich Leckerli.',
|
||||
'Kein Ruck an der Leine — Hund lernt dadurch nicht.',
|
||||
],
|
||||
fehler: [],
|
||||
steigerung: null,
|
||||
hinweis: 'Hilfsmittel bei hartnäckigem Ziehen: Brustgeschirr mit vorderer Befestigung (kein Würge- oder Stachelband).',
|
||||
},
|
||||
];
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
}
|
||||
|
||||
function refresh() {}
|
||||
function onDogChange() {}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HAUPT-RENDER
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<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 & 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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 (5–10 Min), lieber mehrmals täglich'],
|
||||
[true, 'Immer mit Erfolg beenden'],
|
||||
[true, 'Ein Kommando — eine Bedeutung'],
|
||||
[true, 'Konsequenz bei allen Haushaltsmitgliedern'],
|
||||
[false, 'Nie bestrafen, schreien oder Zwang anwenden'],
|
||||
[false, 'Kommando nicht wiederholen wenn der Hund nicht reagiert'],
|
||||
[false, 'Nicht trainieren wenn Hund müde, krank oder aufgewühlt ist'],
|
||||
].map(([ok, text]) => `
|
||||
<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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
246
backend/static/js/pages/welcome.js
Normal file
246
backend/static/js/pages/welcome.js
Normal 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 };
|
||||
|
||||
})();
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue