banyaro/backend/static/js/pages/wiki.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

1473 lines
64 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Hunde-Wiki
Rassen-Datenbank, Gesundheit, Recht, Quiz
============================================================ */
window.Page_wiki = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _tab = 'rassen';
let _rassen = [];
let _gruppen = [];
let _totalBreeds = 0;
let _currentOffset = 0;
const PAGE_SIZE = 30;
let _currentSearch = '';
let _currentGruppe = '';
let _quizAnswers = {};
let _quizStep = 0;
// ----------------------------------------------------------
// HARDCODED: Gesundheits-Inhalte
// ----------------------------------------------------------
const GESUNDHEIT = [
{
titel: 'Zecken & FSME',
icon: 'skull',
text: 'Zecken sind von März bis November aktiv (Spitze AprilJuni, SeptemberOktober). Täglich nach Gassi auf Zecken untersuchen — besonders Ohren, Achseln, Leiste.\n\nZecke entfernen: Zeckenzange ansetzen, nicht drehen, gerade herausziehen. KEINE Öle/Vaseline.\n\nFSME: Impfung für Menschen empfohlen in Risikogebieten (RKI-Karte: rki.de/fsme). Hunde können Borreliose bekommen — Impfung empfohlen.',
},
{
titel: 'Vergiftungen — Sofortmaßnahmen',
icon: 'skull',
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',
icon: 'warning',
text: 'Symptome: Starkes Hecheln, Speichelfluss, taumeln, Kollaps.\n\nSofortmaßnahme: In den Schatten, mit lauwarmem (nicht kaltem!) Wasser abkühlen, sofort zum Tierarzt.\n\nHunde NIEMALS im Auto lassen.',
},
{
titel: 'Erste Hilfe Grundlagen',
icon: 'first-aid',
text: 'Bewusstloser Hund: Atemwege frei? Atemkontrolle.\n\nHerzdruckmassage: 100120/min, 1/3 Brusttiefe.\n\nBeatmung: Maul zu, in Nase blasen.\n\nBlutung: Druckverband.\n\nKnochenbruch: Immobilisieren, tragen.',
},
];
// ----------------------------------------------------------
// HARDCODED: Recht & Regeln
// ----------------------------------------------------------
const RECHT = [
{ land: 'Bayern', leine: 'Anleinpflicht im Wald und in Ortschaften', rasse: 'Keine allgemeine Rasseliste (Gefährlichkeitsfeststellung individuell)', steuer: '~100€/Jahr (variiert nach Gemeinde)' },
{ land: 'Baden-Württemberg', leine: 'Leinenpflicht in Ortschaften und Parks', rasse: 'American Pitbull Terrier, American Staffordshire Terrier u.a.', steuer: '~100150€/Jahr' },
{ land: 'Berlin', leine: 'Allgemeine Leinenpflicht in öffentlichen Anlagen', rasse: 'Pitbull, Staffordshire, Rottweiler (bedingt)', steuer: '~120€/Jahr (ab 2. Hund: 180€)' },
{ land: 'Brandenburg', leine: 'Leinenpflicht in Ortschaften und Wäldern AprilJuli', rasse: 'Pitbull, American Staffordshire u.a.', steuer: '~60100€/Jahr' },
{ land: 'Hamburg', leine: 'Allgemeine Leinenpflicht', rasse: 'Pitbull, Rottweiler, Staffordshire u.a.', steuer: '~90€/Jahr (Kampfhund: 900€)' },
{ land: 'Hessen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a. (Liste)', steuer: '~75120€/Jahr' },
{ land: 'NRW', leine: 'Leinenpflicht in bebauten Gebieten', rasse: 'Pitbull, American Staffordshire, Staffordshire Bull Terrier u.a.', steuer: '~100160€/Jahr' },
{ land: 'Niedersachsen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60100€/Jahr' },
{ land: 'Sachsen', leine: 'Leinenpflicht in Ortschaften und öffentl. Anlagen', rasse: 'Keine staatliche Liste (kommunal)', steuer: '~50100€/Jahr' },
{ land: 'Thüringen', leine: 'Anleinpflicht in Wäldern und Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60100€/Jahr' },
];
// ----------------------------------------------------------
// QUIZ: Fragen
// ----------------------------------------------------------
const QUIZ_FRAGEN = [
{ key: 'groesse', frage: 'Welche Größe passt zu dir?', optionen: [{val:'klein', label:'Klein (unter 10 kg)'}, {val:'mittel', label:'Mittel (1030 kg)'}, {val:'gross', label:'Groß (über 30 kg)'}] },
{ key: 'aktivitaet', frage: 'Wie aktiv bist du?', optionen: [{val:'niedrig', label:'Eher gemütlich'}, {val:'mittel', label:'Regelmäßige Spaziergänge'}, {val:'hoch', label:'Sehr sportlich'}] },
{ key: 'erfahrung', frage: 'Wie viel Hundeerfahrung hast du?', optionen: [{val:'anfaenger', label:'Ersthundehalter'}, {val:'fortgeschritten', label:'Erfahren'}, {val:'experte', label:'Profi'}] },
{ key: 'kinder', frage: 'Lebst du mit Kindern zusammen?', optionen: [{val:'true', label:'Ja'}, {val:'false', label:'Nein'}] },
{ key: 'wohnung', frage: 'Wo wohnst du?', optionen: [{val:'true', label:'Wohnung (ohne Garten)'}, {val:'false', label:'Haus mit Garten'}] },
];
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
// Wiki ist nicht hunde-spezifisch, kein Reload nötig
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
async function _render() {
const isMod = _appState.user && (_appState.user.is_moderator || _appState.user.rolle === 'admin');
_container.innerHTML = `
<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" class="badge badge-sm hidden">0</span></button>` : ''}
</div>
<div id="wiki-content"></div>
`;
_container.querySelector('#wiki-tab-bar').addEventListener('click', e => {
const btn = e.target.closest('[data-tab]');
if (!btn) return;
_tab = btn.dataset.tab;
_container.querySelectorAll('.by-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab));
_renderTab();
});
await _renderTab();
}
async function _renderTab() {
const content = _container.querySelector('#wiki-content');
if (!content) return;
if (_tab === 'rassen') await _renderRassen(content);
else if (_tab === 'gesundheit') _renderGesundheit(content);
else if (_tab === 'recht') _renderRecht(content);
else if (_tab === 'quiz') _renderQuiz(content);
else if (_tab === 'fotos') await _renderFotoSubmissions(content);
}
// ----------------------------------------------------------
// TAB: Foto-Einreichungen (Mod/Admin)
// ----------------------------------------------------------
async function _renderFotoSubmissions(el) {
el.innerHTML = `<div class="p-4">${UI.skeleton(3)}</div>`;
let subs;
try {
subs = await _apiFetch('/api/wiki/foto-submissions');
} catch (e) {
el.innerHTML = `<div class="empty-state"><p>${_esc(e.message)}</p></div>`;
return;
}
// Badge updaten
const badge = document.getElementById('wiki-fotos-badge');
if (badge) { badge.textContent = subs.length; badge.style.display = subs.length ? '' : 'none'; }
if (!subs.length) {
el.innerHTML = `
<div class="empty-state" style="padding:var(--space-10)">
${UI.icon('check')}
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>
</div>`;
return;
}
el.innerHTML = `
<div class="p-4">
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);margin-bottom:var(--space-4)">
Ausstehende Fotos (${subs.length})
</h3>
<div id="wiki-subs-list">
${subs.map(s => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-3)" id="wiki-sub-${s.id}">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<img src="${_esc(s.foto_url)}" alt=""
style="width:100px;height:80px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0;background:var(--c-surface-2)">
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold)">${_esc(s.rasse_name)}</div>
<div class="text-xs-muted">
von ${_esc(s.user_name)} · ${_formatDate(s.created_at)}
</div>
${s.aktuell_foto
? `<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);margin-top:4px">Kein Foto vorhanden</div>`
}
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button class="btn btn-primary btn-sm flex-1"
onclick="Page_wiki._approveSubmission(${s.id})">
${UI.icon('check')} Freischalten
</button>
<button class="btn btn-ghost btn-sm flex-1"
onclick="Page_wiki._rejectSubmission(${s.id})">
${UI.icon('x')} Ablehnen
</button>
</div>
</div>
`).join('')}
</div>
</div>
`;
}
async function _approveSubmission(id) {
try {
await _apiPatch(`/api/wiki/foto-submissions/${id}`, { action: 'approve' });
document.getElementById(`wiki-sub-${id}`)?.remove();
UI.toast('Foto freigeschaltet!', 'success');
const badge = document.getElementById('wiki-fotos-badge');
if (badge) {
const n = Math.max(0, parseInt(badge.textContent || '0') - 1);
badge.textContent = n; badge.style.display = n ? '' : 'none';
}
} catch (e) { UI.toast(e.message, 'danger'); }
}
async function _rejectSubmission(id) {
const reason = prompt('Ablehnungsgrund (optional):') ?? null;
if (reason === null) return; // Abbrechen
try {
await _apiPatch(`/api/wiki/foto-submissions/${id}`, { action: 'reject', reject_reason: reason });
document.getElementById(`wiki-sub-${id}`)?.remove();
UI.toast('Einreichung abgelehnt.', 'info');
} catch (e) { UI.toast(e.message, 'danger'); }
}
// ----------------------------------------------------------
// TAB: Rassen
// ----------------------------------------------------------
async function _renderRassen(el) {
// Check seeding state first
let stats;
try {
stats = await _apiFetch('/api/wiki/stats');
} catch {
el.innerHTML = UI.emptyState({ icon: UI.icon('warning'), title: 'Fehler', text: 'Wiki konnte nicht geladen werden.' });
return;
}
if (!stats.seeded) {
el.innerHTML = `
<div class="wiki-loading-state">
<div class="wiki-loading-spinner"></div>
<p class="wiki-loading-text">Rassen-Datenbank wird geladen… ${UI.icon('dog')}</p>
<p class="wiki-loading-hint">Beim ersten Start werden ~170 Rassen von TheDogAPI abgerufen.</p>
</div>
`;
return;
}
// Reset state when re-rendering the tab fresh
_rassen = [];
_currentOffset = 0;
_currentSearch = '';
_currentGruppe = '';
el.innerHTML = `
<div class="wiki-filter-bar">
<input class="form-control wiki-search-input" type="search" id="wiki-rassen-search" placeholder="Rasse suchen…">
<select class="form-control wiki-gruppe-select" id="wiki-gruppe-select">
<option value="">Alle Gruppen</option>
</select>
</div>
<div style="padding:0 0 var(--space-3)">
<button class="btn btn-secondary w-full" id="wiki-rasse-erkennen-btn"
class="text-sm">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
Welche Rasse ist das? — Foto analysieren
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="wiki-rasse-foto-input" class="hidden">
</div>
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
</div>
`;
// Load initial batch (also populates gruppen)
await _loadBreeds(el, true);
// Rassen-Erkennung per KI
_bindWikiRasseErkennung(el);
// Search handler with debounce
let _searchTimer;
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_currentSearch = e.target.value;
_rassen = [];
_currentOffset = 0;
_loadBreeds(el, true);
}, 300);
});
// Gruppe filter handler
el.querySelector('#wiki-gruppe-select').addEventListener('change', e => {
_currentGruppe = e.target.value;
_rassen = [];
_currentOffset = 0;
_loadBreeds(el, true);
});
// "Mehr laden" button
el.querySelector('#wiki-mehr-btn').addEventListener('click', () => {
_loadBreeds(el, false);
});
}
async function _loadBreeds(el, reset) {
const grid = el.querySelector('#wiki-breed-grid');
const mehrWrap = el.querySelector('#wiki-mehr-wrap');
const mehrBtn = el.querySelector('#wiki-mehr-btn');
if (!grid) return;
if (reset) {
grid.innerHTML = `<div class="wiki-breeds-loading">Lade Rassen…</div>`;
}
const params = new URLSearchParams({
search: _currentSearch,
gruppe: _currentGruppe,
limit: PAGE_SIZE,
offset: _currentOffset,
});
let data;
try {
data = await _apiFetch(`/api/wiki/rassen?${params}`);
} catch {
grid.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Rassen konnten nicht geladen werden.</p>`;
return;
}
// Populate Gruppen dropdown (only on first load)
if (reset && data.gruppen && data.gruppen.length) {
_gruppen = data.gruppen;
const sel = el.querySelector('#wiki-gruppe-select');
if (sel) {
// Preserve current selection
const cur = _currentGruppe;
sel.innerHTML = `<option value="">Alle Gruppen</option>` +
_gruppen.map(g => `<option value="${_esc(g)}"${g === cur ? ' selected' : ''}>${_esc(g)}</option>`).join('');
}
}
if (reset) {
_rassen = data.breeds;
_totalBreeds = data.total;
grid.innerHTML = '';
} else {
_rassen = _rassen.concat(data.breeds);
_currentOffset += data.breeds.length;
}
if (reset) {
_currentOffset = data.breeds.length;
}
if (reset && _rassen.length === 0) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Keine Rassen gefunden.</p>`;
if (mehrWrap) mehrWrap.style.display = 'none';
return;
}
// Render cards
const newCards = data.breeds.map(r => _breedCardHtml(r)).join('');
if (reset) {
grid.innerHTML = newCards;
} else {
grid.insertAdjacentHTML('beforeend', newCards);
}
// Attach click handlers to newly added cards
grid.querySelectorAll('.wiki-breed-card:not([data-bound])').forEach(card => {
card.dataset.bound = '1';
card.addEventListener('click', () => _openBreedDetail(card.dataset.slug));
});
// Show/hide "Mehr laden"
if (mehrWrap) {
const shown = _rassen.length;
mehrWrap.style.display = shown < _totalBreeds ? 'block' : 'none';
if (mehrBtn) mehrBtn.textContent = `Mehr laden (${_totalBreeds - shown} weitere)`;
}
}
const _DOG_SILHOUETTE = `<svg class="ph-icon" aria-hidden="true" style="width:2.5rem;height:2.5rem"><use href="/icons/phosphor.svg#dog"></use></svg>`;
function _breedCardHtml(r) {
const fotoUrl = r.foto_url || r.user_foto || '';
// Für lokale Bilder: _preview.webp zuerst, bei Fehler Original nachladen
const srcUrl = fotoUrl.startsWith('/media/')
? fotoUrl.replace(/\.(jpe?g|png|gif|webp)$/i, '_preview.webp')
: fotoUrl;
const photoHtml = fotoUrl
? `<img class="wiki-breed-photo" src="${_esc(srcUrl)}" loading="lazy" alt="${_esc(r.name)}"
onerror="if(this.src.includes('_preview')){this.src='${_esc(fotoUrl)}'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}">`
: '';
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
return `
<div class="wiki-breed-card" data-slug="${_esc(r.slug)}">
<div class="wiki-breed-photo-wrap">
${photoHtml}
${fallbackHtml}
</div>
<div class="wiki-breed-card-body">
<div class="wiki-breed-card-name">${_esc(r.name)}</div>
<div class="wiki-breed-card-gruppe">${_esc(r.gruppe || '—')}</div>
<div class="wiki-breed-badges">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(r.erfahrung)}">${_erfahrungLabel(r.erfahrung)}</span>
</div>
</div>
</div>
`;
}
// ----------------------------------------------------------
// 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 class="text-success">&#10003; Ja</span>`
: rasse.kinder_geeignet === false
? `<span style="color:var(--c-warning)">&#9889; Bedingt</span>`
: '&mdash;';
const wohnungLabel = rasse.wohnung_geeignet
? `<span class="text-success">&#10003; Ja</span>`
: `<span class="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 class="flex-gap-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 class="text-sm-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" class="mt-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 {
rasse = await _apiFetch(`/api/wiki/rassen/${slug}`);
} catch {
UI.toast.error('Rasse konnte nicht geladen werden.');
return;
}
// Temperament chips
const chips = rasse.temperament
? rasse.temperament.split(',').map(t => `<span class="wiki-trait-chip">${_esc(t.trim())}</span>`).join('')
: '';
const _dogSvgLg = _DOG_SILHOUETTE.replace('width="48" height="48"', 'width="56" height="56"');
// Alle Fotos: Hauptbild zuerst, dann Community-Fotos
const allFotos = [];
if (rasse.foto_url) allFotos.push({ foto_url: rasse.foto_url, user_name: null });
(rasse.user_fotos || []).forEach(f => allFotos.push(f));
const photoHtml = allFotos.length
? `<div class="wiki-gallery-wrap">
<img class="wiki-detail-photo wiki-gallery-main" id="wiki-main-photo"
src="${_esc(allFotos[0].foto_url)}" alt="${_esc(rasse.name)}"
onerror="this.style.display='none';document.getElementById('wiki-photo-fallback').style.display='flex'">
<div id="wiki-photo-fallback" class="wiki-detail-photo-placeholder hidden">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>
${allFotos.length > 1 ? `
<div class="wiki-gallery-strip" id="wiki-gallery-strip">
${allFotos.map((f, i) => `
<button class="wiki-gallery-thumb${i === 0 ? ' active' : ''}" data-idx="${i}"
aria-label="Foto ${i + 1}">
<img src="${_esc(f.foto_url.startsWith('/media/') ? f.foto_url.replace(/\.(jpe?g|png|gif|webp)$/i,'_preview.webp') : f.foto_url)}"
alt="" loading="lazy"
onerror="if(this.src.includes('_preview')){this.src='${_esc(f.foto_url)}'}else{this.style.display='none'}">
${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${_esc(f.user_name)}</span>` : ''}
</button>`).join('')}
</div>` : ''}
<button class="wiki-gallery-expand" id="wiki-gallery-expand" aria-label="Vollbild">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrows-out"></use></svg>
</button>
</div>`
: `<div class="wiki-detail-photo-placeholder">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>`;
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
const userFotosHtml = '';
const body = `
${/* 1. Hero */ ''}
<div class="wiki-detail-hero" style="text-align:center;margin-bottom:var(--space-4)">
${photoHtml}
${userFotosHtml}
<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 class="text-sm-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>
${/* 2. Charakter-Badges */ chips ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Charakter</div>
<div class="wiki-trait-chips">${chips}</div>
</div>` : ''}
${/* 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>`}
${/* 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>`}
${/* 8. Community-Berichte */ `
<div class="wiki-detail-section" id="wiki-berichte-section">
<div class="wiki-detail-label">Community-Berichte</div>
${berichteHtml}
</div>
${_appState.user
? `<button class="btn btn-secondary w-full" id="wiki-bericht-add-btn" class="mt-3">+ Eigenen Bericht hinzufügen</button>`
: `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:var(--space-3)">
<a href="#settings" class="text-primary">Anmelden</a>, um einen Bericht zu schreiben.
</p>`
}
${_appState.user ? `
<div style="margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--c-border-light)">
<button class="btn btn-ghost w-full" id="wiki-foto-submit-btn" class="text-sm">
${UI.icon('camera')} ${rasse.foto_url ? 'Besseres Foto vorschlagen' : 'Foto hinzufügen'}
</button>
</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();
});
// Gallery-Thumbnails + Lightbox
const mainImg = document.getElementById('wiki-main-photo');
const strip = document.getElementById('wiki-gallery-strip');
if (strip && mainImg) {
strip.querySelectorAll('.wiki-gallery-thumb').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx);
mainImg.src = allFotos[idx].foto_url;
mainImg.style.display = '';
document.getElementById('wiki-photo-fallback').style.display = 'none';
strip.querySelectorAll('.wiki-gallery-thumb').forEach(b => b.classList.toggle('active', b === btn));
});
});
}
document.getElementById('wiki-gallery-expand')?.addEventListener('click', () => {
const src = mainImg?.src || allFotos[0]?.foto_url;
if (!src) return;
let curIdx = allFotos.findIndex(f => f.foto_url && src.endsWith(f.foto_url.split('/').pop()));
if (curIdx < 0) curIdx = 0;
function _lbOpen(idx) {
const f = allFotos[idx];
const lb = document.getElementById('wiki-lightbox');
lb.querySelector('.wlb-img').src = f.foto_url;
lb.querySelector('.wlb-caption').textContent = f.user_name ? `Foto von ${f.user_name}` : rasse.name;
lb.querySelector('.wlb-counter').textContent = `${idx + 1} / ${allFotos.length}`;
lb.querySelector('.wlb-prev').style.display = allFotos.length > 1 ? '' : 'none';
lb.querySelector('.wlb-next').style.display = allFotos.length > 1 ? '' : 'none';
curIdx = idx;
}
const lb = document.createElement('div');
lb.id = 'wiki-lightbox';
lb.innerHTML = `
<div class="wlb-backdrop"></div>
<div class="wlb-content">
<button class="wlb-close" aria-label="Schließen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
<button class="wlb-prev" aria-label="Zurück"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-left"></use></svg></button>
<img class="wlb-img" src="" alt="">
<button class="wlb-next" aria-label="Weiter"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#caret-right"></use></svg></button>
<div class="wlb-caption"></div>
<div class="wlb-counter"></div>
</div>`;
document.body.appendChild(lb);
_lbOpen(curIdx);
const close = () => lb.remove();
lb.querySelector('.wlb-close').addEventListener('click', close);
lb.querySelector('.wlb-backdrop').addEventListener('click', close);
lb.querySelector('.wlb-prev').addEventListener('click', () => _lbOpen((curIdx - 1 + allFotos.length) % allFotos.length));
lb.querySelector('.wlb-next').addEventListener('click', () => _lbOpen((curIdx + 1) % allFotos.length));
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
if (e.key === 'ArrowLeft') lb.querySelector('.wlb-prev').click();
if (e.key === 'ArrowRight') lb.querySelector('.wlb-next').click();
});
});
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
});
document.getElementById('wiki-foto-submit-btn')?.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _showFotoSubmitForm(slug, rasse.name), 350);
});
}
// ----------------------------------------------------------
// Foto vorschlagen
// ----------------------------------------------------------
function _showFotoSubmitForm(slug, rasseName) {
const body = `
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-4)">
Dein Foto wird nach einer kurzen Prüfung freigeschaltet und als Hauptbild im Wiki verwendet.
</p>
<form id="wiki-foto-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Foto von <strong>${_esc(rasseName)}</strong></label>
<input class="form-control" type="file" id="wiki-foto-input"
accept="image/jpeg,image/png,image/webp" required>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
JPG, PNG oder WebP · max. 8 MB · möglichst hochauflösend
</div>
</div>
<div id="wiki-foto-preview" style="margin-top:var(--space-3);display:none">
<img id="wiki-foto-preview-img" style="max-width:100%;max-height:200px;border-radius:var(--radius-md);object-fit:contain">
</div>
<div style="margin-top:var(--space-4);padding:var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
border-left:3px solid var(--c-warning)">
<label style="display:flex;gap:var(--space-3);align-items:flex-start;cursor:pointer">
<input type="checkbox" id="wiki-foto-rights" style="margin-top:3px;flex-shrink:0">
<span style="font-size:var(--text-sm);line-height:1.5">
Ich bestätige, dass ich die <strong>uneingeschränkten Bildrechte</strong> an diesem Foto besitze
und banyaro.app das dauerhaft gültige, kostenlose Recht einräume, es zu veröffentlichen und zu
verwenden. Fotos aus dem Internet oder von Dritten dürfen nicht eingereicht werden.
</span>
</label>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wiki-foto-cancel">Abbrechen</button>
<button type="submit" form="wiki-foto-form" class="btn btn-primary flex-1" id="wiki-foto-submit" disabled>
${UI.icon('paper-plane-tilt')} Einreichen
</button>
`;
UI.modal.open({ title: 'Foto vorschlagen', body, footer });
document.getElementById('wiki-foto-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('wiki-foto-rights')?.addEventListener('change', e => {
document.getElementById('wiki-foto-submit').disabled = !e.target.checked;
});
document.getElementById('wiki-foto-input')?.addEventListener('change', e => {
const file = e.target.files?.[0];
if (!file) return;
const preview = document.getElementById('wiki-foto-preview');
const img = document.getElementById('wiki-foto-preview-img');
const url = URL.createObjectURL(file);
img.src = url;
preview.style.display = '';
});
document.getElementById('wiki-foto-form')?.addEventListener('submit', async e => {
e.preventDefault();
const input = document.getElementById('wiki-foto-input');
const file = input?.files?.[0];
if (!file) return;
if (!document.getElementById('wiki-foto-rights')?.checked) {
UI.toast('Bitte Bildrechte bestätigen.', 'danger');
return;
}
const btn = document.getElementById('wiki-foto-submit');
btn.disabled = true;
btn.textContent = 'Wird hochgeladen…';
try {
const fd = new FormData();
fd.append('file', file);
fd.append('rights_confirmed', '1');
const token = localStorage.getItem('by_token');
const resp = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/foto`, {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Upload fehlgeschlagen');
}
UI.modal.close();
UI.toast('Danke! Dein Foto wird geprüft und dann veröffentlicht.', 'success');
} catch (err) {
UI.toast(err.message, 'danger');
btn.disabled = false;
btn.innerHTML = `${UI.icon('paper-plane-tilt')} Einreichen`;
}
});
}
function _renderBerichteHtml(berichte, slug) {
if (!berichte || berichte.length === 0) {
return `<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">Noch keine Community-Berichte für diese Rasse.</p>`;
}
return berichte.map(b => `
<div class="wiki-bericht-item" data-id="${b.id}">
<div class="wiki-bericht-header">
<span class="wiki-bericht-autor">${_esc(b.autor)}</span>
<span class="wiki-bericht-date">${_formatDate(b.created_at)}</span>
${_appState.user && _appState.user.name === b.autor
? `<button class="btn btn-danger btn-xs wiki-bericht-del" data-id="${b.id}" data-slug="${_esc(slug)}" style="margin-left:auto;padding:2px 8px;font-size:0.7rem">Löschen</button>`
: ''}
</div>
<div class="wiki-bericht-titel">${_esc(b.titel)}</div>
<p class="wiki-bericht-text">${_esc(b.text)}</p>
</div>
`).join('');
}
function _showBerichtForm(slug, rasseName) {
const body = `
<form id="wiki-bericht-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Rasse</label>
<input class="form-control" type="text" value="${_esc(rasseName)}" disabled>
</div>
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-control" type="text" name="titel" maxlength="120" placeholder="z.B. Mein Erfahrungsbericht" required>
</div>
<div class="form-group">
<label class="form-label">Bericht</label>
<textarea class="form-control" name="text" rows="6" placeholder="Deine Erfahrungen mit dieser Rasse…" required></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wiki-bericht-cancel">Abbrechen</button>
<button type="submit" form="wiki-bericht-form" class="btn btn-primary flex-1">Veröffentlichen</button>
`;
UI.modal.open({ title: 'Bericht schreiben', body, footer });
document.getElementById('wiki-bericht-cancel')?.addEventListener('click', UI.modal.close);
const form = document.getElementById('wiki-bericht-form');
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
form.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="wiki-bericht-form"][type="submit"]');
const fd = UI.formData(form);
await UI.asyncButton(submitBtn, async () => {
try {
await _apiPost('/api/wiki/berichte', { rasse: slug, titel: fd.titel, text: fd.text });
UI.modal.close();
UI.toast.success('Bericht veröffentlicht!');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Veröffentlichen.');
}
});
});
}
// ----------------------------------------------------------
// TAB: Gesundheit
// ----------------------------------------------------------
function _renderGesundheit(el) {
const items = GESUNDHEIT.map((s, i) => `
<div class="wiki-section" data-idx="${i}">
<div class="wiki-section-header">
<span class="wiki-section-icon">${UI.icon(s.icon)}</span>
<span class="wiki-section-titel">${_esc(s.titel)}</span>
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
</div>
<div class="wiki-section-body hidden">
<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_esc(s.text)}</p>
</div>
</div>
`).join('');
el.innerHTML = `<div class="wiki-accordion">${items}</div>`;
el.querySelectorAll('.wiki-section').forEach(sec => {
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
const body = sec.querySelector('.wiki-section-body');
const arrow = sec.querySelector('.wiki-section-arrow');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
sec.classList.toggle('open', !open);
});
});
}
// ----------------------------------------------------------
// TAB: Recht & Regeln
// ----------------------------------------------------------
function _renderRecht(el) {
const items = RECHT.map((r, i) => `
<div class="wiki-section" data-idx="${i}">
<div class="wiki-section-header">
<span class="wiki-section-icon">${UI.icon('map-pin')}</span>
<span class="wiki-section-titel">${_esc(r.land)}</span>
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
</div>
<div class="wiki-section-body hidden">
<div class="wiki-recht-row"><span class="wiki-recht-label">Leinenpflicht</span><span>${_esc(r.leine)}</span></div>
<div class="wiki-recht-row"><span class="wiki-recht-label">Rasseliste</span><span>${_esc(r.rasse)}</span></div>
<div class="wiki-recht-row"><span class="wiki-recht-label">Hundesteuer</span><span>${_esc(r.steuer)}</span></div>
</div>
</div>
`).join('');
el.innerHTML = `
<div class="wiki-accordion">${items}</div>
<p class="wiki-disclaimer">Angaben ohne Gewähr — Regelungen ändern sich. Bitte beim zuständigen Ordnungsamt prüfen.</p>
`;
el.querySelectorAll('.wiki-section').forEach(sec => {
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
const body = sec.querySelector('.wiki-section-body');
const arrow = sec.querySelector('.wiki-section-arrow');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
sec.classList.toggle('open', !open);
});
});
}
// ----------------------------------------------------------
// TAB: Quiz
// ----------------------------------------------------------
function _renderQuiz(el) {
_quizAnswers = {};
_quizStep = 0;
_renderQuizStep(el);
}
function _renderQuizStep(el) {
if (_quizStep >= QUIZ_FRAGEN.length) {
_loadQuizResult(el);
return;
}
const frage = QUIZ_FRAGEN[_quizStep];
const progress = Math.round((_quizStep / QUIZ_FRAGEN.length) * 100);
const optionsHtml = frage.optionen.map(o => `
<button class="wiki-quiz-option${_quizAnswers[frage.key] === o.val ? ' selected' : ''}"
data-key="${_esc(frage.key)}" data-val="${_esc(o.val)}">
${_esc(o.label)}
</button>
`).join('');
el.innerHTML = `
<div class="wiki-quiz-wrap">
<div class="wiki-quiz-progress-bar">
<div class="wiki-quiz-progress" style="width:${progress}%"></div>
</div>
<p class="wiki-quiz-step-info">Frage ${_quizStep + 1} von ${QUIZ_FRAGEN.length}</p>
<p class="wiki-quiz-frage">${_esc(frage.frage)}</p>
<div class="wiki-quiz-options">${optionsHtml}</div>
<div class="wiki-quiz-nav">
${_quizStep > 0
? `<button class="btn btn-secondary" id="quiz-back">Zurück</button>`
: '<span></span>'}
</div>
</div>
`;
el.querySelectorAll('.wiki-quiz-option').forEach(btn => {
btn.addEventListener('click', () => {
_quizAnswers[btn.dataset.key] = btn.dataset.val;
_quizStep++;
_renderQuizStep(el);
});
});
el.querySelector('#quiz-back')?.addEventListener('click', () => {
_quizStep--;
const prevKey = QUIZ_FRAGEN[_quizStep].key;
delete _quizAnswers[prevKey];
_renderQuizStep(el);
});
}
async function _loadQuizResult(el) {
el.innerHTML = `<div style="text-align:center;padding:var(--space-8)">Berechne Ergebnis…</div>`;
const params = new URLSearchParams(_quizAnswers).toString();
let data;
try {
data = await _apiFetch(`/api/wiki/quiz/result?${params}`);
} catch {
el.innerHTML = UI.emptyState({ icon: UI.icon('warning'), title: 'Fehler', text: 'Ergebnis konnte nicht geladen werden.' });
return;
}
const cardsHtml = data.results.map(r => {
const photoHtml = r.foto_url
? `<img class="wiki-quiz-result-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none'">`
: `<div class="wiki-quiz-result-photo-fallback">${UI.icon('dog')}</div>`;
return `
<div class="wiki-quiz-result-card">
<div class="wiki-quiz-result-photo-wrap">${photoHtml}</div>
<div class="wiki-quiz-result-card-body">
<div class="wiki-quiz-result-name">${_esc(r.name)}</div>
<div class="wiki-quiz-result-gruppe">${_esc(r.gruppe || '')}</div>
<div class="wiki-breed-badges" style="margin:var(--space-2) 0">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
</div>
${r.temperament ? `<p class="wiki-quiz-result-char">${_esc(r.temperament.split(',').slice(0,4).join(', '))}</p>` : ''}
<div class="wiki-fit-row" style="font-size:var(--text-xs);margin-top:var(--space-1)">
<span>${UI.icon('house-line')} ${r.wohnung_geeignet ? 'Wohnung' : 'Haus'}</span>
<span>${UI.icon('users')} ${r.kinder_geeignet ? 'Kinderfreundlich' : 'Erfahrung nötig'}</span>
</div>
<button class="btn btn-secondary btn-sm wiki-quiz-mehr" data-slug="${_esc(r.slug)}" class="mt-2">Mehr erfahren</button>
</div>
</div>
`;
}).join('');
el.innerHTML = `
<div class="wiki-quiz-wrap">
<div class="wiki-quiz-progress-bar">
<div class="wiki-quiz-progress w-full"></div>
</div>
<h3 style="margin:var(--space-4) 0 var(--space-2);text-align:center">Deine Top 3 Rassen</h3>
<div class="wiki-quiz-results">${cardsHtml}</div>
<button class="btn btn-secondary w-full" id="quiz-restart" class="mt-4">Quiz neu starten</button>
</div>
`;
el.querySelectorAll('.wiki-quiz-mehr').forEach(btn => {
btn.addEventListener('click', () => {
_tab = 'rassen';
_container.querySelectorAll('.by-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 'rassen'));
_openBreedDetail(btn.dataset.slug);
});
});
el.querySelector('#quiz-restart')?.addEventListener('click', () => {
_renderQuiz(el);
});
}
// ----------------------------------------------------------
// HELPER: API-Fetch
// ----------------------------------------------------------
async function _apiFetch(url) {
const resp = await fetch(url, { credentials: 'include' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
async function _apiPatch(url, body) {
const token = localStorage.getItem('by_token');
const resp = await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) },
credentials: 'include',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
async function _apiPost(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
// ----------------------------------------------------------
// HELPER: Labels
// ----------------------------------------------------------
function _groesseLabel(g) {
return { klein: 'Klein', mittel: 'Mittel', gross: 'Groß', sehr_gross: 'Sehr groß' }[g] || g;
}
function _aktivLabel(a) {
return { niedrig: 'Ruhig', mittel: 'Aktiv', hoch: 'Sportlich', sehr_hoch: 'Sehr aktiv' }[a] || a;
}
function _erfahrungLabel(e) {
return { anfaenger: 'Anfänger', fortgeschritten: 'Erfahren', experte: 'Experte' }[e] || e;
}
function _formatDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return iso; }
}
function _esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// RASSEN-ERKENNUNG PER KI (Wiki-Tab)
// ----------------------------------------------------------
function _bindWikiRasseErkennung(el) {
const btn = el.querySelector('#wiki-rasse-erkennen-btn');
const fileInput = el.querySelector('#wiki-rasse-foto-input');
if (!btn || !fileInput) return;
btn.addEventListener('click', () => {
if (!_appState.user) {
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
return;
}
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
UI.toast('Bild zu groß (max. 5 MB).', 'danger');
return;
}
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert das Bild…`;
try {
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('by_token');
const resp = await fetch('/api/ki/rasse-erkennung', {
method: 'POST',
credentials: 'include',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: fd,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
btn.disabled = false;
btn.innerHTML = origHtml;
_showWikiRasseErgebnis(data);
} catch (e) {
btn.disabled = false;
btn.innerHTML = origHtml;
UI.toast(e.message || 'Fehler bei der Rassen-Erkennung.', 'danger');
}
});
}
function _showWikiRasseErgebnis(data) {
if (!data.ist_hund) {
UI.modal.open({
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p class="text-secondary">
Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch.
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
const rassen = data.rassen || [];
const cardsHtml = rassen.map((r, i) => {
const isTop = i === 0;
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div>
</div>
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
${r.wiki_slug ? `
<div class="mt-3">
<button class="btn btn-${isTop ? 'primary' : 'secondary'} btn-sm w-full"
data-action="wiki" data-slug="${_esc(r.wiki_slug)}">
Im Wiki nachschlagen
</button>
</div>` : ''}
</div>
`;
}).join('');
UI.modal.open({
title: 'Erkannte Rasse',
body: `
<div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
</p>
</div>
`,
footer: `<button class="btn btn-secondary" id="wiki-rasse-modal-schliessen">Schließen</button>`,
});
document.getElementById('wiki-rasse-modal-schliessen')
?.addEventListener('click', UI.modal.close);
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
btn.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _openBreedDetail(btn.dataset.slug), 300);
});
});
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, _approveSubmission, _rejectSubmission };
})();