Sprint 8: Events + Hundesitting

Events:
- Backend events.py: CRUD, Typen (ausstellung/training/treffen/markt/wettkampf/sonstiges)
  Haversine-Filter, Monats-Gruppierung in der Liste
- Frontend events.js: Liste/Karte-Toggle, Typ-Filter-Chips, farbige Marker,
  Detail-Modal, Erstellen/Bearbeiten-Formular mit GPS-Button

Hundesitting:
- Backend sitting.py: Sitter-Profile (create/update/me), Anfragen (send/accept/decline/cancel),
  Inbox für Sitter, Haversine-Sortierung, Service-Filter
- Frontend sitting.js: 3 Tabs (Suchen/Profil/Anfragen), Sitter-Karten mit Distanz,
  Detail-Modal + Anfrage-Formular, Profil-Verwaltung

DB: events, sitters, sitting_requests Tabellen hinzugefügt
SW-Cache: by-v21 → by-v22
This commit is contained in:
rene 2026-04-14 06:19:15 +02:00
parent ec17dfb029
commit 5f8fd3bd51
9 changed files with 1680 additions and 2 deletions

View file

@ -0,0 +1,419 @@
/* ============================================================
BAN YARO Events (Hundeveranstaltungen)
Liste/Karte · Filter · Erstellen/Bearbeiten
============================================================ */
const Page_events = (() => {
// ----------------------------------------------------------
// Konstanten
// ----------------------------------------------------------
const TYPEN = [
{ id: 'alle', label: 'Alle', icon: '🎪' },
{ id: 'ausstellung', label: 'Ausstellung', icon: '🏆' },
{ id: 'training', label: 'Training', icon: '🎓' },
{ id: 'treffen', label: 'Treffen', icon: '🐕' },
{ id: 'markt', label: 'Markt', icon: '🛍️' },
{ id: 'wettkampf', label: 'Wettkampf', icon: '🥇' },
{ id: 'sonstiges', label: 'Sonstiges', icon: '📌' },
];
const TYP_COLOR = {
ausstellung: '#8b5cf6',
training: '#3b82f6',
treffen: '#10b981',
markt: '#f59e0b',
wettkampf: '#ef4444',
sonstiges: '#6b7280',
};
// ----------------------------------------------------------
// State
// ----------------------------------------------------------
let _container = null;
let _state = null;
let _events = [];
let _filter = 'alle';
let _view = 'liste'; // liste | karte
let _map = null;
let _markers = [];
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_state = appState;
_render();
await _load();
}
async function refresh() {
await _load();
}
// ----------------------------------------------------------
// Render Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="events-toolbar">
<div class="events-view-toggle">
<button class="events-view-btn active" data-ev-view="liste"> Liste</button>
<button class="events-view-btn" data-ev-view="karte">🗺 Karte</button>
</div>
<div style="flex:1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">+ Event</button>` : ''}
</div>
<div class="events-filter-bar" id="ev-filter-bar">
${TYPEN.map(t => `
<button class="events-filter-btn ${t.id === 'alle' ? 'active' : ''}" data-ev-typ="${t.id}">
${t.icon} ${t.label}
</button>
`).join('')}
</div>
<div class="events-list" id="ev-list"></div>
<div class="events-map" id="ev-map" style="display:none"></div>
`;
_container.addEventListener('click', _onClick);
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _load() {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
listEl.innerHTML = UI.skeleton(3);
try {
_events = await API.events.list();
_renderList();
} catch (e) {
UI.toast(e.message, 'error');
}
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
function _renderList() {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
const filtered = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
if (!filtered.length) {
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
return;
}
// Monats-Gruppierung
const groups = {};
for (const ev of filtered) {
const d = new Date(ev.datum + 'T00:00:00');
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (!groups[key]) groups[key] = { label, items: [] };
groups[key].items.push(ev);
}
let html = '';
for (const key of Object.keys(groups).sort()) {
const g = groups[key];
html += `<div class="events-month-label">${g.label}</div>`;
for (const ev of g.items) {
html += _cardHTML(ev);
}
}
listEl.innerHTML = html;
if (_view === 'karte') _renderMap(filtered);
}
function _cardHTML(ev) {
const d = new Date(ev.datum + 'T00:00:00');
const dow = d.toLocaleDateString('de-DE', { weekday: 'short' });
const day = d.getDate();
const mon = d.toLocaleDateString('de-DE', { month: 'short' });
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const isOwn = _state.user?.id === ev.user_id;
return `
<div class="events-card" data-ev-id="${ev.id}" style="border-left-color:${color}">
<div class="events-date-badge">
<span class="day">${dow}</span>
<span class="num">${day}</span>
<span class="month">${mon}</span>
</div>
<div class="events-card-body">
<div class="events-card-title">${UI.escHtml(ev.titel)}</div>
<div class="events-card-meta">
<span class="events-badge" style="background:${color}20;color:${color}">${typ.icon} ${typ.label}</span>
${ev.uhrzeit ? `· ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· 📍 ${UI.escHtml(ev.ort_name)}` : ''}
</div>
</div>
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">✏️</button>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// Karte
// ----------------------------------------------------------
async function _renderMap(filtered) {
const mapEl = document.getElementById('ev-map');
if (!mapEl) return;
await _loadLeaflet();
if (!_map) {
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
} else {
_markers.forEach(m => m.remove());
_markers = [];
}
const bounds = [];
for (const ev of filtered) {
if (!ev.lat || !ev.lon) continue;
const color = TYP_COLOR[ev.typ] || '#6b7280';
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg)"><span style="transform:rotate(45deg)">${typ.icon}</span></div>`,
iconSize: [32, 32], iconAnchor: [16, 32],
});
const m = L.marker([ev.lat, ev.lon], { icon })
.addTo(_map)
.on('click', () => _showDetail(ev.id));
_markers.push(m);
bounds.push([ev.lat, ev.lon]);
}
if (bounds.length) _map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
setTimeout(() => _map.invalidateSize(), 50);
}
function _loadLeaflet() {
if (window.L) return Promise.resolve();
return new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/js/lib/leaflet.css';
document.head.appendChild(link);
const s = document.createElement('script');
s.src = '/js/lib/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
async function _showDetail(id) {
let ev;
try { ev = await API.events.get(id); } catch { return; }
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const isOwn = _state.user?.id === ev.user_id;
const body = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="events-badge" style="background:${color}20;color:${color};font-size:var(--text-sm)">${typ.icon} ${typ.label}</span>
</div>
<div class="events-detail-row">📅 ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">📍 ${UI.escHtml(ev.ort_name)}</div>` : ''}
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escHtml(ev.beschreibung)}</div>` : ''}
${ev.link ? `<div class="events-detail-row">🔗 <a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a></div>` : ''}
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}</div>
`;
const footer = isOwn ? `
<button class="btn btn-secondary" id="ev-detail-edit"> Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">🗑 Löschen</button>
` : '';
UI.modal.open({ title: UI.escHtml(ev.titel), body, footer });
document.getElementById('ev-detail-edit')?.addEventListener('click', () => {
UI.modal.close(); setTimeout(() => _openForm(ev), 50);
});
document.getElementById('ev-detail-del')?.addEventListener('click', () => _deleteEvent(ev));
}
async function _deleteEvent(ev) {
if (!confirm(`"${ev.titel}" wirklich löschen?`)) return;
try {
await API.events.delete(ev.id);
UI.modal.close();
UI.toast('Event gelöscht.');
await _load();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ----------------------------------------------------------
// Erstellen / Bearbeiten
// ----------------------------------------------------------
function openNew() { _openForm(null); }
function _openForm(ev) {
const isEdit = !!ev;
const id = 'ev-form';
const body = `
<form id="${id}">
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" name="titel" required value="${ev ? UI.escHtml(ev.titel) : ''}">
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${ev?.datum || ''}">
</div>
<div class="form-group">
<label class="form-label">Uhrzeit</label>
<input class="form-control" type="time" name="uhrzeit" value="${ev?.uhrzeit || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Typ *</label>
<select class="form-control" name="typ">
${TYPEN.filter(t => t.id !== 'alle').map(t =>
`<option value="${t.id}" ${ev?.typ === t.id ? 'selected' : ''}>${t.icon} ${t.label}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Ort / Veranstaltungsort</label>
<input class="form-control" name="ort_name" placeholder="z.B. Stadtpark München" value="${ev?.ort_name || ''}">
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label">Breitengrad</label>
<input class="form-control" type="number" step="any" name="lat" id="ev-lat" placeholder="48.1234" value="${ev?.lat || ''}">
</div>
<div class="form-group">
<label class="form-label">Längengrad</label>
<input class="form-control" type="number" step="any" name="lon" id="ev-lon" placeholder="11.5678" value="${ev?.lon || ''}">
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="ev-gps-btn">📍 GPS-Position</button>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
</div>
<div class="form-group">
<label class="form-label">Link / Website</label>
<input class="form-control" type="url" name="link" placeholder="https://..." value="${ev?.link || ''}">
</div>
</form>
`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn">
${isEdit ? 'Speichern' : 'Event erstellen'}
</button>
`;
UI.modal.open({ title: isEdit ? 'Event bearbeiten' : 'Neues Event', body, footer });
document.getElementById('ev-gps-btn')?.addEventListener('click', async () => {
try {
const pos = await API.getLocation();
document.getElementById('ev-lat').value = pos.lat.toFixed(6);
document.getElementById('ev-lon').value = pos.lon.toFixed(6);
} catch { UI.toast('GPS nicht verfügbar.', 'error'); }
});
const form = document.getElementById(id);
const submitBtn = document.querySelector(`[form="${id}"][type="submit"]`) || form.querySelector('[type="submit"]');
form.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(form);
const data = {
titel: fd.get('titel'),
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit') || null,
typ: fd.get('typ'),
ort_name: fd.get('ort_name') || null,
lat: fd.get('lat') ? parseFloat(fd.get('lat')) : null,
lon: fd.get('lon') ? parseFloat(fd.get('lon')) : null,
beschreibung: fd.get('beschreibung') || null,
link: fd.get('link') || null,
};
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; }
try {
isEdit ? await API.events.update(ev.id, data) : await API.events.create(data);
UI.modal.close();
UI.toast(isEdit ? 'Event aktualisiert.' : 'Event erstellt!');
await _load();
} catch (err) {
UI.toast(err.message, 'error');
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = isEdit ? 'Speichern' : 'Event erstellen'; }
}
});
}
// ----------------------------------------------------------
// Click-Handler
// ----------------------------------------------------------
function _onClick(e) {
// Filter
const filterBtn = e.target.closest('[data-ev-typ]');
if (filterBtn) {
_filter = filterBtn.dataset.evTyp;
document.querySelectorAll('[data-ev-typ]').forEach(b => b.classList.toggle('active', b.dataset.evTyp === _filter));
_renderList();
return;
}
// View-Toggle
const viewBtn = e.target.closest('[data-ev-view]');
if (viewBtn) {
_view = viewBtn.dataset.evView;
document.querySelectorAll('[data-ev-view]').forEach(b => b.classList.toggle('active', b.dataset.evView === _view));
const listEl = document.getElementById('ev-list');
const mapEl = document.getElementById('ev-map');
if (_view === 'karte') {
listEl.style.display = 'none';
mapEl.style.display = 'block';
const filtered = _filter === 'alle' ? _events : _events.filter(ev => ev.typ === _filter);
_renderMap(filtered);
} else {
listEl.style.display = '';
mapEl.style.display = 'none';
}
return;
}
// Neu-Button
if (e.target.closest('#ev-new-btn')) { openNew(); return; }
// Bearbeiten-Icon auf Karte
const editBtn = e.target.closest('[data-ev-edit]');
if (editBtn) {
e.stopPropagation();
const id = parseInt(editBtn.dataset.evEdit);
const ev = _events.find(x => x.id === id);
if (ev) _openForm(ev);
return;
}
// Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); }
}
return { init, refresh, openNew };
})();