banyaro/backend/static/js/pages/wiki.js
rene 32d630d5a1 Sprint 11b: Wiki-Foto-Einreichungen + Wikipedia-Foto-Scraper
- User können Fotos für Rassen vorschlagen (Upload-Modal in Rassen-Detail)
- Mod/Admin-Review-Tab im Wiki mit Freischalten/Ablehnen + Push-Notification
- wikipedia_photos.py: holt Fotos über Wikidata-QID → Wikipedia-API
- Foto-Status: 578 lokal, 186 extern, 238 ohne Foto
- DB: wiki_foto_submissions Tabelle
- SW by-v90
2026-04-15 22:01:58 +02:00

883 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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).\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="wiki-tab-bar" id="wiki-tab-bar">
<button class="wiki-tab-btn${_tab === 'rassen' ? ' active' : ''}" data-tab="rassen">${UI.icon('dog')} Rassen</button>
<button class="wiki-tab-btn${_tab === 'gesundheit'? ' active' : ''}" data-tab="gesundheit">${UI.icon('syringe')} Gesundheit</button>
<button class="wiki-tab-btn${_tab === 'recht' ? ' active' : ''}" data-tab="recht">${UI.icon('handshake')} Recht</button>
<button class="wiki-tab-btn${_tab === 'quiz' ? ' active' : ''}" data-tab="quiz">${UI.icon('star')} Quiz</button>
${isMod ? `<button class="wiki-tab-btn${_tab === 'fotos' ? ' active' : ''}" data-tab="fotos" id="wiki-fotos-tab">${UI.icon('camera')} Fotos <span id="wiki-fotos-badge" style="display:none" class="badge badge-sm">0</span></button>` : ''}
</div>
<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('.wiki-tab-btn').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 style="padding:var(--space-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 style="padding:var(--space-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 style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold)">${_esc(s.rasse_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-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,#e8a000);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: '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 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);
// 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)`;
}
}
function _breedCardHtml(r) {
const photoHtml = r.foto_url
? `<img class="wiki-breed-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: '';
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${r.foto_url ? 'display:none' : ''}">${UI.icon('dog')}</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>
`;
}
async function _openBreedDetail(slug) {
let rasse;
try {
rasse = await _apiFetch(`/api/wiki/rassen/${slug}`);
} catch {
UI.toast.error('Rasse konnte nicht geladen werden.');
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'">`
: '';
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>` : ''}
</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 ? `
<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>
</div>
<div class="wiki-stat-item">
<span class="wiki-stat-label">Lebenserwartung</span>
<span class="wiki-stat-value">${_esc(rasse.lebensdauer || '—')}</span>
</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 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" style="margin-top:var(--space-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" style="color:var(--c-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" style="font-size:var(--text-sm)">
${UI.icon('camera')} ${rasse.foto_url ? 'Besseres Foto vorschlagen' : 'Foto hinzufügen'}
</button>
</div>` : ''}
`;
UI.modal.open({ title: _esc(rasse.name), body });
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>
</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">
${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-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;
const btn = document.getElementById('wiki-foto-submit');
btn.disabled = true;
btn.textContent = 'Wird hochgeladen…';
try {
const fd = new FormData();
fd.append('file', file);
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" style="display:none">
<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" style="display:none">
<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: '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)}" style="margin-top:var(--space-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" style="width:100%"></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" style="margin-top:var(--space-4)">Quiz neu starten</button>
</div>
`;
el.querySelectorAll('.wiki-quiz-mehr').forEach(btn => {
btn.addEventListener('click', () => {
_tab = 'rassen';
_container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'rassen'));
_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;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, _approveSubmission, _rejectSubmission };
})();