Session 2026-04-21: SEO, Wiki-Anreicherung, Training, Lober

SEO & Crawler:
- robots.txt, llms.txt, sitemap.xml (508 Seiten bei Google)
- SSR-Seiten: /info, /wiki/rassen, /wiki/rasse/{slug}, /knigge
- Open Graph, JSON-LD, Breadcrumbs in index.html

Navigation:
- Training unter "Mein Hund", Wissen collapsible
- Welcome-Seite und Landing-Page auf 5-Gruppen-Struktur

Wiki:
- KI-Anreicherung (Claude API): beschreibung, vorkommen_de, Steckbrief
- "So einen hab ich" / Züchter-Verzeichnis
- Scheduler: 50 Rassen beim Start, 20/Nacht

Training:
- Session-Logging (Erfolgsquote, Stimmung, Zufriedenheit)
- Virtueller KI-Trainer (6h-Cache)
- Trainingskalender (Habit-Tracker)
- Top-Training → automatischer Tagebucheintrag
- Gamification ohne Druck: Badges, Streak, Stats

Fortschritts-Lober:
- Jeden Montag 09:00: Claude schreibt Lob-Text pro Hund
- Push + Karte im Tagebuch

Monitoring:
- 4× täglich Status-Mail mit Scheduler-Status + Wiki-Fortschritt
This commit is contained in:
rene 2026-04-21 19:38:20 +02:00
parent 65d1cf6c7f
commit 180de32e57
22 changed files with 4351 additions and 189 deletions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '262'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '267'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -103,6 +103,7 @@ const App = (() => {
state.page = pageId;
UI.scrollTop();
_expandWissenIfActive(pageId);
// Seiten-Modul lazy laden (einmalig)
_loadPage(pageId, params);
@ -309,6 +310,12 @@ const App = (() => {
return;
}
// Wissen-Toggle aufklappen/zuklappen
if (e.target.closest('#wissen-toggle')) {
_toggleWissen();
return;
}
// Sidebar-Item auf Mobile → schließen nach Navigation
if (e.target.closest('#sidebar .sidebar-item')) {
_closeSidebar();
@ -346,6 +353,22 @@ const App = (() => {
document.getElementById('sidebar-backdrop')?.classList.remove('visible');
}
const _WISSEN_PAGES = new Set(['wiki', 'knigge', 'movies', 'erste-hilfe']);
function _toggleWissen(force) {
const toggle = document.getElementById('wissen-toggle');
const body = document.getElementById('wissen-body');
if (!toggle || !body) return;
const open = force !== undefined ? force : toggle.getAttribute('aria-expanded') !== 'true';
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
body.classList.toggle('open', open);
try { localStorage.setItem('by_wissen_open', open ? '1' : '0'); } catch (_) {}
}
function _expandWissenIfActive(page) {
if (_WISSEN_PAGES.has(page)) _toggleWissen(true);
}
// ----------------------------------------------------------
// SCHNELL-HINZUFÜGEN (+ Button)
// ----------------------------------------------------------
@ -744,6 +767,12 @@ const App = (() => {
}
_bindNavigation();
// Wissen-Sektion: gespeicherten Zustand wiederherstellen
try {
if (localStorage.getItem('by_wissen_open') === '1') _toggleWissen(true);
} catch (_) {}
await _checkAuth();
// Einladungslink /teilen/{token} → direkt annehmen

View file

@ -220,6 +220,62 @@ window.Page_diary = (() => {
await _load();
_renderList();
_loadPraise();
}
// ----------------------------------------------------------
// FORTSCHRITTS-LOBER
// ----------------------------------------------------------
async function _loadPraise() {
const dog = _appState.activeDog;
if (!dog) return;
const existing = _container.querySelector('#diary-praise-card');
if (existing) existing.remove();
let data;
try {
const r = await fetch(`/api/praise/current?dog_id=${dog.id}`, {credentials: 'include'});
data = r.ok ? await r.json() : null;
} catch (_) { return; }
if (!data?.praise) return;
const card = document.createElement('div');
card.id = 'diary-praise-card';
card.style.cssText = `
margin: var(--space-3) var(--space-4) 0;
background: linear-gradient(135deg, var(--c-primary-subtle), #fdf6ef);
border: 1px solid var(--c-primary-light, #e8c99a);
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-5);
display: flex; gap: var(--space-3); align-items: flex-start;
`;
card.innerHTML = `
<div style="font-size:1.8rem;flex-shrink:0;line-height:1">🐾</div>
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-primary-dark);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-1)">
Rückblick der Woche
</div>
<p style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.6;margin:0">${data.praise}</p>
</div>
<button id="diary-praise-close"
style="background:none;border:none;cursor:pointer;padding:2px;
color:var(--c-text-muted);flex-shrink:0;line-height:1;font-size:1.1rem"
aria-label="Schließen">×</button>
`;
const list = _container.querySelector('#diary-list');
if (list) _container.insertBefore(card, list);
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
card.style.opacity = '0';
card.style.transition = 'opacity .2s';
setTimeout(() => card.remove(), 200);
});
}
// ----------------------------------------------------------

View file

@ -11,6 +11,18 @@ window.Page_trainingsplaene = (() => {
let _activePlan = 'welpe'; // welpe | junior | erwachsen
let _activeAdultTab = 'grundkurs'; // grundkurs | aufbaukurs
// ----------------------------------------------------------
// API HELPERS
// ----------------------------------------------------------
function _dogId() {
return window.App?.state?.activeDogId || null;
}
async function _apiGet(url) {
const r = await fetch(url, {credentials: 'include'});
return r.ok ? r.json() : null;
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
@ -590,9 +602,151 @@ window.Page_trainingsplaene = (() => {
</h2>
${_renderPlanSelector()}
${planContent}
<!-- Trainingskalender -->
<div id="tp-calendar-section" style="margin-top:var(--space-6)">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);
color:var(--c-text);margin:0 0 var(--space-3)">
${_icon('calendar')} Trainingskalender
</h3>
<div id="tp-calendar-wrap" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);text-align:center;padding:var(--space-4)">
Lade Kalender
</div>
</div>
</div>
</div>`;
_bindEvents();
_loadCalendar();
}
// ----------------------------------------------------------
// TRAININGSKALENDER
// ----------------------------------------------------------
async function _loadCalendar() {
const dogId = _dogId();
const wrap = _container.querySelector('#tp-calendar-wrap');
if (!wrap) return;
if (!dogId) {
wrap.innerHTML = `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);text-align:center;padding:var(--space-3)">Kein Hund ausgewählt.</p>`;
return;
}
const now = new Date();
const thisYear = now.getFullYear();
const thisMon = now.getMonth() + 1; // 1-based
// Letzter Monat
const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevYear = prevDate.getFullYear();
const prevMon = prevDate.getMonth() + 1;
const [dataCurr, dataPrev] = await Promise.all([
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${thisYear}&month=${thisMon}`),
_apiGet(`/api/training/calendar?dog_id=${dogId}&year=${prevYear}&month=${prevMon}`),
]);
wrap.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-4)">
${_renderCalendarMonth(prevYear, prevMon, dataPrev?.days || {})}
${_renderCalendarMonth(thisYear, thisMon, dataCurr?.days || {})}
</div>
<!-- Legende -->
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-top:var(--space-2);
font-size:var(--text-xs);color:var(--c-text-secondary);align-items:center">
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;border:1.5px solid var(--c-border);display:inline-block"></span>
kein Training
</span>
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary-subtle);border:1.5px solid var(--c-primary);display:inline-block"></span>
Training
</span>
<span style="display:flex;align-items:center;gap:5px">
<span style="width:14px;height:14px;border-radius:3px;background:var(--c-primary);display:inline-block"></span>
Top-Training
</span>
</div>
`;
}
function _renderCalendarMonth(year, month, days) {
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember'];
const WDAYS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
const firstDay = new Date(year, month - 1, 1);
// Monday = 0 offset: getDay() returns 0=Sun, so we shift
let startOffset = firstDay.getDay(); // 0=Sun
startOffset = startOffset === 0 ? 6 : startOffset - 1; // Mon=0 … Sun=6
const daysInMonth = new Date(year, month, 0).getDate();
// Header Wochentage
const wdayHeader = WDAYS.map(d =>
`<div style="text-align:center;font-size:10px;font-weight:var(--weight-semibold);
color:var(--c-text-secondary);padding-bottom:4px">${d}</div>`
).join('');
// Leere Slots am Anfang
let cells = '';
for (let i = 0; i < startOffset; i++) {
cells += `<div></div>`;
}
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const dayData = days[dateStr];
const isToday = dateStr === todayStr;
let bg = 'transparent';
let border = '1.5px solid var(--c-border)';
let color = 'var(--c-text-secondary)';
let title = '';
if (dayData) {
if (dayData.top) {
bg = 'var(--c-primary)';
border = '1.5px solid var(--c-primary)';
color = '#fff';
title = `Top-Training! ${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
} else {
bg = 'var(--c-primary-subtle)';
border = '1.5px solid var(--c-primary)';
color = 'var(--c-primary)';
title = `${dayData.count} Einheit${dayData.count !== 1 ? 'en' : ''}`;
}
}
const todayRing = isToday ? 'box-shadow:0 0 0 2px var(--c-primary);' : '';
cells += `
<div title="${title}"
style="aspect-ratio:1;border-radius:4px;background:${bg};border:${border};
display:flex;align-items:center;justify-content:center;
font-size:10px;font-weight:${dayData ? 'var(--weight-semibold)' : '400'};
color:${color};${todayRing}cursor:default">
${d}
</div>
`;
}
return `
<div style="flex:1;min-width:220px">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
${_esc(MONTHS[month - 1])} ${year}
</div>
<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:3px">
${wdayHeader}
${cells}
</div>
</div>
`;
}
// ----------------------------------------------------------

View file

@ -9,6 +9,34 @@ window.Page_uebungen = (() => {
let _appState = null;
let _activeTab = 'grundkommandos';
// ----------------------------------------------------------
// API HELPERS
// ----------------------------------------------------------
function _dogId() {
return window.App?.state?.activeDogId || null;
}
async function _apiPost(url, body) {
const r = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
return r.ok ? r.json() : null;
}
async function _apiGet(url) {
const r = await fetch(url, {credentials: 'include'});
return r.ok ? r.json() : null;
}
// ----------------------------------------------------------
// STATS STATE
// ----------------------------------------------------------
let _statsData = null; // cached stats from /api/training/stats
let _badgesData = null; // cached badges from /api/achievements
// ----------------------------------------------------------
// DATEN
// ----------------------------------------------------------
@ -387,10 +415,29 @@ window.Page_uebungen = (() => {
API.training.getSuggestions().then(suggestions => {
if (suggestions.length) _showSuggestions(suggestions);
}).catch(() => {});
// Stats + Badges laden
_loadStatsAndBadges();
}
async function _loadStatsAndBadges() {
const dogId = _dogId();
if (!dogId) return;
const [stats, achievements] = await Promise.all([
_apiGet(`/api/training/stats?dog_id=${dogId}`),
_apiGet('/api/achievements'),
]);
_statsData = stats;
_badgesData = achievements;
_renderStatsBanner();
}
function refresh() {}
function onDogChange() {}
function onDogChange() {
_statsData = null;
_badgesData = null;
_loadStatsAndBadges();
}
// ----------------------------------------------------------
// HAUPT-RENDER
@ -399,12 +446,64 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
<div id="ueb-wrap">
${_renderTabs()}
<div id="ueb-stats-banner" style="padding:0 var(--space-4);margin-bottom:var(--space-2)"></div>
<div id="ueb-suggestions" style="padding:0 var(--space-4);display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-2)"></div>
<div id="ueb-content"></div>
</div>
`;
_bindTabs();
_renderContent();
_renderStatsBanner();
}
// ----------------------------------------------------------
// STATS BANNER
// ----------------------------------------------------------
function _renderStatsBanner() {
const el = _container && _container.querySelector('#ueb-stats-banner');
if (!el) return;
if (!_statsData || !_statsData.total_sessions) { el.innerHTML = ''; return; }
const s = _statsData;
const streakHtml = (s.streak_days >= 2)
? ` &nbsp;·&nbsp; ${s.streak_days}-Tage-Streak 🔥`
: '';
const avgHtml = s.avg_erfolgsquote != null
? ` &nbsp;·&nbsp; Ø ${Math.round(s.avg_erfolgsquote)}% Erfolg`
: '';
// Training-Badges filtern
let badgesHtml = '';
if (_badgesData && Array.isArray(_badgesData.user_badges)) {
const trainingBadges = _badgesData.user_badges.filter(b => b.badge_id && b.badge_id.startsWith('training_'));
if (trainingBadges.length) {
const visible = trainingBadges.slice(0, 3);
const rest = trainingBadges.length - visible.length;
const pills = visible.map(b => `
<span style="display:inline-flex;align-items:center;gap:3px;padding:2px 8px;
border-radius:var(--radius-full,999px);
background:var(--c-primary-subtle);color:var(--c-primary);
font-size:var(--text-xs);font-weight:var(--weight-semibold)">
${b.icon || '🏅'} ${_esc(b.name || b.badge_id)}
</span>
`).join('');
const more = rest > 0
? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">+${rest} weitere</span>`
: '';
badgesHtml = `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-2)">${pills}${more}</div>`;
}
}
el.innerHTML = `
<div style="background:var(--c-surface-2);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
font-size:var(--text-sm);color:var(--c-text-secondary)">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">
${s.total_sessions} Einheit${s.total_sessions !== 1 ? 'en' : ''}
</span>${avgHtml}${streakHtml}
${badgesHtml}
</div>
`;
}
function _renderTabs() {
@ -492,7 +591,8 @@ window.Page_uebungen = (() => {
}
_bindAccordions();
_bindStatusButtons();
if (_activeTab === 'ki-trainer') _bindKiTrainer();
_bindLogButtons();
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
}
// ----------------------------------------------------------
@ -523,6 +623,21 @@ window.Page_uebungen = (() => {
${_esc(u.name)}
</span>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<!-- Log-Button -->
<button class="ueb-log-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="Trainingseinheit loggen"
style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
color:var(--c-primary);cursor:pointer;padding:2px 7px;
display:flex;align-items:center;gap:3px;
font-size:var(--text-xs);font-weight:var(--weight-semibold);
border-radius:var(--radius-sm)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#plus"></use>
</svg>
Einheit
</button>
<!-- Status-Button -->
<button class="ueb-status-btn"
data-tab="${_esc(_activeTab)}"
@ -652,143 +767,432 @@ window.Page_uebungen = (() => {
}
// ----------------------------------------------------------
// KI-TRAINER
// LOG SESSION MODAL
// ----------------------------------------------------------
function _bindLogButtons() {
_container.querySelectorAll('.ueb-log-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
_openLogModal(btn.dataset.tab, btn.dataset.name);
});
});
}
function _openLogModal(tab, exerciseName) {
// Build the modal HTML
const modalId = 'ueb-log-modal';
const formId = 'ueb-log-form';
// Remove existing if present
document.getElementById(modalId)?.remove();
const overlay = document.createElement('div');
overlay.id = modalId;
overlay.style.cssText = `
position:fixed;inset:0;z-index:9999;
display:flex;align-items:flex-end;justify-content:center;
background:rgba(0,0,0,0.45);
`;
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:92vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<!-- Handle -->
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);
margin:0 0 var(--space-4);text-align:center">
Einheit loggen: ${_esc(exerciseName)}
</h3>
<form id="${formId}" style="display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Wiederholungen -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Wiederholungen</label>
<div style="display:flex;align-items:center;gap:var(--space-3);justify-content:center">
<button type="button" id="ueb-rep-minus"
style="width:36px;height:36px;border-radius:50%;border:1.5px solid var(--c-border);
background:var(--c-surface-2);font-size:1.2rem;cursor:pointer;
display:flex;align-items:center;justify-content:center;color:var(--c-text)"></button>
<span id="ueb-rep-val" style="font-size:var(--text-xl);font-weight:700;color:var(--c-text);min-width:32px;text-align:center">5</span>
<button type="button" id="ueb-rep-plus"
style="width:36px;height:36px;border-radius:50%;border:1.5px solid var(--c-border);
background:var(--c-surface-2);font-size:1.2rem;cursor:pointer;
display:flex;align-items:center;justify-content:center;color:var(--c-text)">+</button>
</div>
</div>
<!-- Wie lief's -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Wie lief's?</label>
<div style="display:flex;gap:var(--space-2);justify-content:center;flex-wrap:wrap">
${[['😓','0'],['😐','25'],['🙂','50'],['😊','75'],['🎉','100']].map(([emoji, val]) => `
<button type="button" class="ueb-erfolg-btn"
data-val="${val}"
style="font-size:1.5rem;padding:var(--space-2) var(--space-3);
border-radius:var(--radius-md);border:1.5px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;transition:all 0.15s"
title="${val}%">${emoji}</button>
`).join('')}
</div>
</div>
<!-- Stimmung des Hundes -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
<div style="display:flex;gap:var(--space-2);justify-content:center;flex-wrap:wrap">
${[['🎯','aufmerksam'],['😴','müde'],['🌪️','abgelenkt'],['⚡','super']].map(([emoji, val]) => `
<button type="button" class="ueb-stimmung-btn"
data-val="${val}"
style="display:flex;flex-direction:column;align-items:center;gap:2px;
font-size:1.2rem;padding:var(--space-2);min-width:60px;
border-radius:var(--radius-md);border:1.5px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;transition:all 0.15s">
${emoji}
<span style="font-size:9px;color:var(--c-text-secondary)">${_esc(val)}</span>
</button>
`).join('')}
</div>
</div>
<!-- Zufriedenheit -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Wie zufrieden bist du?</label>
<div style="display:flex;gap:var(--space-2);justify-content:center">
${[1,2,3,4,5].map(n => `
<button type="button" class="ueb-stern-btn"
data-val="${n}"
style="font-size:1.5rem;background:none;border:none;cursor:pointer;
padding:2px;opacity:0.35;transition:opacity 0.15s"></button>
`).join('')}
</div>
</div>
<!-- Notiz -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
<textarea id="ueb-log-notiz" rows="2" placeholder="Optional: Was ist aufgefallen?"
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;resize:none;
background:var(--c-surface);color:var(--c-text);line-height:1.5"></textarea>
</div>
<!-- Meilenstein-Checkbox (initially hidden) -->
<label id="ueb-log-milestone-wrap" hidden
style="display:flex;align-items:flex-start;gap:var(--space-2);cursor:pointer;
padding:var(--space-3);background:var(--c-primary-subtle);
border:1px solid var(--c-primary-light);border-radius:var(--radius-md)">
<input type="checkbox" id="ueb-log-milestone"
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">
📖 Als Meilenstein ins Tagebuch eintragen
</span>
</label>
</form>
<!-- Footer Buttons -->
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
<button id="ueb-log-cancel"
style="flex:1;padding:var(--space-3);border:1.5px solid var(--c-border);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-sm);cursor:pointer;color:var(--c-text)">
Abbrechen
</button>
<button id="ueb-log-save" form="${formId}"
class="btn btn-primary" style="flex:2"
type="button">
Einheit speichern
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// State
let wiederholungen = 5;
let erfolgsquote = null; // must be selected
let stimmung = null;
let zufriedenheit = null;
// Close helpers
function _closeModal() { overlay.remove(); }
overlay.addEventListener('click', e => { if (e.target === overlay) _closeModal(); });
overlay.querySelector('#ueb-log-cancel').addEventListener('click', _closeModal);
// Stepper
const repVal = overlay.querySelector('#ueb-rep-val');
overlay.querySelector('#ueb-rep-minus').addEventListener('click', () => {
if (wiederholungen > 1) { wiederholungen--; repVal.textContent = wiederholungen; }
});
overlay.querySelector('#ueb-rep-plus').addEventListener('click', () => {
wiederholungen++;
repVal.textContent = wiederholungen;
});
// Erfolg-Buttons
overlay.querySelectorAll('.ueb-erfolg-btn').forEach(btn => {
btn.addEventListener('click', () => {
erfolgsquote = parseInt(btn.dataset.val, 10);
overlay.querySelectorAll('.ueb-erfolg-btn').forEach(b => {
b.style.background = 'var(--c-surface-2)';
b.style.borderColor = 'var(--c-border)';
b.style.transform = '';
});
btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)';
btn.style.transform = 'scale(1.15)';
_checkMilestoneVisibility();
});
});
// Stimmung-Buttons
overlay.querySelectorAll('.ueb-stimmung-btn').forEach(btn => {
btn.addEventListener('click', () => {
stimmung = btn.dataset.val;
overlay.querySelectorAll('.ueb-stimmung-btn').forEach(b => {
b.style.background = 'var(--c-surface-2)';
b.style.borderColor = 'var(--c-border)';
});
btn.style.background = 'var(--c-primary-subtle)';
btn.style.borderColor = 'var(--c-primary)';
});
});
// Stern-Buttons
overlay.querySelectorAll('.ueb-stern-btn').forEach(btn => {
btn.addEventListener('click', () => {
zufriedenheit = parseInt(btn.dataset.val, 10);
overlay.querySelectorAll('.ueb-stern-btn').forEach(b => {
b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35';
});
_checkMilestoneVisibility();
});
});
function _checkMilestoneVisibility() {
const wrap = overlay.querySelector('#ueb-log-milestone-wrap');
if (!wrap) return;
const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4;
wrap.hidden = !show;
}
// Save
overlay.querySelector('#ueb-log-save').addEventListener('click', async () => {
const dogId = _dogId();
if (!dogId) { UI.toast('Kein Hund ausgewählt.', 'warning'); return; }
if (erfolgsquote === null) { UI.toast('Bitte wähle aus, wie es gelaufen ist.', 'warning'); return; }
const saveBtn = overlay.querySelector('#ueb-log-save');
saveBtn.disabled = true;
saveBtn.textContent = 'Speichern…';
const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`;
const today = new Date().toISOString().slice(0, 10);
const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden &&
overlay.querySelector('#ueb-log-milestone').checked;
const body = {
dog_id: dogId,
exercise_id: exerciseId,
exercise_name: exerciseName,
datum: today,
wiederholungen: wiederholungen,
erfolgsquote: erfolgsquote,
hund_stimmung: stimmung || null,
zufriedenheit: zufriedenheit || null,
notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null,
tagebuch_eintrag: tagebuch,
};
try {
const resp = await _apiPost('/api/training/sessions', body);
_closeModal();
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
if (resp.ist_top) {
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
} else {
UI.toast.success('Einheit gespeichert!');
}
if (resp.badges && resp.badges.length) {
resp.badges.forEach((badge, idx) => {
setTimeout(() => {
UI.toast.success(`🏅 Neues Abzeichen: "${badge.name || badge}"!`);
}, 1000 * (idx + 1));
});
}
if (resp.diary_entry_id) {
setTimeout(() => {
UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.');
}, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000);
}
// Stats-Banner aktualisieren
_statsData = null;
_loadStatsAndBadges();
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = 'Einheit speichern';
UI.toast.error('Speichern fehlgeschlagen.');
}
});
}
// ----------------------------------------------------------
// KI-TRAINER (neu: hundebasiertes Feedback)
// ----------------------------------------------------------
function _renderKiTrainer() {
return `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
const dogId = _dogId();
if (!dogId) {
return `
<div style="padding:var(--space-6) var(--space-4);text-align:center;color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
<use href="/icons/phosphor.svg#robot"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">Wähle einen Hund aus um den KI-Trainer zu nutzen.</p>
</div>
`;
}
<!-- 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">
return `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)" id="ki-trainer-panel">
<!-- Header -->
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="width:48px;height:48px;border-radius:50%;background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg class="ph-icon" style="width:26px;height:26px;color:var(--c-primary)" 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 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text)">
KI-Trainer
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Personalisiertes Feedback basierend auf deinen Trainingseinheiten
</div>
</div>
</div>
<!-- Eingabe -->
<div class="card" style="padding:var(--space-4)">
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">
Rasse &amp; Alter (optional)
</label>
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4)">
<input id="ki-rasse" type="text" placeholder="z.B. Labrador"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
background:var(--c-surface);color:var(--c-text);font-family:inherit">
<input id="ki-alter" type="text" placeholder="z.B. 2 Jahre"
style="flex:1;padding:var(--space-2) var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
background:var(--c-surface);color:var(--c-text);font-family:inherit">
</div>
<!-- Lade-Spinner -->
<div id="ki-loading" style="text-align:center;padding:var(--space-6);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:32px;height:32px;animation:spin 1s linear infinite;margin-bottom:var(--space-2)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">Lade KI-Feedback</p>
</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
<!-- Kein Sessions-Hinweis -->
<div id="ki-no-sessions" hidden
style="text-align:center;padding:var(--space-6) var(--space-4);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:40px;height:40px;margin-bottom:var(--space-3);color:var(--c-border)" aria-hidden="true">
<use href="/icons/phosphor.svg#clipboard-text"></use>
</svg>
<p style="font-size:var(--text-sm);margin:0">
Logge deine erste Trainingseinheit um KI-Feedback zu erhalten.
</p>
</div>
<!-- Feedback-Card -->
<div id="ki-feedback-card" hidden>
<div class="card" style="border-left:3px solid var(--c-primary);background:var(--c-surface)">
<div id="ki-feedback-text"
style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7;white-space:pre-wrap"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:var(--space-3)">
<span id="ki-feedback-meta" style="font-size:var(--text-xs);color:var(--c-text-muted)"></span>
<button id="ki-regenerate"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-3);
border-radius:var(--radius-md);border:1px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;color:var(--c-text-secondary)">
Neu generieren
</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.
KI-Tipps ersetzen keinen professionellen Hundetrainer.
</p>
</div>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
</style>
`;
}
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;
async function _loadKiTrainerFeedback(forceRefresh) {
const loading = _container.querySelector('#ki-loading');
const noSessions = _container.querySelector('#ki-no-sessions');
const feedbackCard = _container.querySelector('#ki-feedback-card');
const feedbackText = _container.querySelector('#ki-feedback-text');
const feedbackMeta = _container.querySelector('#ki-feedback-meta');
const regenBtn = _container.querySelector('#ki-regenerate');
if (!loading) return; // not on ki-trainer tab
textarea.addEventListener('input', () => {
charCount.textContent = `${textarea.value.length} / 1000`;
});
const dogId = _dogId();
if (!dogId) return;
submitBtn.addEventListener('click', async () => {
const problem = textarea.value.trim();
if (problem.length < 10) {
UI.toast('Bitte beschreibe das Problem etwas genauer.', 'warning');
// Show loading
loading.hidden = false;
noSessions.hidden = true;
feedbackCard.hidden = true;
// Check if there are any sessions
const stats = _statsData || await _apiGet(`/api/training/stats?dog_id=${dogId}`);
if (!stats || !stats.total_sessions) {
loading.hidden = true;
noSessions.hidden = false;
return;
}
try {
const resp = await _apiPost('/api/training/ki-feedback', { dog_id: dogId });
loading.hidden = true;
if (!resp || !resp.feedback) {
noSessions.hidden = false;
return;
}
const rasse = _container.querySelector('#ki-rasse')?.value.trim() || null;
const alter = _container.querySelector('#ki-alter')?.value.trim() || null;
submitBtn.disabled = true;
submitBtn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> Denke nach…`;
result.hidden = true;
result.innerHTML = '';
try {
const resp = await API.post('/ki/training', { problem, rasse, alter });
const text = resp.antwort || '';
// Render with simple markdown-like formatting (text already escaped by API)
const safeText = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = safeText
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/^(\d+)\. (.+)$/gm, '<li><strong>$1.</strong> $2</li>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
result.innerHTML = `
<div class="card" style="border-left:3px solid var(--c-primary)">
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<svg class="ph-icon" style="color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#robot"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
Empfehlung des KI-Trainers
</span>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text);line-height:1.7">
<p>${html}</p>
</div>
</div>
`;
result.hidden = false;
} catch (err) {
UI.toast(err.message || 'KI momentan nicht verfügbar.', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg> Tipps holen`;
feedbackText.textContent = resp.feedback;
const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert';
const sessionInfo = stats.total_sessions
? ` · Basiert auf ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}`
: '';
feedbackMeta.textContent = `Aktualisiert alle 6 Stunden · ${cachedInfo}${sessionInfo}`;
if (regenBtn) {
regenBtn.textContent = resp.cached ? 'Neu generieren' : 'Aktualisieren';
regenBtn.style.opacity = resp.cached ? '0.6' : '1';
}
});
feedbackCard.hidden = false;
} catch (err) {
loading.hidden = true;
noSessions.hidden = false;
}
// Bind regenerate button
if (regenBtn) {
regenBtn.addEventListener('click', async () => {
regenBtn.disabled = true;
regenBtn.textContent = 'Generiere…';
loading.hidden = false;
feedbackCard.hidden = true;
await _loadKiTrainerFeedback(true);
regenBtn.disabled = false;
}, { once: true });
}
}
function _bindAccordions() {

View file

@ -70,27 +70,41 @@ window.Page_welcome = (() => {
<!-- Features: Mein Hund -->
${_featureCard('Mein Hund', [
['book-open', 'Tagebuch', 'Momente, Fotos und Meilensteine festhalten', 'diary'],
['syringe', 'Gesundheit', 'Impfungen, Tierarztbesuche & Medikamente', 'health'],
['target', 'Training', 'Übungen, Pläne und KI-Trainer', 'uebungen'],
['books', 'Wiki & Wissen', 'Rassen, Ernährung, Erste Hilfe', 'wiki'],
])}
<!-- Features: Community -->
${_featureCard('Community', [
['users', 'Freunde', 'Verbinde dich mit anderen Hundebesitzern', 'friends'],
['chat-circle-dots', 'Nachrichten', 'Private Chats mit deinen Freunden', 'chat'],
['push-pin', 'Forum', 'Diskussionen, Tipps und Austausch', 'forum'],
['paw-print', 'Gassi-Treffen', 'Hunde-Dates mit anderen Besitzern', 'walks'],
['house-line', 'Sitting', 'Dogsitter finden oder selbst anbieten', 'sitting'],
['magnifying-glass', 'Verlorene Hunde','Hilf gesuchte Hunde zu finden', 'lost'],
['book-open', 'Tagebuch', 'Momente, Fotos und Meilensteine festhalten', 'diary'],
['first-aid', 'Gesundheit', 'Impfungen, Tierarztbesuche & Medikamente', 'health'],
['target', 'Übungen', 'Trainingsübungen mit KI-Unterstützung', 'uebungen'],
['clipboard-text', 'Trainingspläne', 'Strukturierte Pläne für jedes Lernziel', 'trainingsplaene'],
])}
<!-- Features: Entdecken -->
${_featureCard('Entdecken', [
['map-trifold', 'Karte & Routen', 'Hundefreundliche Orte und Spazierwege', 'map'],
['calendar-dots', 'Events', 'Veranstaltungen in deiner Nähe', 'events'],
['warning-octagon','Giftköder-Alarm', 'Community-Warnungen in deiner Nähe', 'poison'],
['map-trifold', 'Karte', 'Orte, Routen und Meldungen in der Nähe', 'map'],
['path', 'Routen', 'GPS-Routen aufzeichnen und bewerten', 'routes'],
['calendar-dots', 'Events', 'Turniere und Veranstaltungen', 'events'],
])}
<!-- Features: Soziales -->
${_featureCard('Soziales', [
['users', 'Freunde', 'Verbinde dich mit anderen Hundebesitzern', 'friends'],
['chat-circle-dots', 'Nachrichten', 'Private Chats mit deinen Freunden', 'chat'],
['bell', 'Aktuelles', 'Benachrichtigungen und Neuigkeiten', 'notifications'],
])}
<!-- Features: Community -->
${_featureCard('Community', [
['warning-octagon', 'Giftköder-Alarm', 'Warnungen sofort melden und empfangen', 'poison'],
['paw-print', 'Gassi-Treffen', 'Hunde-Dates mit anderen Besitzern', 'walks'],
['house-line', 'Sitting', 'Sitter finden oder selbst anbieten', 'sitting'],
['push-pin', 'Forum', 'Diskussionen, Tipps und Austausch', 'forum'],
['magnifying-glass', 'Verlorene Hunde', 'Hilf vermisste Hunde zu finden', 'lost'],
])}
<!-- Features: Wissen -->
${_featureCard('Wissen', [
['books', 'Wiki', 'Rassendatenbank, Gesundheits-Wiki, Quiz', 'wiki'],
['handshake', 'Knigge', 'Regeln, Begegnungen, Leinenpflicht', 'knigge'],
['film-slate', 'Filme', 'Stirbt der Hund? Die wichtigste Frage', 'movies'],
['first-aid', 'Erste Hilfe','Notfallratgeber für häufige Situationen', 'erste-hilfe'],
])}
<!-- App installieren -->

View file

@ -393,6 +393,313 @@ window.Page_wiki = (() => {
`;
}
// ----------------------------------------------------------
// API-Funktionen: Interesse / Stats / Züchter
// ----------------------------------------------------------
async function _fetchStats(slug) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/stats`, { credentials: 'include' });
return r.ok ? r.json() : null;
}
async function _setInteresse(slug, typ) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/interesse`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ typ }),
});
return r.ok ? r.json() : null;
}
async function _deleteInteresse(slug) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/interesse`, {
method: 'DELETE', credentials: 'include',
});
return r.ok ? r.json() : null;
}
async function _fetchZuchter(slug) {
const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/zuchter`);
return r.ok ? r.json() : [];
}
async function _submitZuchter(data) {
const r = await fetch('/api/wiki/zuchter', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return r.ok;
}
// ----------------------------------------------------------
// Render-Helfer: Steckbrief-Grid
// ----------------------------------------------------------
function _renderSteckbriefGrid(rasse) {
const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg)
? `${rasse.gewicht_min_kg}&ndash;${rasse.gewicht_max_kg} kg`
: (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '&mdash;');
const kinderLabel = rasse.kinder_geeignet === true
? `<span style="color:var(--c-success)">&#10003; Ja</span>`
: rasse.kinder_geeignet === false
? `<span style="color:var(--c-warning)">&#9889; Bedingt</span>`
: '&mdash;';
const wohnungLabel = rasse.wohnung_geeignet
? `<span style="color:var(--c-success)">&#10003; Ja</span>`
: `<span style="color:var(--c-text-secondary)">&#10007; Besser Garten</span>`;
const rows = [
['Größe', _groesseLabel(rasse.groesse) || '&mdash;'],
['Gewicht', gewicht],
['Lebensdauer', _esc(rasse.lebensdauer) || '&mdash;'],
['Aktivität', _aktivLabel(rasse.aktivitaet) || '&mdash;'],
['Eignung', _erfahrungLabel(rasse.erfahrung) || '&mdash;'],
['Kinder', kinderLabel],
['Wohnung', wohnungLabel],
['FCI-Gruppe', _esc(rasse.gruppe) || '&mdash;'],
];
return `
<div class="wiki-steckbrief-grid">
${rows.map(([label, val]) => `
<div class="wiki-steckbrief-item">
<span class="wiki-steckbrief-label">${label}</span>
<span class="wiki-steckbrief-value">${val}</span>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// Render-Helfer: Interesse-Section (Social)
// ----------------------------------------------------------
function _renderInteresseSection(stats, slug) {
const hatCount = stats?.dogs_count ?? '';
const willCount = stats?.will_count ?? '';
const interest = stats?.user_interest ?? null;
const isLoggedIn = !!_appState.user;
const hatActive = interest === 'hat';
const willActive = interest === 'will';
const hatStyle = hatActive ? `background:var(--c-primary);color:#fff;border-color:var(--c-primary)` : '';
const willStyle = willActive ? `background:var(--c-primary);color:#fff;border-color:var(--c-primary)` : '';
return `
<div class="wiki-detail-section wiki-interesse-section" id="wiki-interesse-section">
<div class="wiki-detail-label">In der Community</div>
<div class="wiki-interesse-counts" style="display:flex;gap:var(--space-4);margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary)">
<span id="wiki-hat-count">&#128021; <strong>${hatCount}</strong> haben diesen Hund</span>
<span id="wiki-will-count">&#10084;&#65039; <strong>${willCount}</strong> m&ouml;chten ihn</span>
</div>
<div style="display:flex;gap:var(--space-2)">
<button class="btn btn-sm wiki-interesse-btn" id="wiki-btn-hat"
style="flex:1;${hatStyle}"
data-slug="${_esc(slug)}" data-typ="hat">
${isLoggedIn ? '' : '&#128274; '}Ich hab einen
</button>
<button class="btn btn-sm wiki-interesse-btn" id="wiki-btn-will"
style="flex:1;${willStyle}"
data-slug="${_esc(slug)}" data-typ="will">
${isLoggedIn ? '' : '&#128274; '}Ich will einen
</button>
</div>
</div>
`;
}
function _bindInteresseButtons(slug) {
document.querySelectorAll('.wiki-interesse-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!_appState.user) {
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
return;
}
const typ = btn.dataset.typ;
const hatBtn = document.getElementById('wiki-btn-hat');
const willBtn = document.getElementById('wiki-btn-will');
// Determine current state
const isActive = btn.style.background.includes('var(--c-primary)') || btn.style.backgroundColor;
const currentActive = (hatBtn?.style.background || '').includes('var(--c-primary)') ? 'hat'
: (willBtn?.style.background || '').includes('var(--c-primary)') ? 'will' : null;
// Optimistic disable
btn.disabled = true;
try {
if (currentActive === typ) {
await _deleteInteresse(slug);
} else {
await _setInteresse(slug, typ);
}
// Reload stats and re-render counts + button states
const stats = await _fetchStats(slug);
if (stats) {
const hatCount = stats.dogs_count ?? '';
const willCount = stats.will_count ?? '';
const interest = stats.user_interest ?? null;
const hatEl = document.getElementById('wiki-hat-count');
const willEl = document.getElementById('wiki-will-count');
if (hatEl) hatEl.innerHTML = `&#128021; <strong>${hatCount}</strong> haben diesen Hund`;
if (willEl) willEl.innerHTML = `&#10084;&#65039; <strong>${willCount}</strong> m&ouml;chten ihn`;
const activeStyle = `background:var(--c-primary);color:#fff;border-color:var(--c-primary)`;
if (hatBtn) { hatBtn.removeAttribute('style'); if (interest === 'hat') hatBtn.style.cssText = activeStyle; }
if (willBtn) { willBtn.removeAttribute('style'); if (interest === 'will') willBtn.style.cssText = activeStyle; }
}
} catch {
UI.toast.error('Aktion fehlgeschlagen.');
}
btn.disabled = false;
});
});
}
// ----------------------------------------------------------
// Render-Helfer: Züchter-Sektion
// ----------------------------------------------------------
function _renderZuchterSection(zuchter, slug) {
const DE_BUNDESLAENDER = [
'Baden-Württemberg','Bayern','Berlin','Brandenburg','Bremen','Hamburg',
'Hessen','Mecklenburg-Vorpommern','Niedersachsen','Nordrhein-Westfalen',
'Rheinland-Pfalz','Saarland','Sachsen','Sachsen-Anhalt',
'Schleswig-Holstein','Thüringen',
];
const listHtml = zuchter.length === 0
? `<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">Noch keine Züchter eingetragen.</p>`
: zuchter.map(z => `
<div class="wiki-zuchter-card" style="padding:var(--space-3);border-radius:var(--radius-md);background:var(--c-surface-2);margin-bottom:var(--space-2)">
<div style="font-weight:var(--weight-semibold)">${_esc(z.name)}
${z.zwingername ? `<em style="font-weight:normal;color:var(--c-text-secondary)"> &bdquo;${_esc(z.zwingername)}&ldquo;</em>` : ''}
${z.vdh_mitglied ? `<span class="badge badge-sm" style="margin-left:var(--space-1);background:var(--c-primary);color:#fff">VDH</span>` : ''}
</div>
${(z.ort || z.bundesland) ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${[z.ort, z.bundesland].filter(Boolean).map(_esc).join(', ')}</div>` : ''}
${z.beschreibung ? `<p style="font-size:var(--text-sm);margin-top:var(--space-1)">${_esc(z.beschreibung)}</p>` : ''}
${z.website ? `<a href="${_esc(z.website)}" target="_blank" rel="noopener" style="font-size:var(--text-sm);color:var(--c-primary)">${_esc(z.website)}</a>` : ''}
</div>
`).join('');
const formHtml = _appState.user ? `
<div id="wiki-zuchter-form-wrap" style="display:none;margin-top:var(--space-3)">
<form id="wiki-zuchter-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required maxlength="100">
</div>
<div class="form-group">
<label class="form-label">Zwingername</label>
<input class="form-control" name="zwingername" maxlength="100">
</div>
<div class="form-group">
<label class="form-label">Ort</label>
<input class="form-control" name="ort" maxlength="80">
</div>
<div class="form-group">
<label class="form-label">PLZ</label>
<input class="form-control" name="plz" maxlength="10">
</div>
<div class="form-group">
<label class="form-label">Bundesland</label>
<select class="form-control" name="bundesland">
<option value=""> bitte wählen </option>
${DE_BUNDESLAENDER.map(bl => `<option value="${_esc(bl)}">${_esc(bl)}</option>`).join('')}
</select>
</div>
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Website</label>
<input class="form-control" name="website" type="url" maxlength="200" placeholder="https://…">
</div>
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Telefon</label>
<input class="form-control" name="telefon" type="tel" maxlength="30">
</div>
<div class="form-group" style="grid-column:1/-1">
<label class="form-label">Kurzbeschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3" maxlength="500"></textarea>
</div>
<div class="form-group" style="grid-column:1/-1;display:flex;align-items:center;gap:var(--space-2)">
<input type="checkbox" id="wiki-zuchter-vdh" name="vdh_mitglied" value="1" style="width:auto">
<label for="wiki-zuchter-vdh" style="margin:0;font-size:var(--text-sm)">VDH-Mitglied</label>
</div>
</div>
<div id="wiki-zuchter-success" style="display:none;color:var(--c-success);padding:var(--space-3);text-align:center;font-size:var(--text-sm)">
Vielen Dank! Dein Eintrag wird geprüft.
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-ghost flex-1" id="wiki-zuchter-cancel">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1" id="wiki-zuchter-submit">Eintragen</button>
</div>
</form>
</div>
<button class="btn btn-secondary btn-sm" id="wiki-zuchter-add-btn" style="margin-top:var(--space-3)">
+ Züchter eintragen
</button>
` : '';
return `
<div class="wiki-detail-section" id="wiki-zuchter-section">
<div class="wiki-detail-label">Züchter</div>
<div id="wiki-zuchter-list">${listHtml}</div>
${formHtml}
</div>
`;
}
function _bindZuchterForm(slug) {
const addBtn = document.getElementById('wiki-zuchter-add-btn');
const cancelBtn = document.getElementById('wiki-zuchter-cancel');
const formWrap = document.getElementById('wiki-zuchter-form-wrap');
const form = document.getElementById('wiki-zuchter-form');
addBtn?.addEventListener('click', () => {
formWrap.style.display = '';
addBtn.style.display = 'none';
});
cancelBtn?.addEventListener('click', () => {
formWrap.style.display = 'none';
addBtn.style.display = '';
});
form?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.getElementById('wiki-zuchter-submit');
const fd = new FormData(form);
const data = {
rasse_slug: slug,
name: fd.get('name'),
zwingername: fd.get('zwingername') || null,
ort: fd.get('ort') || null,
plz: fd.get('plz') || null,
bundesland: fd.get('bundesland') || null,
vdh_mitglied: fd.get('vdh_mitglied') === '1',
website: fd.get('website') || null,
telefon: fd.get('telefon') || null,
beschreibung: fd.get('beschreibung') || null,
};
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet…';
const ok = await _submitZuchter(data);
if (ok) {
form.reset();
document.getElementById('wiki-zuchter-success').style.display = '';
// Hide submit row
submitBtn.closest('div[style*="flex"]').style.display = 'none';
} else {
UI.toast.error('Fehler beim Einsenden. Bitte versuche es erneut.');
submitBtn.disabled = false;
submitBtn.textContent = 'Eintragen';
}
});
}
async function _openBreedDetail(slug) {
let rasse;
try {
@ -402,54 +709,69 @@ window.Page_wiki = (() => {
return;
}
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
// Temperament chips
const chips = rasse.temperament
? rasse.temperament.split(',').map(t => `<span class="wiki-trait-chip">${_esc(t.trim())}</span>`).join('')
: '';
// Stats row
const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg)
? `${rasse.gewicht_min_kg}${rasse.gewicht_max_kg} kg`
: (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '—');
const photoHtml = rasse.foto_url
? `<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}" onerror="this.style.display='none'">`
? `<div class="wiki-detail-hero-photo-wrap">
<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}" onerror="this.parentElement.style.display='none'">
</div>`
: '';
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
const body = `
${photoHtml}
<div class="wiki-detail-badges">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(rasse.groesse)}">${_groesseLabel(rasse.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(rasse.aktivitaet)}">${_aktivLabel(rasse.aktivitaet)}</span>
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(rasse.erfahrung)}">${_erfahrungLabel(rasse.erfahrung)}</span>
${rasse.gruppe ? `<span class="badge">${_esc(rasse.gruppe)}</span>` : ''}
${/* 1. Hero */ ''}
<div class="wiki-detail-hero" style="text-align:center;margin-bottom:var(--space-4)">
${photoHtml}
<h1 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:var(--space-2) 0 var(--space-1)">${_esc(rasse.name)}</h1>
${rasse.herkunft ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${_esc(rasse.herkunft)}</div>` : ''}
${rasse.gruppe ? `<div style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:2px">${_esc(rasse.gruppe)}</div>` : ''}
</div>
${rasse.herkunft || rasse.bred_for ? `
<div class="wiki-detail-section">
${rasse.herkunft ? `<div class="wiki-detail-label">Herkunft</div><p>${_esc(rasse.herkunft)}</p>` : ''}
${rasse.bred_for ? `<div class="wiki-detail-label">Ursprüngliche Aufgabe</div><p>${_esc(rasse.bred_for)}</p>` : ''}
</div>` : ''}
${chips ? `
${/* 2. Charakter-Badges */ chips ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Charakter</div>
<div class="wiki-trait-chips">${chips}</div>
</div>` : ''}
<div class="wiki-stat-row">
<div class="wiki-stat-item">
<span class="wiki-stat-label">Gewicht</span>
<span class="wiki-stat-value">${gewicht}</span>
${/* 3. Beschreibung */ rasse.beschreibung ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Beschreibung</div>
<p style="line-height:1.6;color:var(--c-text)">${_esc(rasse.beschreibung)}</p>
</div>` : (rasse.bred_for ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Ursprüngliche Aufgabe</div>
<p style="line-height:1.6;color:var(--c-text)">${_esc(rasse.bred_for)}</p>
</div>` : '')}
${/* 4. Steckbrief */ _renderSteckbriefGrid(rasse)}
${/* 5. Vorkommen */ rasse.vorkommen_de ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Vorkommen in Deutschland</div>
<p style="line-height:1.6;color:var(--c-text)">${_esc(rasse.vorkommen_de)}</p>
</div>` : ''}
${/* 6. Interesse — wird async befüllt */ `
<div id="wiki-interesse-placeholder">
<div class="wiki-detail-section" style="opacity:0.5">
<div class="wiki-detail-label">In der Community</div>
<div style="height:60px;background:var(--c-surface-2);border-radius:var(--radius-md)"></div>
</div>
<div class="wiki-stat-item">
<span class="wiki-stat-label">Lebenserwartung</span>
<span class="wiki-stat-value">${_esc(rasse.lebensdauer || '—')}</span>
</div>`}
${/* 7. Züchter — wird async befüllt */ `
<div id="wiki-zuchter-placeholder">
<div class="wiki-detail-section" style="opacity:0.5">
<div class="wiki-detail-label">Züchter</div>
<div style="height:40px;background:var(--c-surface-2);border-radius:var(--radius-md)"></div>
</div>
</div>
<div class="wiki-fit-row">
<span>${UI.icon('house-line')} Wohnung: ${rasse.wohnung_geeignet ? UI.icon('check') : UI.icon('x')}</span>
<span>${UI.icon('users')} Kinder: ${rasse.kinder_geeignet ? UI.icon('check') : UI.icon('x')}</span>
</div>
</div>`}
${/* 8. Community-Berichte */ `
<div class="wiki-detail-section" id="wiki-berichte-section">
<div class="wiki-detail-label">Community-Berichte</div>
${berichteHtml}
@ -465,11 +787,30 @@ window.Page_wiki = (() => {
<button class="btn btn-ghost w-full" id="wiki-foto-submit-btn" style="font-size:var(--text-sm)">
${UI.icon('camera')} ${rasse.foto_url ? 'Besseres Foto vorschlagen' : 'Foto hinzufügen'}
</button>
</div>` : ''}
</div>` : ''}`}
`;
UI.modal.open({ title: _esc(rasse.name), body });
// Async: load stats + züchter in parallel
Promise.all([_fetchStats(slug), _fetchZuchter(slug)]).then(([stats, zuchter]) => {
const interessePlaceholder = document.getElementById('wiki-interesse-placeholder');
if (interessePlaceholder) {
interessePlaceholder.outerHTML = _renderInteresseSection(stats, slug);
_bindInteresseButtons(slug);
}
const zuchterPlaceholder = document.getElementById('wiki-zuchter-placeholder');
if (zuchterPlaceholder) {
zuchterPlaceholder.outerHTML = _renderZuchterSection(zuchter || [], slug);
_bindZuchterForm(slug);
}
}).catch(() => {
// Silently remove placeholders on error
document.getElementById('wiki-interesse-placeholder')?.remove();
document.getElementById('wiki-zuchter-placeholder')?.remove();
});
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);