Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration
- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push - Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push - Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge, forum, wiki, walks) vollständig auf Phosphor-Icons migriert - Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos) - TheDogAPI lokal gespiegelt (169 Rassen + Fotos) - Quiz-Result-Cards horizontal (korrekte Bildproportionen) - SW by-v89
This commit is contained in:
parent
96bd57f0ad
commit
097295c628
44 changed files with 9980 additions and 300 deletions
687
backend/static/js/pages/wiki.js
Normal file
687
backend/static/js/pages/wiki.js
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
/* ============================================================
|
||||
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 April–Juni, September–Oktober). 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: 100–120/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: '~100–150€/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 April–Juli', rasse: 'Pitbull, American Staffordshire u.a.', steuer: '~60–100€/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: '~75–120€/Jahr' },
|
||||
{ land: 'NRW', leine: 'Leinenpflicht in bebauten Gebieten', rasse: 'Pitbull, American Staffordshire, Staffordshire Bull Terrier u.a.', steuer: '~100–160€/Jahr' },
|
||||
{ land: 'Niedersachsen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60–100€/Jahr' },
|
||||
{ land: 'Sachsen', leine: 'Leinenpflicht in Ortschaften und öffentl. Anlagen', rasse: 'Keine staatliche Liste (kommunal)', steuer: '~50–100€/Jahr' },
|
||||
{ land: 'Thüringen', leine: 'Anleinpflicht in Wäldern und Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60–100€/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 (10–30 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() {
|
||||
_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>
|
||||
</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);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 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>`
|
||||
}
|
||||
`;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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 _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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue